diff options
Diffstat (limited to 'apps/dav/lib/CalDAV')
98 files changed, 13922 insertions, 2863 deletions
diff --git a/apps/dav/lib/CalDAV/Activity/Backend.php b/apps/dav/lib/CalDAV/Activity/Backend.php index 9f929dc195b..f0c49e6e28c 100644 --- a/apps/dav/lib/CalDAV/Activity/Backend.php +++ b/apps/dav/lib/CalDAV/Activity/Backend.php @@ -1,37 +1,21 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.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: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\DAV\CalDAV\Activity; - use OCA\DAV\CalDAV\Activity\Provider\Calendar; use OCA\DAV\CalDAV\Activity\Provider\Event; +use OCA\DAV\CalDAV\CalDavBackend; use OCP\Activity\IEvent; use OCP\Activity\IManager as IActivityManager; +use OCP\App\IAppManager; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; +use OCP\IUserManager; use OCP\IUserSession; use Sabre\VObject\Reader; @@ -42,24 +26,13 @@ use Sabre\VObject\Reader; */ class Backend { - /** @var IActivityManager */ - protected $activityManager; - - /** @var IGroupManager */ - protected $groupManager; - - /** @var IUserSession */ - protected $userSession; - - /** - * @param IActivityManager $activityManager - * @param IGroupManager $groupManager - * @param IUserSession $userSession - */ - public function __construct(IActivityManager $activityManager, IGroupManager $groupManager, IUserSession $userSession) { - $this->activityManager = $activityManager; - $this->groupManager = $groupManager; - $this->userSession = $userSession; + public function __construct( + protected IActivityManager $activityManager, + protected IGroupManager $groupManager, + protected IUserSession $userSession, + protected IAppManager $appManager, + protected IUserManager $userManager, + ) { } /** @@ -83,12 +56,32 @@ class Backend { } /** + * Creates activities when a calendar was moved to trash + * + * @param array $calendarData + * @param array $shares + */ + public function onCalendarMovedToTrash(array $calendarData, array $shares): void { + $this->triggerCalendarActivity(Calendar::SUBJECT_MOVE_TO_TRASH, $calendarData, $shares); + } + + /** + * Creates activities when a calendar was restored + * + * @param array $calendarData + * @param array $shares + */ + public function onCalendarRestored(array $calendarData, array $shares): void { + $this->triggerCalendarActivity(Calendar::SUBJECT_RESTORE, $calendarData, $shares); + } + + /** * Creates activities when a calendar was deleted * * @param array $calendarData * @param array $shares */ - public function onCalendarDelete(array $calendarData, array $shares) { + public function onCalendarDelete(array $calendarData, array $shares): void { $this->triggerCalendarActivity(Calendar::SUBJECT_DELETE, $calendarData, $shares); } @@ -98,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); } @@ -127,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); @@ -144,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'], ], @@ -181,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); @@ -206,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'], ], @@ -215,7 +213,7 @@ class Backend { if ($owner === $event->getAuthor()) { $subject = Calendar::SUBJECT_UNSHARE_USER . '_you'; - } else if ($principal[2] === $event->getAuthor()) { + } elseif ($principal[2] === $event->getAuthor()) { $subject = Calendar::SUBJECT_UNSHARE_USER . '_self'; } else { $event->setAffectedUser($event->getAuthor()) @@ -229,13 +227,13 @@ class Backend { ->setSubject($subject, $parameters); $this->activityManager->publish($event); } - } else if ($principal[1] === 'groups') { + } elseif ($principal[1] === 'groups') { $this->triggerActivityGroup($principal[2], $event, $calendarData, Calendar::SUBJECT_UNSHARE_USER); $parameters = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -277,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'], ], @@ -298,13 +296,13 @@ class Backend { ->setSubject($subject, $parameters); $this->activityManager->publish($event); } - } else if ($principal[1] === 'groups') { + } elseif ($principal[1] === 'groups') { $this->triggerActivityGroup($principal[2], $event, $calendarData, Calendar::SUBJECT_SHARE_USER); $parameters = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -382,7 +380,7 @@ class Backend { [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $properties['id'], + 'id' => (int)$properties['id'], 'uri' => $properties['uri'], 'name' => $properties['{DAV:}displayname'], ], @@ -415,53 +413,171 @@ class Backend { $currentUser = $owner; } + $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'; - } else if ($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); $users = $this->getUsersForShares($shares); $users[] = $owner; - foreach ($users as $user) { + // 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 !== $owner) { + // Private events are only shown to the owner + continue; + } + + $params = [ + 'actor' => $event->getAuthor(), + 'calendar' => [ + 'id' => (int)$calendarData['id'], + 'uri' => $calendarData['uri'], + 'name' => $calendarData['{DAV:}displayname'], + ], + 'object' => [ + 'id' => $object['id'], + 'name' => $classification === CalDavBackend::CLASSIFICATION_CONFIDENTIAL && $user !== $owner ? 'Busy' : $object['name'], + 'classified' => $classification === CalDavBackend::CLASSIFICATION_CONFIDENTIAL && $user !== $owner, + ], + ]; + + 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; + } + + $event->setAffectedUser($user) ->setSubject( $user === $currentUser ? $action . '_self' : $action, - [ - 'actor' => $event->getAuthor(), - 'calendar' => [ - 'id' => (int) $calendarData['id'], - 'uri' => $calendarData['uri'], - 'name' => $calendarData['{DAV:}displayname'], - ], - 'object' => [ - 'id' => $object['id'], - 'name' => $object['name'], - ], - ] + $params ); + + $this->activityManager->publish($event); + } + } + + /** + * 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']); $component = $componentType = null; - foreach($vObject->getComponents() as $component) { + foreach ($vObject->getComponents() as $component) { if (in_array($component->name, ['VEVENT', 'VTODO'])) { $componentType = $component->name; break; @@ -474,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]; } /** @@ -488,11 +604,11 @@ class Backend { protected function getUsersForShares(array $shares) { $users = $groups = []; foreach ($shares as $share) { - $prinical = explode('/', $share['{http://owncloud.org/ns}principal']); - if ($prinical[1] === 'users') { - $users[] = $prinical[2]; - } else if ($prinical[1] === 'groups') { - $groups[] = $prinical[2]; + $principal = explode('/', $share['{http://owncloud.org/ns}principal']); + if ($principal[1] === 'users') { + $users[] = $principal[2]; + } elseif ($principal[1] === 'groups') { + $groups[] = $principal[2]; } } diff --git a/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php b/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php index 7456074915b..78579ee84b7 100644 --- a/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php @@ -1,44 +1,21 @@ <?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; - use OCP\Activity\IFilter; use OCP\IL10N; 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, + ) { } /** @@ -59,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() { @@ -72,7 +49,7 @@ class Calendar implements IFilter { * @since 11.0.0 */ public function getIcon() { - return $this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar-dark.svg')); + return $this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar.svg')); } /** diff --git a/apps/dav/lib/CalDAV/Activity/Filter/Todo.php b/apps/dav/lib/CalDAV/Activity/Filter/Todo.php index 5bc08c8f2dd..b001f90c28d 100644 --- a/apps/dav/lib/CalDAV/Activity/Filter/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Filter/Todo.php @@ -1,44 +1,21 @@ <?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; - use OCP\Activity\IFilter; use OCP\IL10N; 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, + ) { } /** @@ -54,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 b6d8a5be736..558abe0ca1a 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Base.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Base.php @@ -1,80 +1,38 @@ <?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\Provider; use OCA\DAV\CalDAV\CalDavBackend; use OCP\Activity\IEvent; use OCP\Activity\IProvider; +use OCP\IGroup; +use OCP\IGroupManager; use OCP\IL10N; -use OCP\IUser; +use OCP\IURLGenerator; use OCP\IUserManager; abstract class Base implements IProvider { - - /** @var IUserManager */ - protected $userManager; - - /** @var string[] cached displayNames - key is the UID and value the displayname */ - protected $displayNames = []; + /** @var string[] */ + protected $groupDisplayNames = []; /** * @param IUserManager $userManager + * @param IGroupManager $groupManager + * @param IURLGenerator $url */ - public function __construct(IUserManager $userManager) { - $this->userManager = $userManager; - } - - /** - * @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); + public function __construct( + protected IUserManager $userManager, + protected IGroupManager $groupManager, + protected IURLGenerator $url, + ) { } - /** - * @param array $eventData - * @return array - */ - protected function generateObjectParameter($eventData) { - if (!is_array($eventData) || !isset($eventData['id']) || !isset($eventData['name'])) { - throw new \InvalidArgumentException(); - } - - return [ - 'type' => 'calendar-event', - 'id' => $eventData['id'], - 'name' => $eventData['name'], - ]; + 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,49 +65,44 @@ abstract class Base implements IProvider { protected function generateLegacyCalendarParameter($id, $name) { return [ 'type' => 'calendar', - 'id' => $id, + 'id' => (string)$id, 'name' => $name, ]; } - /** - * @param string $id - * @return array - */ - protected function generateGroupParameter($id) { + protected function generateUserParameter(string $uid): array { return [ - 'type' => 'group', - 'id' => $id, - 'name' => $id, + 'type' => 'user', + 'id' => $uid, + 'name' => $this->userManager->getDisplayName($uid) ?? $uid, ]; } /** - * @param string $uid + * @param string $gid * @return array */ - protected function generateUserParameter($uid) { - if (!isset($this->displayNames[$uid])) { - $this->displayNames[$uid] = $this->getDisplayName($uid); + protected function generateGroupParameter($gid) { + if (!isset($this->groupDisplayNames[$gid])) { + $this->groupDisplayNames[$gid] = $this->getGroupDisplayName($gid); } return [ - 'type' => 'user', - 'id' => $uid, - 'name' => $this->displayNames[$uid], + 'type' => 'user-group', + 'id' => $gid, + 'name' => $this->groupDisplayNames[$gid], ]; } /** - * @param string $uid + * @param string $gid * @return string */ - protected function getDisplayName($uid) { - $user = $this->userManager->get($uid); - if ($user instanceof IUser) { - return $user->getDisplayName(); - } else { - return $uid; + protected function getGroupDisplayName($gid) { + $group = $this->groupManager->get($gid); + if ($group instanceof IGroup) { + return $group->getDisplayName(); } + return $gid; } } diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php index ff129144285..8c93ddae431 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php @@ -1,76 +1,54 @@ <?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\Provider; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; use OCP\Activity\IEventMerger; use OCP\Activity\IManager; +use OCP\IGroupManager; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUserManager; use OCP\L10N\IFactory; class Calendar extends Base { - - const SUBJECT_ADD = 'calendar_add'; - const SUBJECT_UPDATE = 'calendar_update'; - const SUBJECT_DELETE = 'calendar_delete'; - const SUBJECT_PUBLISH = 'calendar_publish'; - const SUBJECT_UNPUBLISH = 'calendar_unpublish'; - const SUBJECT_SHARE_USER = 'calendar_user_share'; - const SUBJECT_SHARE_GROUP = 'calendar_group_share'; - const SUBJECT_UNSHARE_USER = 'calendar_user_unshare'; - const SUBJECT_UNSHARE_GROUP = 'calendar_group_unshare'; - - /** @var IFactory */ - protected $languageFactory; + public const SUBJECT_ADD = 'calendar_add'; + public const SUBJECT_UPDATE = 'calendar_update'; + public const SUBJECT_MOVE_TO_TRASH = 'calendar_move_to_trash'; + public const SUBJECT_RESTORE = 'calendar_restore'; + public const SUBJECT_DELETE = 'calendar_delete'; + public const SUBJECT_PUBLISH = 'calendar_publish'; + public const SUBJECT_UNPUBLISH = 'calendar_unpublish'; + public const SUBJECT_SHARE_USER = 'calendar_user_share'; + public const SUBJECT_SHARE_GROUP = 'calendar_group_share'; + public const SUBJECT_UNSHARE_USER = 'calendar_user_unshare'; + public const SUBJECT_UNSHARE_GROUP = 'calendar_group_unshare'; /** @var IL10N */ protected $l; - /** @var IURLGenerator */ - protected $url; - - /** @var IManager */ - protected $activityManager; - - /** @var IEventMerger */ - protected $eventMerger; - /** * @param IFactory $languageFactory * @param IURLGenerator $url * @param IManager $activityManager * @param IUserManager $userManager + * @param IGroupManager $groupManager * @param IEventMerger $eventMerger */ - public function __construct(IFactory $languageFactory, IURLGenerator $url, IManager $activityManager, IUserManager $userManager, IEventMerger $eventMerger) { - parent::__construct($userManager); - $this->languageFactory = $languageFactory; - $this->url = $url; - $this->activityManager = $activityManager; - $this->eventMerger = $eventMerger; + public function __construct( + protected IFactory $languageFactory, + IURLGenerator $url, + protected IManager $activityManager, + IUserManager $userManager, + IGroupManager $groupManager, + protected IEventMerger $eventMerger, + ) { + parent::__construct($userManager, $groupManager, $url); } /** @@ -78,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); @@ -91,52 +69,57 @@ class Calendar extends Base { if ($this->activityManager->getRequirePNG()) { $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar-dark.png'))); } else { - $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar-dark.svg'))); + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar.svg'))); } if ($event->getSubject() === self::SUBJECT_ADD) { $subject = $this->l->t('{actor} created calendar {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_ADD . '_self') { + } elseif ($event->getSubject() === self::SUBJECT_ADD . '_self') { $subject = $this->l->t('You created calendar {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_DELETE) { + } elseif ($event->getSubject() === self::SUBJECT_DELETE) { $subject = $this->l->t('{actor} deleted calendar {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_DELETE . '_self') { + } elseif ($event->getSubject() === self::SUBJECT_DELETE . '_self') { $subject = $this->l->t('You deleted calendar {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_UPDATE) { + } elseif ($event->getSubject() === self::SUBJECT_UPDATE) { $subject = $this->l->t('{actor} updated calendar {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_UPDATE . '_self') { + } elseif ($event->getSubject() === self::SUBJECT_UPDATE . '_self') { $subject = $this->l->t('You updated calendar {calendar}'); - - } else if ($event->getSubject() === self::SUBJECT_PUBLISH . '_self') { + } elseif ($event->getSubject() === self::SUBJECT_MOVE_TO_TRASH) { + $subject = $this->l->t('{actor} deleted calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_MOVE_TO_TRASH . '_self') { + $subject = $this->l->t('You deleted calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_RESTORE) { + $subject = $this->l->t('{actor} restored calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_RESTORE . '_self') { + $subject = $this->l->t('You restored calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_PUBLISH . '_self') { $subject = $this->l->t('You shared calendar {calendar} as public link'); - } else if ($event->getSubject() === self::SUBJECT_UNPUBLISH . '_self') { + } elseif ($event->getSubject() === self::SUBJECT_UNPUBLISH . '_self') { $subject = $this->l->t('You removed public link for calendar {calendar}'); - - } else if ($event->getSubject() === self::SUBJECT_SHARE_USER) { + } elseif ($event->getSubject() === self::SUBJECT_SHARE_USER) { $subject = $this->l->t('{actor} shared calendar {calendar} with you'); - } else if ($event->getSubject() === self::SUBJECT_SHARE_USER . '_you') { + } elseif ($event->getSubject() === self::SUBJECT_SHARE_USER . '_you') { $subject = $this->l->t('You shared calendar {calendar} with {user}'); - } else if ($event->getSubject() === self::SUBJECT_SHARE_USER . '_by') { + } elseif ($event->getSubject() === self::SUBJECT_SHARE_USER . '_by') { $subject = $this->l->t('{actor} shared calendar {calendar} with {user}'); - } else if ($event->getSubject() === self::SUBJECT_UNSHARE_USER) { + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER) { $subject = $this->l->t('{actor} unshared calendar {calendar} from you'); - } else if ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_you') { + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_you') { $subject = $this->l->t('You unshared calendar {calendar} from {user}'); - } else if ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_by') { + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_by') { $subject = $this->l->t('{actor} unshared calendar {calendar} from {user}'); - } else if ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_self') { + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_self') { $subject = $this->l->t('{actor} unshared calendar {calendar} from themselves'); - - } else if ($event->getSubject() === self::SUBJECT_SHARE_GROUP . '_you') { + } elseif ($event->getSubject() === self::SUBJECT_SHARE_GROUP . '_you') { $subject = $this->l->t('You shared calendar {calendar} with group {group}'); - } else if ($event->getSubject() === self::SUBJECT_SHARE_GROUP . '_by') { + } elseif ($event->getSubject() === self::SUBJECT_SHARE_GROUP . '_by') { $subject = $this->l->t('{actor} shared calendar {calendar} with group {group}'); - } else if ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_you') { + } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_you') { $subject = $this->l->t('You unshared calendar {calendar} from group {group}'); - } else if ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_by') { + } 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); @@ -148,7 +131,7 @@ class Calendar extends Base { if (isset($parsedParameters['user'])) { // Couldn't group by calendar, maybe we can group by users $event = $this->eventMerger->mergeEvents('user', $event, $previousEvent); - } else if (isset($parsedParameters['group'])) { + } elseif (isset($parsedParameters['group'])) { // Couldn't group by calendar, maybe we can group by groups $event = $this->eventMerger->mergeEvents('group', $event, $previousEvent); } @@ -174,6 +157,12 @@ class Calendar extends Base { case self::SUBJECT_DELETE . '_self': case self::SUBJECT_UPDATE: case self::SUBJECT_UPDATE . '_self': + case self::SUBJECT_MOVE_TO_TRASH: + case self::SUBJECT_MOVE_TO_TRASH . '_self': + case self::SUBJECT_RESTORE: + case self::SUBJECT_RESTORE . '_self': + case self::SUBJECT_PUBLISH . '_self': + case self::SUBJECT_UNPUBLISH . '_self': case self::SUBJECT_SHARE_USER: case self::SUBJECT_UNSHARE_USER: case self::SUBJECT_UNSHARE_USER . '_self': @@ -229,32 +218,32 @@ class Calendar extends Base { case self::SUBJECT_UNSHARE_USER . '_self': return [ 'actor' => $this->generateUserParameter($parameters[0]), - 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]), + 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), ]; case self::SUBJECT_SHARE_USER . '_you': case self::SUBJECT_UNSHARE_USER . '_you': return [ 'user' => $this->generateUserParameter($parameters[0]), - 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]), + 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), ]; case self::SUBJECT_SHARE_USER . '_by': case self::SUBJECT_UNSHARE_USER . '_by': return [ 'user' => $this->generateUserParameter($parameters[0]), - 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]), + 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), 'actor' => $this->generateUserParameter($parameters[2]), ]; case self::SUBJECT_SHARE_GROUP . '_you': case self::SUBJECT_UNSHARE_GROUP . '_you': return [ 'group' => $this->generateGroupParameter($parameters[0]), - 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]), + 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), ]; case self::SUBJECT_SHARE_GROUP . '_by': case self::SUBJECT_UNSHARE_GROUP . '_by': return [ 'group' => $this->generateGroupParameter($parameters[0]), - 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]), + 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), 'actor' => $this->generateUserParameter($parameters[2]), ]; } diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Event.php b/apps/dav/lib/CalDAV/Activity/Provider/Event.php index eabd2e517c0..87551d7840b 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Event.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Event.php @@ -1,70 +1,92 @@ <?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\Provider; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; use OCP\Activity\IEventMerger; use OCP\Activity\IManager; +use OCP\App\IAppManager; +use OCP\IGroupManager; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUserManager; use OCP\L10N\IFactory; class Event extends Base { - - const SUBJECT_OBJECT_ADD = 'object_add'; - const SUBJECT_OBJECT_UPDATE = 'object_update'; - const SUBJECT_OBJECT_DELETE = 'object_delete'; - - /** @var IFactory */ - protected $languageFactory; + 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 IL10N */ protected $l; - /** @var IURLGenerator */ - protected $url; - - /** @var IManager */ - protected $activityManager; - - /** @var IEventMerger */ - protected $eventMerger; - /** * @param IFactory $languageFactory * @param IURLGenerator $url * @param IManager $activityManager * @param IUserManager $userManager + * @param IGroupManager $groupManager * @param IEventMerger $eventMerger + * @param IAppManager $appManager */ - public function __construct(IFactory $languageFactory, IURLGenerator $url, IManager $activityManager, IUserManager $userManager, IEventMerger $eventMerger) { - parent::__construct($userManager); - $this->languageFactory = $languageFactory; - $this->url = $url; - $this->activityManager = $activityManager; - $this->eventMerger = $eventMerger; + 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); + } + + /** + * @param array $eventData + * @return array + */ + protected function generateObjectParameter(array $eventData, string $affectedUser): array { + if (!isset($eventData['id']) || !isset($eventData['name'])) { + throw new \InvalidArgumentException(); + } + + $params = [ + 'type' => 'calendar-event', + 'id' => $eventData['id'], + '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 + $this->appManager->loadApp('calendar'); + $linkData = $eventData['link']; + $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, + ]); + } catch (\Exception $error) { + // Do nothing + } + } + return $params; } /** @@ -72,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); @@ -85,23 +107,35 @@ class Event extends Base { if ($this->activityManager->getRequirePNG()) { $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar-dark.png'))); } else { - $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar-dark.svg'))); + $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar.svg'))); } if ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_event') { $subject = $this->l->t('{actor} created event {event} in calendar {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_event_self') { + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_event_self') { $subject = $this->l->t('You created event {event} in calendar {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_event') { + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_event') { $subject = $this->l->t('{actor} deleted event {event} from calendar {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_event_self') { + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_event_self') { $subject = $this->l->t('You deleted event {event} from calendar {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_event') { + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_event') { $subject = $this->l->t('{actor} updated event {event} in calendar {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_event_self') { + } 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') { + $subject = $this->l->t('You deleted event {event} from calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_RESTORE . '_event') { + $subject = $this->l->t('{actor} restored event {event} of calendar {calendar}'); + } 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); @@ -126,17 +160,39 @@ class Event extends Base { case self::SUBJECT_OBJECT_ADD . '_event': case self::SUBJECT_OBJECT_DELETE . '_event': case self::SUBJECT_OBJECT_UPDATE . '_event': + case self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event': + case self::SUBJECT_OBJECT_RESTORE . '_event': return [ 'actor' => $this->generateUserParameter($parameters['actor']), 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), - 'event' => $this->generateObjectParameter($parameters['object']), + 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()), ]; case self::SUBJECT_OBJECT_ADD . '_event_self': case self::SUBJECT_OBJECT_DELETE . '_event_self': case self::SUBJECT_OBJECT_UPDATE . '_event_self': + case self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event_self': + case self::SUBJECT_OBJECT_RESTORE . '_event_self': return [ 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), - 'event' => $this->generateObjectParameter($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()), ]; } } @@ -152,18 +208,38 @@ class Event extends Base { case self::SUBJECT_OBJECT_UPDATE . '_event': return [ 'actor' => $this->generateUserParameter($parameters[0]), - 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]), - 'event' => $this->generateObjectParameter($parameters[2]), + 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), + '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((int)$event->getObjectId(), $parameters[1]), - 'event' => $this->generateObjectParameter($parameters[2]), + 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), + 'event' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()), ]; } throw new \InvalidArgumentException(); } + + 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 7ee91184794..fc0625ec970 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Todo.php @@ -1,28 +1,12 @@ <?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\Provider; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; class Todo extends Event { @@ -32,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); @@ -49,28 +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}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_todo_self') { - $subject = $this->l->t('You created todo {todo} in list {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo') { - $subject = $this->l->t('{actor} deleted todo {todo} from list {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo_self') { - $subject = $this->l->t('You deleted todo {todo} from list {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo') { - $subject = $this->l->t('{actor} updated todo {todo} in list {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_self') { - $subject = $this->l->t('You updated todo {todo} in list {calendar}'); - - } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed') { - $subject = $this->l->t('{actor} solved todo {todo} in list {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed_self') { - $subject = $this->l->t('You solved todo {todo} in list {calendar}'); - } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action') { - $subject = $this->l->t('{actor} reopened todo {todo} in list {calendar}'); - } else if ($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('{actor} created to-do {todo} in list {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_todo_self') { + $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 to-do {todo} from list {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo_self') { + $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 to-do {todo} in list {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_self') { + $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 to-do {todo} in list {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed_self') { + $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 to-do {todo} in list {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self') { + $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()), ]; } } @@ -127,8 +132,8 @@ class Todo extends Event { case self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action': return [ 'actor' => $this->generateUserParameter($parameters[0]), - 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]), - 'todo' => $this->generateObjectParameter($parameters[2]), + 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), + 'todo' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()), ]; case self::SUBJECT_OBJECT_ADD . '_todo_self': case self::SUBJECT_OBJECT_DELETE . '_todo_self': @@ -136,8 +141,8 @@ class Todo extends Event { case self::SUBJECT_OBJECT_UPDATE . '_todo_completed_self': case self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self': return [ - 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]), - 'todo' => $this->generateObjectParameter($parameters[2]), + 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), + '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 new file mode 100644 index 00000000000..7ab7f16dbbb --- /dev/null +++ b/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Activity\Setting; + +use OCP\Activity\ActivitySettings; +use OCP\IL10N; + +abstract class CalDAVSetting extends ActivitySettings { + /** + * @param IL10N $l + */ + public function __construct( + protected IL10N $l, + ) { + } + + public function getGroupIdentifier() { + return 'calendar'; + } + + public function getGroupName() { + return $this->l->t('Calendar, contacts and tasks'); + } +} diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php b/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php index 24d2cef913e..0ad86a919bc 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php @@ -1,44 +1,12 @@ <?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\Setting; - -use OCP\Activity\ISetting; -use OCP\IL10N; - -class Calendar implements ISetting { - - /** @var IL10N */ - protected $l; - - /** - * @param IL10N $l - */ - public function __construct(IL10N $l) { - $this->l = $l; - } - +class Calendar extends CalDAVSetting { /** * @return string Lowercase a-z and underscore only identifier * @since 11.0.0 @@ -57,8 +25,8 @@ class Calendar implements ISetting { /** * @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() { @@ -97,4 +65,3 @@ class Calendar implements ISetting { return false; } } - diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Event.php b/apps/dav/lib/CalDAV/Activity/Setting/Event.php index 14b22ad220e..ea9476d6f08 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Event.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Event.php @@ -1,44 +1,12 @@ <?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\Setting; - -use OCP\Activity\ISetting; -use OCP\IL10N; - -class Event implements ISetting { - - /** @var IL10N */ - protected $l; - - /** - * @param IL10N $l - */ - public function __construct(IL10N $l) { - $this->l = $l; - } - +class Event extends CalDAVSetting { /** * @return string Lowercase a-z and underscore only identifier * @since 11.0.0 @@ -57,8 +25,8 @@ class Event implements ISetting { /** * @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() { @@ -97,4 +65,3 @@ class Event implements ISetting { return false; } } - diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Todo.php b/apps/dav/lib/CalDAV/Activity/Setting/Todo.php index 272843198a9..ed8377b0ffa 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Todo.php @@ -1,43 +1,12 @@ <?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\Setting; - -use OCP\Activity\ISetting; -use OCP\IL10N; - -class Todo implements ISetting { - - /** @var IL10N */ - protected $l; - - /** - * @param IL10N $l - */ - public function __construct(IL10N $l) { - $this->l = $l; - } +class Todo extends CalDAVSetting { /** * @return string Lowercase a-z and underscore only identifier @@ -52,13 +21,13 @@ class Todo implements ISetting { * @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() { @@ -97,4 +66,3 @@ class Todo implements ISetting { return false; } } - 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 new file mode 100644 index 00000000000..71b9acb939b --- /dev/null +++ b/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\Auth; + +use Sabre\DAV\Auth\Plugin; + +/** + * Set a custom principal uri to allow public requests to its calendar + */ +class CustomPrincipalPlugin extends Plugin { + public function setCurrentPrincipal(?string $currentPrincipal): void { + $this->currentPrincipal = $currentPrincipal; + } +} diff --git a/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php b/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php new file mode 100644 index 00000000000..ed89638451e --- /dev/null +++ b/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Auth; + +use Sabre\DAV\Auth\Plugin; + +/** + * Defines the public facing principal option + */ +class PublicPrincipalPlugin extends Plugin { + public function getCurrentPrincipal(): ?string { + return 'principals/system/public'; + } +} diff --git a/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php b/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php index 497d7112b3c..681709cdb6f 100644 --- a/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php +++ b/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php @@ -1,35 +1,20 @@ <?php + /** - * @copyright 2017, 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: 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; use Sabre\HTTP\ResponseInterface; -use OCP\IConfig; /** * Class EnablePlugin @@ -38,17 +23,7 @@ use OCP\IConfig; * @package OCA\DAV\CalDAV\BirthdayCalendar */ class EnablePlugin extends ServerPlugin { - const NS_Nextcloud = 'http://nextcloud.com/ns'; - - /** - * @var IConfig - */ - protected $config; - - /** - * @var BirthdayService - */ - protected $birthdayService; + public const NS_Nextcloud = 'http://nextcloud.com/ns'; /** * @var Server @@ -60,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, + ) { } /** @@ -86,7 +64,7 @@ class EnablePlugin extends ServerPlugin { * * @return string */ - public function getPluginName() { + public function getPluginName() { return 'nc-enable-birthday-calendar'; } @@ -116,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 62d218f0a2a..680b228766f 100644 --- a/apps/dav/lib/CalDAV/BirthdayService.php +++ b/apps/dav/lib/CalDAV/BirthdayService.php @@ -1,37 +1,20 @@ <?php + +declare(strict_types=1); + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2016, Georg Ehrke - * - * @author Achim Königs <garfonso@tratschtante.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CalDAV; use Exception; use OCA\DAV\CardDAV\CardDavBackend; use OCA\DAV\DAV\GroupPrincipalBackend; use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IL10N; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VCard; use Sabre\VObject\DateTimeParser; @@ -40,72 +23,66 @@ use Sabre\VObject\InvalidDataException; use Sabre\VObject\Property\VCard\DateAndOrTime; use Sabre\VObject\Reader; +/** + * Class BirthdayService + * + * @package OCA\DAV\CalDAV + */ class BirthdayService { - - const BIRTHDAY_CALENDAR_URI = 'contact_birthdays'; - - /** @var GroupPrincipalBackend */ - private $principalBackend; - - /** @var CalDavBackend */ - private $calDavBackEnd; - - /** @var CardDavBackend */ - private $cardDavBackEnd; - - /** @var IConfig */ - private $config; + public const BIRTHDAY_CALENDAR_URI = 'contact_birthdays'; + 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; */ - public function __construct(CalDavBackend $calDavBackEnd, CardDavBackend $cardDavBackEnd, GroupPrincipalBackend $principalBackend, IConfig $config) { - $this->calDavBackEnd = $calDavBackEnd; - $this->cardDavBackEnd = $cardDavBackEnd; - $this->principalBackend = $principalBackend; - $this->config = $config; + 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($addressBookId, $cardUri, $cardData) { + public function onCardChanged(int $addressBookId, + 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', 'symbol' => '*'], - ['postfix' => '-death', 'field' => 'DEATHDATE', 'symbol' => "†"], - ['postfix' => '-anniversary', 'field' => 'ANNIVERSARY', 'symbol' => "⚭"], + ['postfix' => '', 'field' => 'BDAY'], + ['postfix' => '-death', 'field' => 'DEATHDATE'], + ['postfix' => '-anniversary', 'field' => 'ANNIVERSARY'], ]; + foreach ($targetPrincipals as $principalUri) { if (!$this->isUserEnabled($principalUri)) { continue; } + $reminderOffset = $this->getReminderOffsetForUser($principalUri); + $calendar = $this->ensureCalendarExists($principalUri); + if ($calendar === null) { + return; + } foreach ($datesToSync as $type) { - $this->updateCalendar($cardUri, $cardData, $book, $calendar['id'], $type); + $this->updateCalendar($cardUri, $cardData, $book, (int)$calendar['id'], $type, $reminderOffset); } } } - /** - * @param int $addressBookId - * @param string $cardUri - */ - public function onCardDeleted($addressBookId, $cardUri) { + public function onCardDeleted(int $addressBookId, + string $cardUri): void { if (!$this->isGloballyEnabled()) { return; } @@ -120,38 +97,41 @@ class BirthdayService { $calendar = $this->ensureCalendarExists($principalUri); foreach (['', '-death', '-anniversary'] as $tag) { - $objectUri = $book['uri'] . '-' . $cardUri . $tag .'.ics'; - $this->calDavBackEnd->deleteCalendarObject($calendar['id'], $objectUri); + $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($principal) { - $book = $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI); - if (!is_null($book)) { - return $book; + public function ensureCalendarExists(string $principal): ?array { + $calendar = $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI); + if (!is_null($calendar)) { + return $calendar; } $this->calDavBackEnd->createCalendar($principal, self::BIRTHDAY_CALENDAR_URI, [ - '{DAV:}displayname' => 'Contact birthdays', - '{http://apple.com/ns/ical/}calendar-color' => '#FFFFCA', - 'components' => 'VEVENT', + '{DAV:}displayname' => $this->l10n->t('Contact birthdays'), + '{http://apple.com/ns/ical/}calendar-color' => '#E9D859', + 'components' => 'VEVENT', ]); return $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI); } /** - * @param string $cardData - * @param string $dateField - * @param string $summarySymbol - * @return null|VCalendar + * @param $cardData + * @param $dateField + * @param $postfix + * @param $reminderOffset + * @return VCalendar|null + * @throws InvalidDataException */ - public function buildDateFromContact($cardData, $dateField, $summarySymbol) { + public function buildDateFromContact(string $cardData, + string $dateField, + string $postfix, + ?string $reminderOffset):?VCalendar { if (empty($cardData)) { return null; } @@ -167,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; } @@ -188,27 +172,43 @@ 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; - if (!$dateParts['year']) { - $birthday = '1900-' . $dateParts['month'] . '-' . $dateParts['date']; + $originalYear = null; + if ($dateParts['year'] !== null) { + $originalYear = (int)$dateParts['year']; + } - $unknownYear = true; + $leapDay = ((int)$dateParts['month'] === 2 + && (int)$dateParts['date'] === 29); + if ($dateParts['year'] === null || $originalYear < 1970) { + $birthday = ($leapDay ? '1972-' : '1970-') + . $dateParts['month'] . '-' . $dateParts['date']; } try { - $date = new \DateTime($birthday); + if ($birthday instanceof DateAndOrTime) { + $date = $birthday->getDateTime(); + } else { + $date = new \DateTimeImmutable($birthday); + } } catch (Exception $e) { return null; } - if ($unknownYear) { - $summary = $doc->FN->getValue() . ' ' . $summarySymbol; - } else { - $year = (int)$date->format('Y'); - $summary = $doc->FN->getValue() . " ($summarySymbol$year)"; - } + + $summary = $this->formatTitle($dateField, $doc->FN->getValue(), $originalYear, $this->dbConnection->supports4ByteText()); + $vCal = new VCalendar(); $vCal->VERSION = '2.0'; + $vCal->PRODID = '-//IDN nextcloud.com//Birthday calendar//EN'; $vEvent = $vCal->createComponent('VEVENT'); $vEvent->add('DTSTART'); $vEvent->DTSTART->setDateTime( @@ -216,20 +216,35 @@ class BirthdayService { ); $vEvent->DTSTART['VALUE'] = 'DATE'; $vEvent->add('DTEND'); - $date->add(new \DateInterval('P1D')); + + $dtEndDate = (new \DateTime())->setTimestamp($date->getTimeStamp()); + $dtEndDate->add(new \DateInterval('P1D')); $vEvent->DTEND->setDateTime( - $date + $dtEndDate ); + $vEvent->DTEND['VALUE'] = 'DATE'; - $vEvent->{'UID'} = $doc->UID; + $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'; - $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); + $vEvent->{'X-NEXTCLOUD-BC-FIELD-TYPE'} = $dateField; + $vEvent->{'X-NEXTCLOUD-BC-UNKNOWN-YEAR'} = $dateParts['year'] === null ? '1' : '0'; + if ($originalYear !== null) { + $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); + } $vCal->add($vEvent); return $vCal; } @@ -237,14 +252,31 @@ class BirthdayService { /** * @param string $user */ - public function syncUser($user) { - $principal = 'principals/users/'.$user; + public function resetForUser(string $user):void { + $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) { + $this->calDavBackEnd->deleteCalendarObject($calendar['id'], $calendarObject['uri'], CalDavBackend::CALENDAR_TYPE_CALENDAR, true); + } + } + + /** + * @param string $user + * @throws \Sabre\DAV\Exception\BadRequest + */ + public function syncUser(string $user):void { + $principal = 'principals/users/' . $user; $this->ensureCalendarExists($principal); $books = $this->cardDavBackEnd->getAddressBooksForUser($principal); - foreach($books as $book) { + foreach ($books as $book) { $cards = $this->cardDavBackEnd->getCards($book['id']); - foreach($cards as $card) { - $this->onCardChanged($book['id'], $card['uri'], $card['carddata']); + foreach ($cards as $card) { + $this->onCardChanged((int)$book['id'], $card['uri'], $card['carddata']); } } } @@ -254,25 +286,25 @@ class BirthdayService { * @param VCalendar $newCalendarData * @return bool */ - public function birthdayEvenChanged($existingCalendarData, $newCalendarData) { + public function birthdayEvenChanged(string $existingCalendarData, + VCalendar $newCalendarData):bool { try { $existingBirthday = Reader::read($existingCalendarData); } catch (Exception $ex) { return true; } - if ($newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() || - $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue() - ) { - return true; - } - return false; + + return ( + $newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() + || $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue() + ); } /** * @param integer $addressBookId * @return mixed */ - protected function getAllAffectedPrincipals($addressBookId) { + protected function getAllAffectedPrincipals(int $addressBookId) { $targetPrincipals = []; $shares = $this->cardDavBackEnd->getShares($addressBookId); foreach ($shares as $share) { @@ -290,21 +322,43 @@ class BirthdayService { /** * @param string $cardUri - * @param string $cardData + * @param string $cardData * @param array $book * @param int $calendarId - * @param string[] $type + * @param array $type + * @param string $reminderOffset + * @throws InvalidDataException + * @throws \Sabre\DAV\Exception\BadRequest */ - private function updateCalendar($cardUri, $cardData, $book, $calendarId, $type) { + private function updateCalendar(string $cardUri, + 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['symbol']); + $calendarData = $this->buildDateFromContact($cardData, $type['field'], $type['postfix'], $reminderOffset); $existing = $this->calDavBackEnd->getCalendarObject($calendarId, $objectUri); - if (is_null($calendarData)) { - if (!is_null($existing)) { - $this->calDavBackEnd->deleteCalendarObject($calendarId, $objectUri); + if ($calendarData === null) { + if ($existing !== null) { + $this->calDavBackEnd->deleteCalendarObject($calendarId, $objectUri, CalDavBackend::CALENDAR_TYPE_CALENDAR, true); } } else { - if (is_null($existing)) { + if ($existing === null) { + // not found by URI, but maybe by UID + // happens when a contact with birthday is moved to a different address book + $calendarInfo = $this->calDavBackEnd->getCalendarById($calendarId); + $extraData = $this->calDavBackEnd->getDenormalizedData($calendarData->serialize()); + + if ($calendarInfo && array_key_exists('principaluri', $calendarInfo)) { + $existing2path = $this->calDavBackEnd->getCalendarObjectByUID($calendarInfo['principaluri'], $extraData['uid']); + if ($existing2path !== null && array_key_exists('uri', $calendarInfo)) { + // delete the old birthday entry first so that we do not get duplicate UIDs + $existing2objectUri = substr($existing2path, strlen($calendarInfo['uri']) + 1); + $this->calDavBackEnd->deleteCalendarObject($calendarId, $existing2objectUri, CalDavBackend::CALENDAR_TYPE_CALENDAR, true); + } + } + $this->calDavBackEnd->createCalendarObject($calendarId, $objectUri, $calendarData->serialize()); } else { if ($this->birthdayEvenChanged($existing['calendardata'], $calendarData)) { @@ -319,20 +373,32 @@ class BirthdayService { * * @return bool */ - private function isGloballyEnabled() { - $isGloballyEnabled = $this->config->getAppValue('dav', 'generateBirthdayCalendar', 'yes'); - return $isGloballyEnabled === 'yes'; + private function isGloballyEnabled():bool { + return $this->config->getAppValue('dav', 'generateBirthdayCalendar', 'yes') === 'yes'; } /** - * checks if the user opted-out of birthday calendars + * Extracts the userId part of a principal * - * @param $userPrincipal + * @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($userPrincipal) { - if (strpos($userPrincipal, 'principals/users/') === 0) { - $userId = substr($userPrincipal, 17); + private function isUserEnabled(string $userPrincipal):bool { + $userId = $this->principalToUserId($userPrincipal); + if ($userId !== null) { $isEnabled = $this->config->getUserValue($userId, 'dav', 'generateBirthdayCalendar', 'yes'); return $isEnabled === 'yes'; } @@ -341,4 +407,86 @@ class BirthdayService { return true; } + /** + * 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, ... + * @param string $name Name of contact + * @param int|null $year Year of birth, anniversary, ... + * @param bool $supports4Byte Whether or not the database supports 4 byte chars + * @return string The formatted title + */ + private function formatTitle(string $field, + string $name, + ?int $year = null, + bool $supports4Byte = true):string { + if ($supports4Byte) { + switch ($field) { + case 'BDAY': + return implode('', [ + '🎂 ', + $name, + $year ? (' (' . $year . ')') : '', + ]); + + case 'DEATHDATE': + return implode('', [ + $this->l10n->t('Death of %s', [$name]), + $year ? (' (' . $year . ')') : '', + ]); + + case 'ANNIVERSARY': + return implode('', [ + '💍 ', + $name, + $year ? (' (' . $year . ')') : '', + ]); + + default: + return ''; + } + } else { + switch ($field) { + case 'BDAY': + return implode('', [ + $name, + ' ', + $year ? ('(*' . $year . ')') : '*', + ]); + + case 'DEATHDATE': + return implode('', [ + $this->l10n->t('Death of %s', [$name]), + $year ? (' (' . $year . ')') : '', + ]); + + case 'ANNIVERSARY': + return implode('', [ + $name, + ' ', + $year ? ('(⚭' . $year . ')') : '⚭', + ]); + + default: + return ''; + } + } + } } diff --git a/apps/dav/lib/CalDAV/CachedSubscription.php b/apps/dav/lib/CalDAV/CachedSubscription.php new file mode 100644 index 00000000000..75ee5cb440f --- /dev/null +++ b/apps/dav/lib/CalDAV/CachedSubscription.php @@ -0,0 +1,196 @@ +<?php + +declare(strict_types=1); + +/** + * 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\DAV\Exception\MethodNotAllowed; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\INode; +use Sabre\DAV\PropPatch; + +/** + * Class CachedSubscription + * + * @package OCA\DAV\CalDAV + * @property CalDavBackend $caldavBackend + */ +class CachedSubscription extends \Sabre\CalDAV\Calendar { + + /** + * @return string + */ + public function getPrincipalURI():string { + return $this->calendarInfo['principaluri']; + } + + /** + * @return array + */ + public function getACL() { + return [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner() . '/calendar-proxy-write', + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner() . '/calendar-proxy-read', + 'protected' => true, + ], + [ + 'privilege' => '{' . Plugin::NS_CALDAV . '}read-free-busy', + 'principal' => '{DAV:}authenticated', + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner(), + 'protected' => true, + ] + ]; + } + + /** + * @return array + */ + public function getChildACL() { + return [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner() . '/calendar-proxy-write', + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner() . '/calendar-proxy-read', + 'protected' => true, + ], + ]; + } + + /** + * @return null|string + */ + public function getOwner() { + if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) { + return $this->calendarInfo['{http://owncloud.org/ns}owner-principal']; + } + return parent::getOwner(); + } + + + public function delete() { + $this->caldavBackend->deleteSubscription($this->calendarInfo['id']); + } + + /** + * @param PropPatch $propPatch + */ + public function propPatch(PropPatch $propPatch) { + $this->caldavBackend->updateSubscription($this->calendarInfo['id'], $propPatch); + } + + /** + * @param string $name + * @return CalendarObject|\Sabre\CalDAV\ICalendarObject + * @throws NotFound + */ + public function getChild($name) { + $obj = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $name, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + if (!$obj) { + throw new NotFound('Calendar object not found'); + } + + $obj['acl'] = $this->getChildACL(); + return new CachedSubscriptionObject($this->caldavBackend, $this->calendarInfo, $obj); + } + + /** + * @return INode[] + */ + public function getChildren(): array { + $objs = $this->caldavBackend->getCalendarObjects($this->calendarInfo['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + + $children = []; + foreach ($objs as $obj) { + $children[] = new CachedSubscriptionObject($this->caldavBackend, $this->calendarInfo, $obj); + } + + return $children; + } + + /** + * @param array $paths + * @return array + */ + public function getMultipleChildren(array $paths):array { + $objs = $this->caldavBackend->getMultipleCalendarObjects($this->calendarInfo['id'], $paths, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + + $children = []; + foreach ($objs as $obj) { + $children[] = new CachedSubscriptionObject($this->caldavBackend, $this->calendarInfo, $obj); + } + + return $children; + } + + /** + * @param string $name + * @param null|resource|string $data + * @return null|string + * @throws MethodNotAllowed + */ + public function createFile($name, $data = null) { + throw new MethodNotAllowed('Creating objects in cached subscription is not allowed'); + } + + /** + * @param string $name + * @return bool + */ + public function childExists($name):bool { + $obj = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $name, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + if (!$obj) { + return false; + } + + return true; + } + + /** + * @param array $filters + * @return array + */ + public function calendarQuery(array $filters):array { + return $this->caldavBackend->calendarQuery($this->calendarInfo['id'], $filters, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + } + + /** + * @inheritDoc + */ + public function getChanges($syncToken, $syncLevel, $limit = null) { + if (!$syncToken && $limit) { + throw new UnsupportedLimitOnInitialSyncException(); + } + + return parent::getChanges($syncToken, $syncLevel, $limit); + } +} 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 new file mode 100644 index 00000000000..dc9141a61b8 --- /dev/null +++ b/apps/dav/lib/CalDAV/CachedSubscriptionObject.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV; + +use Sabre\DAV\Exception\MethodNotAllowed; + +/** + * Class CachedSubscriptionObject + * + * @package OCA\DAV\CalDAV + * @property CalDavBackend $caldavBackend + */ +class CachedSubscriptionObject extends \Sabre\CalDAV\CalendarObject { + + /** + * @inheritdoc + */ + public function get() { + // Pre-populating the 'calendardata' is optional, if we don't have it + // already we fetch it from the backend. + if (!isset($this->objectData['calendardata'])) { + $this->objectData = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $this->objectData['uri'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + } + + return $this->objectData['calendardata']; + } + + /** + * @param resource|string $calendarData + * @return string + * @throws MethodNotAllowed + */ + public function put($calendarData) { + throw new MethodNotAllowed('Creating objects in a cached subscription is not allowed'); + } + + /** + * @throws MethodNotAllowed + */ + public function delete() { + throw new MethodNotAllowed('Deleting objects in a cached subscription is not allowed'); + } +} 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 169bf6ff6a5..d5b0d875ede 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -1,46 +1,52 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2017 Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author nhirokinet <nhirokinet@nhiroki.net> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Stefan Weil <sw@weilnetz.de> - * @author Thomas Citharel <tcit@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: 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\IShareable; +use OCA\DAV\Events\CachedCalendarObjectCreatedEvent; +use OCA\DAV\Events\CachedCalendarObjectDeletedEvent; +use OCA\DAV\Events\CachedCalendarObjectUpdatedEvent; +use OCA\DAV\Events\CalendarCreatedEvent; +use OCA\DAV\Events\CalendarDeletedEvent; +use OCA\DAV\Events\CalendarMovedToTrashEvent; +use OCA\DAV\Events\CalendarPublishedEvent; +use OCA\DAV\Events\CalendarRestoredEvent; +use OCA\DAV\Events\CalendarShareUpdatedEvent; +use OCA\DAV\Events\CalendarUnpublishedEvent; +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 OCA\DAV\Connector\Sabre\Principal; -use OCA\DAV\DAV\Sharing\Backend; +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; use Sabre\CalDAV\Backend\SubscriptionSupport; @@ -48,13 +54,13 @@ use Sabre\CalDAV\Backend\SyncSupport; use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp; use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet; use Sabre\DAV; +use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\PropPatch; -use Sabre\HTTP\URLUtil; +use Sabre\Uri; use Sabre\VObject\Component; use Sabre\VObject\Component\VCalendar; -use Sabre\VObject\Component\VEvent; use Sabre\VObject\Component\VTimeZone; use Sabre\VObject\DateTimeParser; use Sabre\VObject\InvalidDataException; @@ -62,9 +68,22 @@ use Sabre\VObject\ParseException; use Sabre\VObject\Property; use Sabre\VObject\Reader; use Sabre\VObject\Recur\EventIterator; -use Sabre\Uri; -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; +use function is_array; +use function is_resource; +use function pathinfo; +use function rewind; +use function settype; +use function sprintf; +use function str_replace; +use function strtolower; +use function time; /** * Class CalDavBackend @@ -72,11 +91,31 @@ use Symfony\Component\EventDispatcher\GenericEvent; * 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; - const PERSONAL_CALENDAR_URI = 'personal'; - const PERSONAL_CALENDAR_NAME = 'Personal'; + public const CALENDAR_TYPE_CALENDAR = 0; + public const CALENDAR_TYPE_SUBSCRIPTION = 1; + + public const PERSONAL_CALENDAR_URI = 'personal'; + public const PERSONAL_CALENDAR_NAME = 'Personal'; + + public const RESOURCE_BOOKING_CALENDAR_URI = 'calendar'; + public const RESOURCE_BOOKING_CALENDAR_NAME = 'Calendar'; /** * We need to specify a max date, because we need to stop *somewhere* @@ -86,27 +125,27 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * in 2038-01-19 to avoid problems when the date is converted * to a unix timestamp. */ - const MAX_DATE = '2038-01-01'; + public const MAX_DATE = '2038-01-01'; - const ACCESS_PUBLIC = 4; - const CLASSIFICATION_PUBLIC = 0; - const CLASSIFICATION_PRIVATE = 1; - const CLASSIFICATION_CONFIDENTIAL = 2; + public const ACCESS_PUBLIC = 4; + public const CLASSIFICATION_PUBLIC = 0; + public const CLASSIFICATION_PRIVATE = 1; + public const CLASSIFICATION_CONFIDENTIAL = 2; /** - * List of CalDAV properties, and how they map to database field names + * List of CalDAV properties, and how they map to database field names and their type * Add your own properties by simply adding on to this array. * - * Note that only string-based properties are supported here. - * * @var array + * @psalm-var array<string, string[]> */ - public $propertyMap = [ - '{DAV:}displayname' => 'displayname', - '{urn:ietf:params:xml:ns:caldav}calendar-description' => 'description', - '{urn:ietf:params:xml:ns:caldav}calendar-timezone' => 'timezone', - '{http://apple.com/ns/ical/}calendar-order' => 'calendarorder', - '{http://apple.com/ns/ical/}calendar-color' => 'calendarcolor', + 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'], + '{http://apple.com/ns/ical/}calendar-order' => ['calendarorder', 'int'], + '{http://apple.com/ns/ical/}calendar-color' => ['calendarcolor', 'string'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => ['deleted_at', 'int'], ]; /** @@ -114,23 +153,38 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * * @var array */ - public $subscriptionPropertyMap = [ - '{DAV:}displayname' => 'displayname', - '{http://apple.com/ns/ical/}refreshrate' => 'refreshrate', - '{http://apple.com/ns/ical/}calendar-order' => 'calendarorder', - '{http://apple.com/ns/ical/}calendar-color' => 'calendarcolor', - '{http://calendarserver.org/ns/}subscribed-strip-todos' => 'striptodos', - '{http://calendarserver.org/ns/}subscribed-strip-alarms' => 'stripalarms', - '{http://calendarserver.org/ns/}subscribed-strip-attachments' => 'stripattachments', + public array $subscriptionPropertyMap = [ + '{DAV:}displayname' => ['displayname', 'string'], + '{http://apple.com/ns/ical/}refreshrate' => ['refreshrate', 'string'], + '{http://apple.com/ns/ical/}calendar-order' => ['calendarorder', 'int'], + '{http://apple.com/ns/ical/}calendar-color' => ['calendarcolor', 'string'], + '{http://calendarserver.org/ns/}subscribed-strip-todos' => ['striptodos', 'bool'], + '{http://calendarserver.org/ns/}subscribed-strip-alarms' => ['stripalarms', 'string'], + '{http://calendarserver.org/ns/}subscribed-strip-attachments' => ['stripattachments', 'string'], ]; - /** @var array properties to index */ - public static $indexProperties = ['CATEGORIES', 'COMMENT', 'DESCRIPTION', - 'LOCATION', 'RESOURCES', 'STATUS', 'SUMMARY', 'ATTENDEE', 'CONTACT', - 'ORGANIZER']; + /** + * properties to index + * + * This list has to be kept in sync with ICalendarQuery::SEARCH_PROPERTY_* + * + * @see \OCP\Calendar\ICalendarQuery + */ + private const INDEXED_PROPERTIES = [ + 'CATEGORIES', + 'COMMENT', + 'DESCRIPTION', + 'LOCATION', + 'RESOURCES', + 'STATUS', + 'SUMMARY', + 'ATTENDEE', + 'CONTACT', + 'ORGANIZER' + ]; /** @var array parameters to index */ - public static $indexParameters = [ + public static array $indexParameters = [ 'ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN'], ]; @@ -138,86 +192,95 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** * @var string[] Map of uid => display name */ - protected $userDisplayNames; - - /** @var IDBConnection */ - private $db; - - /** @var Backend */ - private $sharingBackend; - - /** @var Principal */ - private $principalBackend; - - /** @var IUserManager */ - private $userManager; - - /** @var ISecureRandom */ - private $random; - - /** @var ILogger */ - private $logger; - - /** @var EventDispatcherInterface */ - private $dispatcher; - - /** @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 EventDispatcherInterface $dispatcher - * @param bool $legacyEndpoint - */ - public function __construct(IDBConnection $db, - Principal $principalBackend, - IUserManager $userManager, - IGroupManager $groupManager, - ISecureRandom $random, - ILogger $logger, - EventDispatcherInterface $dispatcher, - $legacyEndpoint = false) { - $this->db = $db; - $this->principalBackend = $principalBackend; - $this->userManager = $userManager; - $this->sharingBackend = new Backend($this->db, $this->userManager, $groupManager, $principalBackend, 'calendar'); - $this->random = $random; - $this->logger = $logger; - $this->dispatcher = $dispatcher; - $this->legacyEndpoint = $legacyEndpoint; + protected array $userDisplayNames; + + private string $dbObjectsTable = 'calendarobjects'; + private string $dbObjectPropertiesTable = 'calendarobjects_props'; + private string $dbObjectInvitationsTable = 'calendar_invitations'; + private array $cachedObjects = []; + + 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->createFunction('COUNT(*)')) - ->from('calendars') - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); + $query->select($query->func()->count('*')) + ->from('calendars'); + + if ($principalUri === '') { + $query->where($query->expr()->emptyString('principaluri')); + } else { + $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); + } if ($excludeBirthday) { $query->andWhere($query->expr()->neq('uri', $query->createNamedParameter(BirthdayService::BIRTHDAY_CALENDAR_URI))); } - return (int)$query->execute()->fetchColumn(); + $result = $query->executeQuery(); + $column = (int)$result->fetchOne(); + $result->closeCursor(); + return $column; + } + + /** + * 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 { + $qb = $this->db->getQueryBuilder(); + $qb->select(['id', 'deleted_at']) + ->from('calendars') + ->where($qb->expr()->isNotNull('deleted_at')) + ->andWhere($qb->expr()->lt('deleted_at', $qb->createNamedParameter($deletedBefore))); + $result = $qb->executeQuery(); + $calendars = []; + while (($row = $result->fetch()) !== false) { + $calendars[] = [ + 'id' => (int)$row['id'], + 'deleted_at' => (int)$row['deleted_at'], + ]; + } + $result->closeCursor(); + return $calendars; } /** @@ -245,135 +308,153 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $principalUri * @return array */ - function getCalendarsForUser($principalUri) { - $principalUriOriginal = $principalUri; - $principalUri = $this->convertPrincipal($principalUri, true); - $fields = array_values($this->propertyMap); - $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') - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) + public function getCalendarsForUser($principalUri) { + 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'); - $stmt = $query->execute(); - - $calendars = []; - while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { - $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), - ]; - - foreach($this->propertyMap as $xmlName=>$dbName) { - $calendar[$xmlName] = $row[$dbName]; + if ($principalUri === '') { + $query->where($query->expr()->emptyString('principaluri')); + } else { + $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); } - $this->addOwnerPrincipal($calendar); + $result = $query->executeQuery(); - if (!isset($calendars[$calendar['id']])) { - $calendars[$calendar['id']] = $calendar; - } - } + $calendars = []; + while ($row = $result->fetch()) { + $row['principaluri'] = (string)$row['principaluri']; + $components = []; + if ($row['components']) { + $components = explode(',', $row['components']); + } - $stmt->closeCursor(); + $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), + ]; - // query for shared calendars - $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); - $principals = array_map(function($principal) { - return urldecode($principal); - }, $principals); - $principals[]= $principalUri; + $calendar = $this->rowToCalendar($row, $calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); - $fields = array_values($this->propertyMap); - $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(); - $result = $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) - ->execute(); - - $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'; - while($row = $result->fetch()) { - 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; - } - } - list(, $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, - ]; + $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; + } + } - foreach($this->propertyMap as $xmlName=>$dbName) { - $calendar[$xmlName] = $row[$dbName]; - } + [, $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, + ]; - $this->addOwnerPrincipal($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); } + /** + * @param $principalUri + * @return array + */ public function getUsersOwnCalendars($principalUri) { $principalUri = $this->convertPrincipal($principalUri, true); - $fields = array_values($this->propertyMap); + $fields = array_column($this->propertyMap, 0); $fields[] = 'id'; $fields[] = 'uri'; $fields[] = 'synctoken'; @@ -385,27 +466,27 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $query->select($fields)->from('calendars') ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) ->orderBy('calendarorder', 'ASC'); - $stmt = $query->execute(); + $stmt = $query->executeQuery(); $calendars = []; - while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + while ($row = $stmt->fetch()) { + $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'), ]; - foreach($this->propertyMap as $xmlName=>$dbName) { - $calendar[$xmlName] = $row[$dbName]; - } - $this->addOwnerPrincipal($calendar); + $calendar = $this->rowToCalendar($row, $calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); if (!isset($calendars[$calendar['id']])) { $calendars[$calendar['id']] = $calendar; @@ -415,26 +496,11 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return array_values($calendars); } - - 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 */ public function getPublicCalendars() { - $fields = array_values($this->propertyMap); + $fields = array_column($this->propertyMap, 0); $fields[] = 'a.id'; $fields[] = 'a.uri'; $fields[] = 'a.synctoken'; @@ -450,21 +516,22 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->join('s', 'calendars', 'a', $query->expr()->eq('s.resourceid', 'a.id')) ->where($query->expr()->in('s.access', $query->createNamedParameter(self::ACCESS_PUBLIC))) ->andWhere($query->expr()->eq('s.type', $query->createNamedParameter('calendar'))) - ->execute(); + ->executeQuery(); - while($row = $result->fetch()) { - list(, $name) = Uri\split($row['principaluri']); + while ($row = $result->fetch()) { + $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), @@ -472,11 +539,9 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}public' => (int)$row['access'] === self::ACCESS_PUBLIC, ]; - foreach($this->propertyMap as $xmlName=>$dbName) { - $calendar[$xmlName] = $row[$dbName]; - } - - $this->addOwnerPrincipal($calendar); + $calendar = $this->rowToCalendar($row, $calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); if (!isset($calendars[$calendar['id']])) { $calendars[$calendar['id']] = $calendar; @@ -493,7 +558,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @throws NotFound */ public function getPublicCalendar($uri) { - $fields = array_values($this->propertyMap); + $fields = array_column($this->propertyMap, 0); $fields[] = 'a.id'; $fields[] = 'a.uri'; $fields[] = 'a.synctoken'; @@ -509,9 +574,9 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->where($query->expr()->in('s.access', $query->createNamedParameter(self::ACCESS_PUBLIC))) ->andWhere($query->expr()->eq('s.type', $query->createNamedParameter('calendar'))) ->andWhere($query->expr()->eq('s.publicuri', $query->createNamedParameter($uri))) - ->execute(); + ->executeQuery(); - $row = $result->fetch(\PDO::FETCH_ASSOC); + $row = $result->fetch(); $result->closeCursor(); @@ -519,18 +584,19 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription throw new NotFound('Node with name \'' . $uri . '\' could not be found'); } - list(, $name) = Uri\split($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), @@ -538,14 +604,11 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}public' => (int)$row['access'] === self::ACCESS_PUBLIC, ]; - foreach($this->propertyMap as $xmlName=>$dbName) { - $calendar[$xmlName] = $row[$dbName]; - } - - $this->addOwnerPrincipal($calendar); + $calendar = $this->rowToCalendar($row, $calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); return $calendar; - } /** @@ -554,7 +617,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return array|null */ public function getCalendarByUri($principal, $uri) { - $fields = array_values($this->propertyMap); + $fields = array_column($this->propertyMap, 0); $fields[] = 'id'; $fields[] = 'uri'; $fields[] = 'synctoken'; @@ -568,40 +631,43 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal))) ->setMaxResults(1); - $stmt = $query->execute(); + $stmt = $query->executeQuery(); - $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $row = $stmt->fetch(); $stmt->closeCursor(); if ($row === false) { return null; } + $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'), ]; - foreach($this->propertyMap as $xmlName=>$dbName) { - $calendar[$xmlName] = $row[$dbName]; - } - - $this->addOwnerPrincipal($calendar); + $calendar = $this->rowToCalendar($row, $calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); return $calendar; } - public function getCalendarById($calendarId) { - $fields = array_values($this->propertyMap); + /** + * @psalm-return CalendarInfo|null + * @return array|null + */ + public function getCalendarById(int $calendarId): ?array { + $fields = array_column($this->propertyMap, 0); $fields[] = 'id'; $fields[] = 'uri'; $fields[] = 'synctoken'; @@ -614,36 +680,111 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $query->select($fields)->from('calendars') ->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))) ->setMaxResults(1); - $stmt = $query->execute(); + $stmt = $query->executeQuery(); - $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $row = $stmt->fetch(); $stmt->closeCursor(); if ($row === false) { return null; } + $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'), ]; - foreach($this->propertyMap as $xmlName=>$dbName) { - $calendar[$xmlName] = $row[$dbName]; + $calendar = $this->rowToCalendar($row, $calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); + + return $calendar; + } + + /** + * @param $subscriptionId + */ + public function getSubscriptionById($subscriptionId) { + $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('id', $query->createNamedParameter($subscriptionId))) + ->orderBy('calendarorder', 'asc'); + $stmt = $query->executeQuery(); + + $row = $stmt->fetch(); + $stmt->closeCursor(); + if ($row === false) { + return null; } - $this->addOwnerPrincipal($calendar); + $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 $calendar; + 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'], + '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); } /** @@ -656,16 +797,21 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $calendarUri * @param array $properties * @return int - * @suppress SqlInjectionChecker + * + * @throws CalendarException */ - function createCalendar($principalUri, $calendarUri, array $properties) { + 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', - 'displayname' => $calendarUri + 'uri' => $calendarUri, + 'synctoken' => 1, + 'transparent' => 0, + 'components' => 'VEVENT,VTODO,VJOURNAL', + 'displayname' => $calendarUri ]; // Default value @@ -674,33 +820,38 @@ 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 + $values['components'] = $properties['components']; } + $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) { + foreach ($this->propertyMap as $xmlName => [$dbName, $type]) { if (isset($properties[$xmlName])) { $values[$dbName] = $properties[$xmlName]; } } - $query = $this->db->getQueryBuilder(); - $query->insert('calendars'); - foreach($values as $column => $value) { - $query->setValue($column, $query->createNamedParameter($value)); - } - $query->execute(); - $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); - $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::createCalendar', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::createCalendar', - [ - 'calendarId' => $calendarId, - 'calendarData' => $this->getCalendarById($calendarId), - ])); + $this->dispatcher->dispatchTyped(new CalendarCreatedEvent((int)$calendarId, $calendarData)); return $calendarId; } @@ -721,47 +872,41 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param PropPatch $propPatch * @return void */ - function updateCalendar($calendarId, PropPatch $propPatch) { + public function updateCalendar($calendarId, PropPatch $propPatch) { $supportedProperties = array_keys($this->propertyMap); $supportedProperties[] = '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp'; - /** - * @suppress SqlInjectionChecker - */ - $propPatch->handle($supportedProperties, function($mutations) use ($calendarId) { + $propPatch->handle($supportedProperties, function ($mutations) use ($calendarId) { $newValues = []; foreach ($mutations as $propertyName => $propertyValue) { - switch ($propertyName) { - case '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' : + 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]; + default: + $fieldName = $this->propertyMap[$propertyName][0]; $newValues[$fieldName] = $propertyValue; 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->execute(); + [$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->addChanges($calendarId, [''], 2); - $this->addChange($calendarId, "", 2); + $calendarData = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); + return [$calendarData, $shares]; + }, $this->db); - $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateCalendar', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateCalendar', - [ - 'calendarId' => $calendarId, - 'calendarData' => $this->getCalendarById($calendarId), - 'shares' => $this->getShares($calendarId), - 'propertyMutations' => $mutations, - ])); + $this->dispatcher->dispatchTyped(new CalendarUpdatedEvent($calendarId, $calendarData, $shares, $mutations)); return true; }); @@ -773,30 +918,163 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param mixed $calendarId * @return void */ - function deleteCalendar($calendarId) { - $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::deleteCalendar', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::deleteCalendar', - [ - 'calendarId' => $calendarId, - 'calendarData' => $this->getCalendarById($calendarId), - 'shares' => $this->getShares($calendarId), - ])); - - $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ?'); - $stmt->execute([$calendarId]); - - $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendars` WHERE `id` = ?'); - $stmt->execute([$calendarId]); + public function deleteCalendar($calendarId, bool $forceDeletePermanently = false) { + $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); + $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); + + $this->purgeCalendarInvitations($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(); + + $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(); + + $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(); + + $this->calendarSharingBackend->deleteAllShares($calendarId); + + $qbDeleteCalendar = $this->db->getQueryBuilder(); + $qbDeleteCalendar->delete('calendars') + ->where($qbDeleteCalendar->expr()->eq('id', $qbDeleteCalendar->createNamedParameter($calendarId))) + ->executeStatement(); + + // 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); + } - $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarchanges` WHERE `calendarid` = ?'); - $stmt->execute([$calendarId]); + 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); + } - $this->sharingBackend->deleteAllShares($calendarId); + /** + * 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(); + $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->delete($this->dbObjectPropertiesTable) + $query->select(['id','uid', 'etag', 'uri', 'calendardata']) + ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) - ->execute(); + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) + ->andWhere($query->expr()->isNull('deleted_at')); + $stmt = $query->executeQuery(); + + $result = []; + while (($row = $stmt->fetch()) !== false) { + $result[$row['uid']] = [ + 'id' => $row['id'], + 'etag' => $row['etag'], + 'uri' => $row['uri'], + 'calendardata' => $row['calendardata'], + ]; + } + $stmt->closeCursor(); + + return $result; } /** @@ -805,8 +1083,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $principaluri * @return void */ - function deleteAllSharesByUser($principaluri) { - $this->sharingBackend->deleteAllSharesByUser($principaluri); + public function deleteAllSharesByUser($principaluri) { + $this->calendarSharingBackend->deleteAllSharesByUser($principaluri); } /** @@ -838,28 +1116,100 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * amount of times this is needed is reduced by a great degree. * * @param mixed $calendarId + * @param int $calendarType * @return array */ - function getCalendarObjects($calendarId) { + public function getCalendarObjects($calendarId, $calendarType = self::CALENDAR_TYPE_CALENDAR):array { $query = $this->db->getQueryBuilder(); $query->select(['id', 'uri', 'lastmodified', 'etag', 'calendarid', 'size', 'componenttype', 'classification']) ->from('calendarobjects') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))); - $stmt = $query->execute(); + ->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(); $result = []; - foreach($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { + while (($row = $stmt->fetch()) !== false) { $result[] = [ - 'id' => $row['id'], - 'uri' => $row['uri'], - 'lastmodified' => $row['lastmodified'], - 'etag' => '"' . $row['etag'] . '"', - 'calendarid' => $row['calendarid'], - 'size' => (int)$row['size'], - 'component' => strtolower($row['componenttype']), - 'classification'=> (int)$row['classification'] + 'id' => $row['id'], + 'uri' => $row['uri'], + 'lastmodified' => $row['lastmodified'], + 'etag' => '"' . $row['etag'] . '"', + 'calendarid' => $row['calendarid'], + 'size' => (int)$row['size'], + 'component' => strtolower($row['componenttype']), + 'classification' => (int)$row['classification'] ]; } + $stmt->closeCursor(); + + return $result; + } + + public function getDeletedCalendarObjects(int $deletedBefore): array { + $query = $this->db->getQueryBuilder(); + $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.calendartype', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at']) + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT)) + ->where($query->expr()->isNotNull('co.deleted_at')) + ->andWhere($query->expr()->lt('co.deleted_at', $query->createNamedParameter($deletedBefore))); + $stmt = $query->executeQuery(); + + $result = []; + 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'], + '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'], + ]; + } + $stmt->closeCursor(); + + return $result; + } + + /** + * Return all deleted calendar objects by the given principal that are not + * in deleted calendars. + * + * @param string $principalUri + * @return array + * @throws Exception + */ + public function getDeletedCalendarObjectsByPrincipal(string $principalUri): array { + $query = $this->db->getQueryBuilder(); + $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at']) + ->selectAlias('c.uri', 'calendaruri') + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) + ->andWhere($query->expr()->isNotNull('co.deleted_at')) + ->andWhere($query->expr()->isNull('c.deleted_at')); + $stmt = $query->executeQuery(); + + $result = []; + while ($row = $stmt->fetch()) { + $result[] = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'lastmodified' => $row['lastmodified'], + 'etag' => '"' . $row['etag'] . '"', + 'calendarid' => $row['calendarid'], + 'calendaruri' => $row['calendaruri'], + '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'], + ]; + } + $stmt->closeCursor(); return $result; } @@ -878,30 +1228,46 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * * @param mixed $calendarId * @param string $objectUri + * @param int $calendarType * @return array|null */ - function getCalendarObject($calendarId, $objectUri) { - + 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']) - ->from('calendarobjects') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) - ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))); - $stmt = $query->execute(); - $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $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))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))); + $stmt = $query->executeQuery(); + $row = $stmt->fetch(); + $stmt->closeCursor(); - if(!$row) return null; + if (!$row) { + 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'], - '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'] + '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'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'], ]; } @@ -915,9 +1281,10 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * * @param mixed $calendarId * @param string[] $uris + * @param int $calendarType * @return array */ - function getMultipleCalendarObjects($calendarId, array $uris) { + public function getMultipleCalendarObjects($calendarId, array $uris, $calendarType = self::CALENDAR_TYPE_CALENDAR):array { if (empty($uris)) { return []; } @@ -929,27 +1296,30 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $query->select(['id', 'uri', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification']) ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) - ->andWhere($query->expr()->in('uri', $query->createParameter('uri'))); + ->andWhere($query->expr()->in('uri', $query->createParameter('uri'))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) + ->andWhere($query->expr()->isNull('deleted_at')); foreach ($chunks as $uris) { $query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY); - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { $objects[] = [ - 'id' => $row['id'], - 'uri' => $row['uri'], + 'id' => $row['id'], + 'uri' => $row['uri'], 'lastmodified' => $row['lastmodified'], - 'etag' => '"' . $row['etag'] . '"', - 'calendarid' => $row['calendarid'], - 'size' => (int)$row['size'], + 'etag' => '"' . $row['etag'] . '"', + 'calendarid' => $row['calendarid'], + 'size' => (int)$row['size'], 'calendardata' => $this->readBlob($row['calendardata']), - 'component' => strtolower($row['componenttype']), + 'component' => strtolower($row['componenttype']), 'classification' => (int)$row['classification'] ]; } $result->closeCursor(); } + return $objects; } @@ -969,56 +1339,83 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param mixed $calendarId * @param string $objectUri * @param string $calendarData + * @param int $calendarType * @return string */ - function createCalendarObject($calendarId, $objectUri, $calendarData) { + public function createCalendarObject($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + $this->cachedObjects = []; $extraData = $this->getDenormalizedData($calendarData); - $q = $this->db->getQueryBuilder(); - $q->select($q->createFunction('COUNT(*)')) - ->from('calendarobjects') - ->where($q->expr()->eq('calendarid', $q->createNamedParameter($calendarId))) - ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($extraData['uid']))); - - $result = $q->execute(); - $count = (int) $result->fetchColumn(); - $result->closeCursor(); - - if ($count !== 0) { - throw new \Sabre\DAV\Exception\BadRequest('Calendar object with uid already exists in this calendar collection.'); - } + 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(); - $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']), - ]) - ->execute(); + 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']); + } - $this->updateProperties($calendarId, $objectUri, $calendarData); + $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->addChanges($calendarId, [$objectUri], 1, $calendarType); + + $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType); + assert($objectRow !== null); + + 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); - $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::createCalendarObject', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::createCalendarObject', - [ - 'calendarId' => $calendarId, - 'calendarData' => $this->getCalendarById($calendarId), - 'shares' => $this->getShares($calendarId), - 'objectData' => $this->getCalendarObject($calendarId, $objectUri), - ] - )); - $this->addChange($calendarId, $objectUri, 1); + $this->dispatcher->dispatchTyped(new CachedCalendarObjectCreatedEvent($calendarId, $subscriptionRow, [], $objectRow)); + } - return '"' . $extraData['etag'] . '"'; + return '"' . $extraData['etag'] . '"'; + }, $this->db); } /** @@ -1037,13 +1434,16 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param mixed $calendarId * @param string $objectUri * @param string $calendarData + * @param int $calendarType * @return string */ - function updateCalendarObject($calendarId, $objectUri, $calendarData) { + 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'])) @@ -1053,44 +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))) - ->execute(); - - $this->updateProperties($calendarId, $objectUri, $calendarData); - - $data = $this->getCalendarObject($calendarId, $objectUri); - if (is_array($data)) { - $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateCalendarObject', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateCalendarObject', - [ - 'calendarId' => $calendarId, - 'calendarData' => $this->getCalendarById($calendarId), - 'shares' => $this->getShares($calendarId), - 'objectData' => $data, - ] - )); - } - $this->addChange($calendarId, $objectUri, 2); + ->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->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); + + $this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent($calendarId, $calendarRow, $shares, $objectRow)); + } else { + $subscriptionRow = $this->getSubscriptionById($calendarId); - return '"' . $extraData['etag'] . '"'; + $this->dispatcher->dispatchTyped(new CachedCalendarObjectUpdatedEvent($calendarId, $subscriptionRow, [], $objectRow)); + } + } + + return '"' . $extraData['etag'] . '"'; + }, $this->db); } /** - * @param int $calendarObjectId - * @param int $classification + * Moves a calendar object from calendar to calendar. + * + * @param string $sourcePrincipalUri + * @param int $sourceObjectId + * @param string $targetPrincipalUri + * @param int $targetCalendarId + * @param string $tragetObjectUri + * @param int $calendarType + * @return bool + * @throws Exception */ - 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))) - ->execute(); + 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; + } + + $sourceCalendarId = $object['calendarid']; + $sourceObjectUri = $object['uri']; + + $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(); + + $this->purgeProperties($sourceCalendarId, $sourceObjectId); + $this->updateProperties($targetCalendarId, $tragetObjectUri, $object['calendardata'], $calendarType); + + $this->addChanges($sourceCalendarId, [$sourceObjectUri], 3, $calendarType); + $this->addChanges($targetCalendarId, [$tragetObjectUri], 1, $calendarType); + + $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; + } + + 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); } /** @@ -1100,28 +1544,142 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * * @param mixed $calendarId * @param string $objectUri + * @param int $calendarType + * @param bool $forceDeletePermanently * @return void */ - function deleteCalendarObject($calendarId, $objectUri) { - $data = $this->getCalendarObject($calendarId, $objectUri); - if (is_array($data)) { - $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::deleteCalendarObject', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::deleteCalendarObject', - [ - 'calendarId' => $calendarId, - 'calendarData' => $this->getCalendarById($calendarId), - 'shares' => $this->getShares($calendarId), - 'objectData' => $data, - ] - )); - } + public function deleteCalendarObject($calendarId, $objectUri, $calendarType = self::CALENDAR_TYPE_CALENDAR, bool $forceDeletePermanently = false) { + $this->cachedObjects = []; + $this->atomic(function () use ($calendarId, $objectUri, $calendarType, $forceDeletePermanently): void { + $data = $this->getCalendarObject($calendarId, $objectUri, $calendarType); + + if ($data === null) { + // Nothing to delete + return; + } - $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `uri` = ?'); - $stmt->execute([$calendarId, $objectUri]); + 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']); - $this->addChange($calendarId, $objectUri, 3); + $this->purgeObjectInvitations($data['uid']); + + if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { + $calendarRow = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); + + $this->dispatcher->dispatchTyped(new CalendarObjectDeletedEvent($calendarId, $calendarRow, $shares, $data)); + } else { + $subscriptionRow = $this->getSubscriptionById($calendarId); + + $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'] + ); + } + + // 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"); + } + + $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->addChanges($calendarId, [$objectUri], 3, $calendarType); + }, $this->db); + } + + /** + * @param mixed $objectData + * + * @throws Forbidden + */ + public function restoreCalendarObject(array $objectData): void { + $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->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 + ) + ); + }, $this->db); } /** @@ -1164,16 +1722,17 @@ 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. * * @param mixed $calendarId * @param array $filters + * @param int $calendarType * @return array */ - function calendarQuery($calendarId, array $filters) { + public function calendarQuery($calendarId, array $filters, $calendarType = self::CALENDAR_TYPE_CALENDAR):array { $componentType = null; $requirePostFilter = true; $timeRange = null; @@ -1192,7 +1751,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $requirePostFilter = false; } // There was a time-range filter - if ($componentType === 'VEVENT' && isset($filters['comp-filters'][0]['time-range'])) { + if ($componentType === 'VEVENT' && isset($filters['comp-filters'][0]['time-range']) && is_array($filters['comp-filters'][0]['time-range'])) { $timeRange = $filters['comp-filters'][0]['time-range']; // If start time OR the end time is not specified, we can do a @@ -1201,16 +1760,13 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $requirePostFilter = false; } } - - } - $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))); + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) + ->andWhere($query->expr()->isNull('deleted_at')); if ($componentType) { $query->andWhere($query->expr()->eq('componenttype', $query->createNamedParameter($componentType))); @@ -1223,25 +1779,36 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $query->andWhere($query->expr()->lt('firstoccurence', $query->createNamedParameter($timeRange['end']->getTimeStamp()))); } - $stmt = $query->execute(); + $stmt = $query->executeQuery(); $result = []; - while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + 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, [ + } catch (ParseException $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; } @@ -1251,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; @@ -1259,120 +1828,129 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** * custom Nextcloud search extension for CalDAV * + * TODO - this should optionally cover cached calendar objects as well + * * @param string $principalUri * @param array $filters * @param integer|null $limit * @param integer|null $offset * @return array */ - public function calendarSearch($principalUri, array $filters, $limit=null, $offset=null) { - $calendars = $this->getCalendarsForUser($principalUri); - $ownCalendars = []; - $sharedCalendars = []; + public function calendarSearch($principalUri, array $filters, $limit = null, $offset = null) { + 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() - ->eq('c.calendarid', $query->createNamedParameter($id)); - } - 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)) - ); - } - if (count($calendarExpressions) === 1) { - $calExpr = $calendarExpressions[0]; - } else { - $calExpr = call_user_func_array([$query->expr(), 'orX'], $calendarExpressions); - } + $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))); + } - // 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']).'%'))); + 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->execute(); + $stmt = $query->executeQuery(); - $result = []; - while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { - $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); } /** @@ -1387,78 +1965,155 @@ 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']))); + $outerQuery->createNamedParameter($calendarInfo['id']))) + ->andWhere($innerQuery->expr()->eq('op.calendartype', + $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 ($calendarInfo['principaluri'] !== $calendarInfo['{http://owncloud.org/ns}owner-principal']) { - $innerQuery->andWhere($innerQuery->expr()->eq('c.classification', + if (isset($calendarInfo['{http://owncloud.org/ns}owner-principal']) === false || $calendarInfo['principaluri'] !== $calendarInfo['{http://owncloud.org/ns}owner-principal']) { + $outerQuery->andWhere($outerQuery->expr()->eq('c.classification', $outerQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); } - $or = $innerQuery->expr()->orX(); - foreach($searchProperties as $searchProperty) { - $or->add($innerQuery->expr()->eq('op.name', - $outerQuery->createNamedParameter($searchProperty))); + if (!empty($searchProperties)) { + $or = []; + foreach ($searchProperties as $searchProperty) { + $or[] = $innerQuery->expr()->eq('op.name', + $outerQuery->createNamedParameter($searchProperty)); + } + $innerQuery->andWhere($innerQuery->expr()->orX(...$or)); } - $innerQuery->andWhere($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'); + $start = null; + $end = null; - if (isset($options['timerange'])) { - if (isset($options['timerange']['start'])) { - $outerQuery->andWhere($outerQuery->expr()->gt('lastoccurence', - $outerQuery->createNamedParameter($options['timerange']['start']->getTimeStamp))); + $hasLimit = is_int($limit); + $hasTimeRange = false; - } - if (isset($options['timerange']['end'])) { - $outerQuery->andWhere($outerQuery->expr()->lt('firstoccurence', - $outerQuery->createNamedParameter($options['timerange']['end']->getTimeStamp))); - } + 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['types'])) { - $or = $outerQuery->expr()->orX(); - foreach($options['types'] as $type) { - $or->add($outerQuery->expr()->eq('componenttype', - $outerQuery->createNamedParameter($type))); - } - $outerQuery->andWhere($or); + 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; } - $outerQuery->andWhere($outerQuery->expr()->in('c.id', - $outerQuery->createFunction($innerQuery->getSQL()))); - - if ($offset) { - $outerQuery->setFirstResult($offset); + if (isset($options['uid'])) { + $outerQuery->andWhere($outerQuery->expr()->eq('uid', $outerQuery->createNamedParameter($options['uid']))); } - if ($limit) { + + if (!empty($options['types'])) { + $or = []; + foreach ($options['types'] as $type) { + $or[] = $outerQuery->expr()->eq('componenttype', + $outerQuery->createNamedParameter($type)); + } + $outerQuery->andWhere($outerQuery->expr()->orX(...$or)); + } + + $outerQuery->andWhere($outerQuery->expr()->in('c.id', $outerQuery->createFunction($innerQuery->getSQL()))); + + // 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->execute(); - $calendarObjects = $result->fetchAll(); - - return array_map(function($o) { + $calendarObjects = array_map(function ($o) use ($options) { $calendarData = Reader::read($o['calendardata']); + + // 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'], + ); + } + $comps = $calendarData->getComponents(); $objects = []; $timezones = []; - foreach($comps as $comp) { + foreach ($comps as $comp) { if ($comp instanceof VTimeZone) { $timezones[] = $comp; } else { @@ -1471,14 +2126,80 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'type' => $o['componenttype'], 'uid' => $o['uid'], 'uri' => $o['uri'], - 'objects' => array_map(function($c) { + 'objects' => array_map(function ($c) { return $this->transformSearchData($c); }, $objects), - 'timezones' => array_map(function($c) { + 'timezones' => array_map(function ($c) { return $this->transformSearchData($c); }, $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; } /** @@ -1490,12 +2211,12 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** @var Component[] $subComponents */ $subComponents = $comp->getComponents(); /** @var Property[] $properties */ - $properties = array_filter($comp->children(), function($c) { + $properties = array_filter($comp->children(), function ($c) { return $c instanceof Property; }); $validationRules = $comp->getValidationRules(); - foreach($subComponents as $subComponent) { + foreach ($subComponents as $subComponent) { $name = $subComponent->name; if (!isset($data[$name])) { $data[$name] = []; @@ -1503,7 +2224,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $data[$name][] = $this->transformSearchData($subComponent); } - foreach($properties as $property) { + foreach ($properties as $property) { $name = $property->name; if (!isset($validationRules[$name])) { $validationRules[$name] = '*'; @@ -1543,6 +2264,148 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** + * @param string $principalUri + * @param string $pattern + * @param array $componentTypes + * @param array $searchProperties + * @param array $searchParameters + * @param array $options + * @return array + */ + public function searchPrincipalUri(string $principalUri, + 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)), + ); + + // 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)), + ); + + // 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; + } + + foreach ($searchProperties as $property) { + $propertyAnd = $calendarObjectIdQuery->expr()->andX( + $calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR)), + $calendarObjectIdQuery->expr()->isNull('cob.parameter'), + ); + + $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)), + ); + + $searchOr[] = $parameterAnd; + } + + if (empty($calendarOr)) { + return []; + } + if (empty($searchOr)) { + return []; + } + + $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) . '%'))); + } + } + + 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()), + )); + } + } + + $result = $calendarObjectIdQuery->executeQuery(); + $matches = []; + while (($row = $result->fetch()) !== false) { + $matches[] = (int)$row['objectid']; + } + $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))); + + $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']); + + $calendarObjects[] = $array; + } + $result->closeCursor(); + return $calendarObjects; + }, $this->db); + } + + /** * Searches through all of a users calendars and calendar objects to find * an object with a specific UID. * @@ -1561,24 +2424,55 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $uid * @return string|null */ - function getCalendarObjectByUID($principalUri, $uid) { - + public function getCalendarObjectByUID($principalUri, $uid) { $query = $this->db->getQueryBuilder(); $query->selectAlias('c.uri', 'calendaruri')->selectAlias('co.uri', 'objecturi') ->from('calendarobjects', 'co') ->leftJoin('co', 'calendars', 'c', $query->expr()->eq('co.calendarid', 'c.id')) ->where($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri))) - ->andWhere($query->expr()->eq('co.uid', $query->createNamedParameter($uid))); - - $stmt = $query->execute(); - - if ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + ->andWhere($query->expr()->eq('co.uid', $query->createNamedParameter($uid))) + ->andWhere($query->expr()->isNull('co.deleted_at')); + $stmt = $query->executeQuery(); + $row = $stmt->fetch(); + $stmt->closeCursor(); + if ($row) { return $row['calendaruri'] . '/' . $row['objecturi']; } return null; } + public function getCalendarObjectById(string $principalUri, int $id): ?array { + $query = $this->db->getQueryBuilder(); + $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at']) + ->selectAlias('c.uri', 'calendaruri') + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri))) + ->andWhere($query->expr()->eq('co.id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $stmt = $query->executeQuery(); + $row = $stmt->fetch(); + $stmt->closeCursor(); + + if (!$row) { + return null; + } + + return [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'lastmodified' => $row['lastmodified'], + 'etag' => '"' . $row['etag'] . '"', + 'calendarid' => $row['calendarid'], + 'calendaruri' => $row['calendaruri'], + 'size' => (int)$row['size'], + 'calendardata' => $this->readBlob($row['calendardata']), + 'component' => strtolower($row['componenttype']), + 'classification' => (int)$row['classification'], + 'deleted_at' => isset($row['deleted_at']) ? ((int)$row['deleted_at']) : null, + ]; + } + /** * The getChanges method returns all the changes that have happened, since * the specified syncToken in the specified calendar. @@ -1632,72 +2526,76 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $calendarId * @param string $syncToken * @param int $syncLevel - * @param int $limit - * @return array + * @param int|null $limit + * @param int $calendarType + * @return ?array */ - function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limit = null) { - // Current synctoken - $stmt = $this->db->prepare('SELECT `synctoken` FROM `*PREFIX*calendars` WHERE `id` = ?'); - $stmt->execute([ $calendarId ]); - $currentToken = $stmt->fetchColumn(0); - - if (is_null($currentToken)) { - return null; - } - - $result = [ - 'syncToken' => $currentToken, - 'added' => [], - 'modified' => [], - 'deleted' => [], - ]; - - if ($syncToken) { - - $query = "SELECT `uri`, `operation` FROM `*PREFIX*calendarchanges` WHERE `synctoken` >= ? AND `synctoken` < ? AND `calendarid` = ? ORDER BY `synctoken`"; - if ($limit>0) { - $query.= " LIMIT " . (int)$limit; + public function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limit = null, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + $table = $calendarType === self::CALENDAR_TYPE_CALENDAR ? 'calendars': 'calendarsubscriptions'; + + return $this->atomic(function () use ($calendarId, $syncToken, $syncLevel, $limit, $calendarType, $table) { + // Current synctoken + $qb = $this->db->getQueryBuilder(); + $qb->select('synctoken') + ->from($table) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($calendarId)) + ); + $stmt = $qb->executeQuery(); + $currentToken = $stmt->fetchOne(); + $initialSync = !is_numeric($syncToken); + + if ($currentToken === false) { + return null; } - // Fetching all changes - $stmt = $this->db->prepare($query); - $stmt->execute([$syncToken, $currentToken, $calendarId]); - - $changes = []; - - // This loop ensures that any duplicates are overwritten, only the - // last change on a node is relevant. - while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { - - $changes[$row['uri']] = $row['operation']; - + // 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'); } - - 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 limit exists + if (is_numeric($limit)) { + $qb->setMaxResults($limit); + } + // execute command + $stmt = $qb->executeQuery(); + // 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') + }; } - } - } else { - // No synctoken supplied, this is the initial sync. - $query = "SELECT `uri` FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ?"; - $stmt = $this->db->prepare($query); - $stmt->execute([$calendarId]); - - $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); - } - return $result; + $stmt->closeCursor(); + return $result; + }, $this->db); } /** @@ -1732,42 +2630,36 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $principalUri * @return array */ - function getSubscriptionsForUser($principalUri) { - $fields = array_values($this->subscriptionPropertyMap); + public function getSubscriptionsForUser($principalUri) { + $fields = array_column($this->subscriptionPropertyMap, 0); $fields[] = 'id'; $fields[] = 'uri'; $fields[] = 'source'; $fields[] = 'principaluri'; $fields[] = 'lastmodified'; + $fields[] = 'synctoken'; $query = $this->db->getQueryBuilder(); $query->select($fields) ->from('calendarsubscriptions') ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) ->orderBy('calendarorder', 'asc'); - $stmt =$query->execute(); + $stmt = $query->executeQuery(); $subscriptions = []; - while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { - + while ($row = $stmt->fetch()) { $subscription = [ - 'id' => $row['id'], - 'uri' => $row['uri'], + 'id' => $row['id'], + 'uri' => $row['uri'], 'principaluri' => $row['principaluri'], - 'source' => $row['source'], + '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', ]; - foreach($this->subscriptionPropertyMap as $xmlName=>$dbName) { - if (!is_null($row[$dbName])) { - $subscription[$xmlName] = $row[$dbName]; - } - } - - $subscriptions[] = $subscription; - + $subscriptions[] = $this->rowToSubscription($row, $subscription); } return $subscriptions; @@ -1784,43 +2676,48 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param array $properties * @return mixed */ - function createSubscription($principalUri, $uri, array $properties) { - + public function createSubscription($principalUri, $uri, array $properties) { if (!isset($properties['{http://calendarserver.org/ns/}source'])) { throw new Forbidden('The {http://calendarserver.org/ns/}source property is required when creating subscriptions'); } $values = [ 'principaluri' => $principalUri, - 'uri' => $uri, - 'source' => $properties['{http://calendarserver.org/ns/}source']->getHref(), + 'uri' => $uri, + 'source' => $properties['{http://calendarserver.org/ns/}source']->getHref(), 'lastmodified' => time(), ]; $propertiesBoolean = ['striptodos', 'stripalarms', 'stripattachments']; - foreach($this->subscriptionPropertyMap as $xmlName=>$dbName) { + foreach ($this->subscriptionPropertyMap as $xmlName => [$dbName, $type]) { if (array_key_exists($xmlName, $properties)) { - $values[$dbName] = $properties[$xmlName]; - if (in_array($dbName, $propertiesBoolean)) { - $values[$dbName] = true; + $values[$dbName] = $properties[$xmlName]; + if (in_array($dbName, $propertiesBoolean)) { + $values[$dbName] = true; } } } - $valuesToInsert = array(); + [$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 = $this->db->getQueryBuilder(); + $subscriptionId = $query->getLastInsertId(); - foreach (array_keys($values) as $name) { - $valuesToInsert[$name] = $query->createNamedParameter($values[$name]); - } + $subscriptionRow = $this->getSubscriptionById($subscriptionId); + return [$subscriptionId, $subscriptionRow]; + }, $this->db); - $query->insert('calendarsubscriptions') - ->values($valuesToInsert) - ->execute(); + $this->dispatcher->dispatchTyped(new SubscriptionCreatedEvent($subscriptionId, $subscriptionRow)); - return $this->db->lastInsertId('*PREFIX*calendarsubscriptions'); + return $subscriptionId; } /** @@ -1839,37 +2736,38 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param PropPatch $propPatch * @return void */ - function updateSubscription($subscriptionId, PropPatch $propPatch) { + public function updateSubscription($subscriptionId, PropPatch $propPatch) { $supportedProperties = array_keys($this->subscriptionPropertyMap); $supportedProperties[] = '{http://calendarserver.org/ns/}source'; - /** - * @suppress SqlInjectionChecker - */ - $propPatch->handle($supportedProperties, function($mutations) use ($subscriptionId) { - + $propPatch->handle($supportedProperties, function ($mutations) use ($subscriptionId) { $newValues = []; - foreach($mutations as $propertyName=>$propertyValue) { + foreach ($mutations as $propertyName => $propertyValue) { if ($propertyName === '{http://calendarserver.org/ns/}source') { $newValues['source'] = $propertyValue->getHref(); } else { - $fieldName = $this->subscriptionPropertyMap[$propertyName]; + $fieldName = $this->subscriptionPropertyMap[$propertyName][0]; $newValues[$fieldName] = $propertyValue; } } - $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))) - ->execute(); + $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 true; + return $this->getSubscriptionById($subscriptionId); + }, $this->db); + $this->dispatcher->dispatchTyped(new SubscriptionUpdatedEvent((int)$subscriptionId, $subscriptionRow, [], $mutations)); + + return true; }); } @@ -1879,11 +2777,35 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param mixed $subscriptionId * @return void */ - function deleteSubscription($subscriptionId) { - $query = $this->db->getQueryBuilder(); - $query->delete('calendarsubscriptions') - ->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId))) - ->execute(); + public function deleteSubscription($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('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($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, [])); + } + }, $this->db); } /** @@ -1902,26 +2824,26 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $objectUri * @return array */ - function getSchedulingObject($principalUri, $objectUri) { + public function getSchedulingObject($principalUri, $objectUri) { $query = $this->db->getQueryBuilder(); $stmt = $query->select(['uri', 'calendardata', 'lastmodified', 'etag', 'size']) ->from('schedulingobjects') ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) - ->execute(); + ->executeQuery(); - $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $row = $stmt->fetch(); - if(!$row) { + if (!$row) { return null; } return [ - 'uri' => $row['uri'], - 'calendardata' => $row['calendardata'], - 'lastmodified' => $row['lastmodified'], - 'etag' => '"' . $row['etag'] . '"', - 'size' => (int)$row['size'], + 'uri' => $row['uri'], + 'calendardata' => $row['calendardata'], + 'lastmodified' => $row['lastmodified'], + 'etag' => '"' . $row['etag'] . '"', + 'size' => (int)$row['size'], ]; } @@ -1936,25 +2858,26 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $principalUri * @return array */ - function getSchedulingObjects($principalUri) { + 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))) - ->execute(); + ->from('schedulingobjects') + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) + ->executeQuery(); - $result = []; - foreach($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { - $result[] = [ - 'calendardata' => $row['calendardata'], - 'uri' => $row['uri'], - 'lastmodified' => $row['lastmodified'], - 'etag' => '"' . $row['etag'] . '"', - 'size' => (int)$row['size'], + $results = []; + while (($row = $stmt->fetch()) !== false) { + $results[] = [ + 'calendardata' => $row['calendardata'], + 'uri' => $row['uri'], + 'lastmodified' => $row['lastmodified'], + 'etag' => '"' . $row['etag'] . '"', + 'size' => (int)$row['size'], ]; } + $stmt->closeCursor(); - return $result; + return $results; } /** @@ -1964,12 +2887,51 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $objectUri * @return void */ - function deleteSchedulingObject($principalUri, $objectUri) { + 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))) - ->execute(); + ->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); + } } /** @@ -1980,42 +2942,105 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $objectData * @return void */ - function createSchedulingObject($principalUri, $objectUri, $objectData) { + public function createSchedulingObject($principalUri, $objectUri, $objectData) { + $this->cachedObjects = []; $query = $this->db->getQueryBuilder(); $query->insert('schedulingobjects') ->values([ 'principaluri' => $query->createNamedParameter($principalUri), - 'calendardata' => $query->createNamedParameter($objectData), + 'calendardata' => $query->createNamedParameter($objectData, IQueryBuilder::PARAM_LOB), 'uri' => $query->createNamedParameter($objectUri), 'lastmodified' => $query->createNamedParameter(time()), 'etag' => $query->createNamedParameter(md5($objectData)), 'size' => $query->createNamedParameter(strlen($objectData)) ]) - ->execute(); + ->executeStatement(); } /** * 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) { + 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'; + + $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->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('INSERT INTO `*PREFIX*calendarchanges` (`uri`, `synctoken`, `calendarid`, `operation`) SELECT ?, `synctoken`, ?, ? FROM `*PREFIX*calendars` WHERE `id` = ?'); - $stmt->execute([ - $objectUri, - $calendarId, - $operation, - $calendarId - ]); - $stmt = $this->db->prepare('UPDATE `*PREFIX*calendars` 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); } /** @@ -2033,29 +3058,42 @@ 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; $component = null; $firstOccurrence = null; $lastOccurrence = null; $uid = null; $classification = self::CLASSIFICATION_PUBLIC; - foreach($vObject->getComponents() as $component) { - if ($component->name!=='VTIMEZONE') { - $componentType = $component->name; - $uid = (string)$component->UID; - break; + $hasDTSTART = false; + foreach ($vObject->getComponents() as $component) { + if ($component->name !== 'VTIMEZONE') { + // Finding all VEVENTs, and track them + if ($component->name === 'VEVENT') { + $vEvents[] = $component; + if ($component->DTSTART) { + $hasDTSTART = true; + } + } + // Track first component type and uid + if ($uid === null) { + $componentType = $component->name; + $uid = (string)$component->UID; + } } } if (!$componentType) { - throw new \Sabre\DAV\Exception\BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component'); + throw new BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component'); } - if ($componentType === 'VEVENT' && $component->DTSTART) { - $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp(); + + if ($hasDTSTART) { + $component = $vEvents[0]; + // Finding the last occurrence is a bit harder - if (!isset($component->RRULE)) { + if (!isset($component->RRULE) && count($vEvents) === 1) { + $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp(); if (isset($component->DTEND)) { $lastOccurrence = $component->DTEND->getDateTime()->getTimeStamp(); } elseif (isset($component->DURATION)) { @@ -2070,20 +3108,27 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $lastOccurrence = $firstOccurrence; } } else { - $it = new EventIterator($vObject, (string)$component->UID); - $maxDate = new \DateTime(self::MAX_DATE); + 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()) { $lastOccurrence = $maxDate->getTimestamp(); } else { $end = $it->getDtEnd(); - while($it->valid() && $end < $maxDate) { + while ($it->valid() && $end < $maxDate) { $end = $it->getDtEnd(); $it->next(); - } $lastOccurrence = $end->getTimestamp(); } - } } @@ -2103,13 +3148,16 @@ 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 ]; - } + /** + * @param $cardData + * @return bool|string + */ private function readBlob($cardData) { if (is_resource($cardData)) { return stream_get_contents($cardData); @@ -2119,71 +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(); - $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateShares', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateShares', - [ - 'calendarId' => $calendarId, - 'calendarData' => $this->getCalendarById($calendarId), - 'shares' => $this->getShares($calendarId), - 'add' => $add, - 'remove' => $remove, - ])); - $this->sharingBackend->updateShares($shareable, $add, $remove); + 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->calendarSharingBackend->updateShares($shareable, $add, $remove, $oldShares); + + $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) { - return $this->sharingBackend->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) { + return $this->atomic(function () use ($value, $calendar) { + $calendarId = $calendar->getResourceId(); + $calendarData = $this->getCalendarById($calendarId); - $calendarId = $calendar->getResourceId(); - $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::publishCalendar', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateShares', - [ - 'calendarId' => $calendarId, - 'calendarData' => $this->getCalendarById($calendarId), - 'public' => $value, - ])); + $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->execute(); - 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->execute(); - return null; + $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($calendarId, $calendarData)); + return null; + }, $this->db); } /** - * @param \OCA\DAV\CalDAV\Calendar $calendar + * @param Calendar $calendar * @return mixed */ public function getPublishStatus($calendar) { @@ -2192,7 +3242,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->from('dav_shares') ->where($query->expr()->eq('resourceid', $query->createNamedParameter($calendar->getResourceId()))) ->andWhere($query->expr()->eq('access', $query->createNamedParameter(self::ACCESS_PUBLIC))) - ->execute(); + ->executeQuery(); $row = $result->fetch(); $result->closeCursor(); @@ -2201,103 +3251,207 @@ 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->sharingBackend->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 * * @param int $calendarId * @param string $objectUri * @param string $calendarData + * @param int $calendarType */ - public function updateProperties($calendarId, $objectUri, $calendarData) { - $objectId = $this->getCalendarObjectId($calendarId, $objectUri); - - try { - $vCalendar = $this->readCalendarData($calendarData); - } catch (\Exception $ex) { - return; - } - - $this->purgeProperties($calendarId, $objectId); - - $query = $this->db->getQueryBuilder(); - $query->insert($this->dbObjectPropertiesTable) - ->values( - [ - 'calendarid' => $query->createNamedParameter($calendarId), - '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; + public function updateProperties($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + $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; } - foreach ($component->children() as $property) { - if (in_array($property->name, self::$indexProperties)) { - $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 = substr($value, 0, 254); + $this->purgeProperties($calendarId, $objectId); - $query->setParameter('name', $property->name); - $query->setParameter('parameter', null); - $query->setParameter('value', $value); - $query->execute(); + $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; } - if (in_array($property->name, array_keys(self::$indexParameters))) { - $parameters = $property->parameters(); - $indexedParametersForProperty = self::$indexParameters[$property->name]; + 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 ($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); - } - $value = substr($value, 0, 254); + $query->setParameter('name', $property->name); + $query->setParameter('parameter', null); + $query->setParameter('value', mb_strcut($value, 0, 254)); + $query->executeStatement(); + } - $query->setParameter('name', $property->name); - $query->setParameter('parameter', substr($key, 0, 254)); - $query->setParameter('value', substr($value, 0, 254)); - $query->execute(); + 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(); + } } } } } - } + }, $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))) - ->execute(); + $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(); + + 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) { + $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 = []; + 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(); - $ids = $result->fetchAll(); - foreach($ids as $id) { - $this->deleteCalendar($id['id']); + $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 = $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); + } + + /** + * @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); + } + + /** + * Move a calendar from one user to another + * + * @param string $uriName + * @param string $uriOrigin + * @param string $uriDestination + * @param string $newUriName (optional) the new uriName + */ + public function moveCalendar($uriName, $uriOrigin, $uriDestination, $newUriName = null) { + $query = $this->db->getQueryBuilder(); + $query->update('calendars') + ->set('principaluri', $query->createNamedParameter($uriDestination)) + ->set('uri', $query->createNamedParameter($newUriName ?: $uriName)) + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($uriOrigin))) + ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($uriName))) + ->executeStatement(); } /** @@ -2317,11 +3471,12 @@ 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))) ->andWhere($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))); - $query->execute(); + $query->executeStatement(); } /** @@ -2329,15 +3484,18 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * * @param int $calendarId * @param string $uri + * @param int $calendarType * @return int */ - protected function getCalendarObjectId($calendarId, $uri) { + protected function getCalendarObjectId($calendarId, $uri, $calendarType):int { $query = $this->db->getQueryBuilder(); - $query->select('id')->from('calendarobjects') + $query->select('id') + ->from('calendarobjects') ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) - ->andWhere($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))); + ->andWhere($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))); - $result = $query->execute(); + $result = $query->executeQuery(); $objectIds = $result->fetch(); $result->closeCursor(); @@ -2348,9 +3506,44 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return (int)$objectIds['id']; } + /** + * @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 + * @param $toV2 + * @return string + */ private function convertPrincipal($principalUri, $toV2) { if ($this->principalBackend->getPrincipalPrefix() === 'principals') { - list(, $name) = Uri\split($principalUri); + [, $name] = Uri\split($principalUri); if ($toV2 === true) { return "principals/users/$name"; } @@ -2359,7 +3552,11 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return $principalUri; } - private function addOwnerPrincipal(&$calendarInfo) { + /** + * adds information about an owner to the calendar data + * + */ + private function addOwnerPrincipalToCalendar(array $calendarInfo): array { $ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'; $displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname'; if (isset($calendarInfo[$ownerPrincipalKey])) { @@ -2372,5 +3569,122 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription if (isset($principalInformation['{DAV:}displayname'])) { $calendarInfo[$displaynameKey] = $principalInformation['{DAV:}displayname']; } + return $calendarInfo; + } + + private function addResourceTypeToCalendar(array $row, array $calendar): array { + if (isset($row['deleted_at'])) { + // Columns is set and not null -> this is a deleted calendar + // we send a custom resourcetype to hide the deleted calendar + // from ordinary DAV clients, but the Calendar app will know + // how to handle this special resource. + $calendar['{DAV:}resourcetype'] = new DAV\Xml\Property\ResourceType([ + '{DAV:}collection', + sprintf('{%s}deleted-calendar', \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD), + ]); + } + return $calendar; + } + + /** + * Amend the calendar info with database row data + * + * @param array $row + * @param array $calendar + * + * @return array + */ + private function rowToCalendar($row, array $calendar): array { + foreach ($this->propertyMap as $xmlName => [$dbName, $type]) { + $value = $row[$dbName]; + if ($value !== null) { + settype($value, $type); + } + $calendar[$xmlName] = $value; + } + return $calendar; + } + + /** + * Amend the subscription info with database row data + * + * @param array $row + * @param array $subscription + * + * @return array + */ + private function rowToSubscription($row, array $subscription): array { + foreach ($this->subscriptionPropertyMap as $xmlName => [$dbName, $type]) { + $value = $row[$dbName]; + if ($value !== null) { + settype($value, $type); + } + $subscription[$xmlName] = $value; + } + 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 02808ab5662..deb00caa93d 100644 --- a/apps/dav/lib/CalDAV/Calendar.php +++ b/apps/dav/lib/CalDAV/Calendar.php @@ -1,84 +1,74 @@ <?php + + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @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 <tcit@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; +use DateTimeImmutable; +use DateTimeInterface; +use OCA\DAV\CalDAV\Trashbin\Plugin as TrashbinPlugin; use OCA\DAV\DAV\Sharing\IShareable; +use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException; +use OCP\DB\Exception; use OCP\IConfig; use OCP\IL10N; +use Psr\Log\LoggerInterface; use Sabre\CalDAV\Backend\BackendInterface; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\IMoveTarget; +use Sabre\DAV\INode; use Sabre\DAV\PropPatch; /** * Class Calendar * * @package OCA\DAV\CalDAV - * @property BackendInterface|CalDavBackend $caldavBackend + * @property CalDavBackend $caldavBackend */ -class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { - - /** @var IConfig */ - private $config; +class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable, IMoveTarget { + 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()) + ->setTimestamp($calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT]) + ->format(DateTimeInterface::ATOM); + } - public function __construct(BackendInterface $caldavBackend, $calendarInfo, IL10N $l10n, IConfig $config) { 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->l10n = $l10n; + } - $this->config = $config; + 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(); } @@ -95,19 +85,16 @@ class Calendar extends \Sabre\CalDAV\Calendar implements 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']; } @@ -118,33 +105,70 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { return $this->calendarInfo['principaluri']; } + /** + * @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 = [ + $acl = [ [ 'privilege' => '{DAV:}read', 'principal' => $this->getOwner(), 'protected' => true, - ]]; + ], + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner() . '/calendar-proxy-write', + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner() . '/calendar-proxy-read', + 'protected' => true, + ], + ]; + if ($this->getName() !== BirthdayService::BIRTHDAY_CALENDAR_URI) { $acl[] = [ 'privilege' => '{DAV:}write', 'principal' => $this->getOwner(), 'protected' => true, ]; + $acl[] = [ + 'privilege' => '{DAV:}write', + 'principal' => $this->getOwner() . '/calendar-proxy-write', + 'protected' => true, + ]; } else { $acl[] = [ 'privilege' => '{DAV:}write-properties', 'principal' => $this->getOwner(), 'protected' => true, ]; + $acl[] = [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner() . '/calendar-proxy-write', + 'protected' => true, + ]; + } + + $acl[] = [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner() . '/calendar-proxy-read', + 'protected' => true, + ]; + + if (!$this->isShared()) { + return $acl; } if ($this->getOwner() !== parent::getOwner()) { - $acl[] = [ - 'privilege' => '{DAV:}read', - 'principal' => parent::getOwner(), - 'protected' => true, - ]; + $acl[] = [ + 'privilege' => '{DAV:}read', + 'principal' => parent::getOwner(), + 'protected' => true, + ]; if ($this->canWrite()) { $acl[] = [ 'privilege' => '{DAV:}write', @@ -168,22 +192,25 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { } $acl = $this->caldavBackend->applyShareAcl($this->getResourceId(), $acl); - - if (!$this->isShared()) { - return $acl; - } - - $allowedPrincipals = [$this->getOwner(), parent::getOwner(), 'principals/system/public']; - return array_filter($acl, function($rule) use ($allowedPrincipals) { - return in_array($rule['principal'], $allowedPrincipals); + $allowedPrincipals = [ + $this->getOwner(), + $this->getOwner() . '/calendar-proxy-read', + $this->getOwner() . '/calendar-proxy-write', + parent::getOwner(), + 'principals/system/public' + ]; + /** @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']; } @@ -191,20 +218,8 @@ class Calendar extends \Sabre\CalDAV\Calendar implements 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, [], [ - 'href' => $principal - ]); + if ($this->isShared()) { + $this->caldavBackend->unshare($this, 'principal:' . $this->getPrincipalURI()); return; } @@ -217,7 +232,10 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { $this->config->setUserValue($userId, 'dav', 'generateBirthdayCalendar', 'no'); } - parent::delete(); + $this->caldavBackend->deleteCalendar( + $this->calendarInfo['id'], + !$this->useTrashbin + ); } public function propPatch(PropPatch $propPatch) { @@ -229,7 +247,6 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { } public function getChild($name) { - $obj = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $name); if (!$obj) { @@ -242,12 +259,10 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { $obj['acl'] = $this->getChildACL(); - return new CalendarObject($this->caldavBackend, $this->calendarInfo, $obj); - + return new CalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj); } public function getChildren() { - $objs = $this->caldavBackend->getCalendarObjects($this->calendarInfo['id']); $children = []; foreach ($objs as $obj) { @@ -255,14 +270,12 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { continue; } $obj['acl'] = $this->getChildACL(); - $children[] = new CalendarObject($this->caldavBackend, $this->calendarInfo, $obj); + $children[] = new CalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj); } return $children; - } public function getMultipleChildren(array $paths) { - $objs = $this->caldavBackend->getMultipleCalendarObjects($this->calendarInfo['id'], $paths); $children = []; foreach ($objs as $obj) { @@ -270,10 +283,9 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { continue; } $obj['acl'] = $this->getChildACL(); - $children[] = new CalendarObject($this->caldavBackend, $this->calendarInfo, $obj); + $children[] = new CalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj); } return $children; - } public function childExists($name) { @@ -289,7 +301,6 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { } public function calendarQuery(array $filters) { - $uris = $this->caldavBackend->calendarQuery($this->calendarInfo['id'], $filters); if ($this->isShared()) { return array_filter($uris, function ($uri) { @@ -317,7 +328,11 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable { return $this->caldavBackend->getPublishStatus($this); } - private function canWrite() { + public function canWrite() { + if ($this->getName() === BirthdayService::BIRTHDAY_CALENDAR_URI) { + return false; + } + if (isset($this->calendarInfo['{http://owncloud.org/ns}read-only'])) { return !$this->calendarInfo['{http://owncloud.org/ns}read-only']; } @@ -328,7 +343,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements 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; } @@ -340,4 +355,53 @@ class Calendar extends \Sabre\CalDAV\Calendar implements 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 + */ + public function getChanges($syncToken, $syncLevel, $limit = null) { + if (!$syncToken && $limit) { + throw new UnsupportedLimitOnInitialSyncException(); + } + + return parent::getChanges($syncToken, $syncLevel, $limit); + } + + /** + * @inheritDoc + */ + public function restore(): void { + $this->caldavBackend->restoreCalendar((int)$this->calendarInfo['id']); + } + + public function disableTrashbin(): void { + $this->useTrashbin = false; + } + + /** + * @inheritDoc + */ + public function moveInto($targetName, $sourcePath, INode $sourceNode) { + if (!($sourceNode instanceof CalendarObject)) { + return false; + } + try { + 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 3e645db459f..89b78ba9007 100644 --- a/apps/dav/lib/CalDAV/CalendarHome.php +++ b/apps/dav/lib/CalDAV/CalendarHome.php @@ -1,52 +1,57 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @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; +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; use Sabre\CalDAV\Backend\SchedulingSupport; use Sabre\CalDAV\Backend\SubscriptionSupport; use Sabre\CalDAV\Schedule\Inbox; -use Sabre\CalDAV\Schedule\Outbox; use Sabre\CalDAV\Subscriptions\Subscription; -use Sabre\DAV\Exception\NotFound; use Sabre\DAV\Exception\MethodNotAllowed; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\INode; use Sabre\DAV\MkCol; class CalendarHome extends \Sabre\CalDAV\CalendarHome { - /** @var \OCP\IL10N */ + /** @var IL10N */ private $l10n; - /** @var \OCP\IConfig */ + /** @var IConfig */ private $config; - public function __construct(BackendInterface $caldavBackend, $principalInfo) { + /** @var PluginManager */ + private $pluginManager; + 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, + Server::get(IAppManager::class) + ); } /** @@ -59,10 +64,13 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { /** * @inheritdoc */ - function createExtendedCollection($name, MkCol $mkCol) { - $reservedNames = [BirthdayService::BIRTHDAY_CALENDAR_URI]; + public function createExtendedCollection($name, MkCol $mkCol): void { + $reservedNames = [ + BirthdayService::BIRTHDAY_CALENDAR_URI, + TrashbinHome::NAME, + ]; - if (in_array($name, $reservedNames)) { + if (\in_array($name, $reservedNames, true) || ExternalCalendar::doesViolateReservedName($name)) { throw new MethodNotAllowed('The resource you tried to create has a reserved name'); } @@ -72,16 +80,19 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { /** * @inheritdoc */ - function getChildren() { + public function getChildren() { + if ($this->cachedChildren) { + return $this->cachedChildren; + } $calendars = $this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']); $objects = []; foreach ($calendars as $calendar) { - $objects[] = new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config); + $objects[] = new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger); } if ($this->caldavBackend instanceof SchedulingSupport) { $objects[] = new Inbox($this->caldavBackend, $this->principalInfo['uri']); - $objects[] = new Outbox($this->principalInfo['uri']); + $objects[] = new Outbox($this->config, $this->principalInfo['uri']); } // We're adding a notifications node, if it's supported by the backend. @@ -89,45 +100,94 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { $objects[] = new \Sabre\CalDAV\Notifications\Collection($this->caldavBackend, $this->principalInfo['uri']); } + if ($this->caldavBackend instanceof CalDavBackend) { + $objects[] = new TrashbinHome($this->caldavBackend, $this->principalInfo); + } + // If the backend supports subscriptions, we'll add those as well, if ($this->caldavBackend instanceof SubscriptionSupport) { foreach ($this->caldavBackend->getSubscriptionsForUser($this->principalInfo['uri']) as $subscription) { - $objects[] = new Subscription($this->caldavBackend, $subscription); + if ($this->returnCachedSubscriptions) { + $objects[] = new CachedSubscription($this->caldavBackend, $subscription); + } else { + $objects[] = new Subscription($this->caldavBackend, $subscription); + } + } + } + + foreach ($this->pluginManager->getCalendarPlugins() as $calendarPlugin) { + /** @var ICalendarProvider $calendarPlugin */ + $calendars = $calendarPlugin->fetchAllForCalendarHome($this->principalInfo['uri']); + foreach ($calendars as $calendar) { + $objects[] = $calendar; } } + $this->cachedChildren = $objects; return $objects; } /** - * @inheritdoc + * @param string $name + * + * @return INode */ - function getChild($name) { + public function getChild($name) { // Special nodes if ($name === 'inbox' && $this->caldavBackend instanceof SchedulingSupport) { return new Inbox($this->caldavBackend, $this->principalInfo['uri']); } if ($name === 'outbox' && $this->caldavBackend instanceof SchedulingSupport) { - return new Outbox($this->principalInfo['uri']); + return new Outbox($this->config, $this->principalInfo['uri']); } if ($name === 'notifications' && $this->caldavBackend instanceof NotificationSupport) { - return new \Sabre\CalDAv\Notifications\Collection($this->caldavBackend, $this->principalInfo['uri']); + return new \Sabre\CalDAV\Notifications\Collection($this->caldavBackend, $this->principalInfo['uri']); + } + if ($name === TrashbinHome::NAME && $this->caldavBackend instanceof CalDavBackend) { + return new TrashbinHome($this->caldavBackend, $this->principalInfo); + } + + // 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); + } } - // Calendars + // 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); + return new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger); } } if ($this->caldavBackend instanceof SubscriptionSupport) { foreach ($this->caldavBackend->getSubscriptionsForUser($this->principalInfo['uri']) as $subscription) { if ($subscription['uri'] === $name) { + if ($this->returnCachedSubscriptions) { + return new CachedSubscription($this->caldavBackend, $subscription); + } + return new Subscription($this->caldavBackend, $subscription); } } + } + if (ExternalCalendar::isAppGeneratedCalendar($name)) { + [$appId, $calendarUri] = ExternalCalendar::splitAppGeneratedCalendarUri($name); + + foreach ($this->pluginManager->getCalendarPlugins() as $calendarPlugin) { + /** @var ICalendarProvider $calendarPlugin */ + if ($calendarPlugin->getAppId() !== $appId) { + continue; + } + + if ($calendarPlugin->hasCalendarInCalendarHome($this->principalInfo['uri'], $calendarUri)) { + return $calendarPlugin->getCalendarInCalendarHome($this->principalInfo['uri'], $calendarUri); + } + } } throw new NotFound('Node with name \'' . $name . '\' could not be found'); @@ -138,7 +198,7 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { * @param integer|null $limit * @param integer|null $offset */ - function calendarSearch(array $filters, $limit=null, $offset=null) { + public function calendarSearch(array $filters, $limit = null, $offset = null) { $principalUri = $this->principalInfo['uri']; return $this->caldavBackend->calendarSearch($principalUri, $filters, $limit, $offset); } diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index cfdf821a563..5f912da732e 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -1,106 +1,116 @@ <?php + +declare(strict_types=1); + /** - * @copyright 2017, 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: 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 OCP\Calendar\ICalendar; - -class CalendarImpl implements ICalendar { - - /** @var CalDavBackend */ - private $backend; +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; - /** @var Calendar */ - private $calendar; - - /** @var array */ - private $calendarInfo; +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, + ) { + } /** - * CalendarImpl constructor. - * - * @param Calendar $calendar - * @param array $calendarInfo - * @param CalDavBackend $backend + * @return string defining the technical unique key + * @since 13.0.0 */ - public function __construct(Calendar $calendar, array $calendarInfo, - CalDavBackend $backend) { - $this->calendar = $calendar; - $this->calendarInfo = $calendarInfo; - $this->backend = $backend; + public function getKey(): string { + return (string)$this->calendarInfo['id']; } - + /** - * @return string defining the technical unique key - * @since 13.0.0 + * {@inheritDoc} */ - public function getKey() { - return $this->calendarInfo['id']; + public function getUri(): string { + return $this->calendarInfo['uri']; } /** * 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) { - switch($permission['privilege']) { + if ($this->calendarInfo['principaluri'] !== $permission['principal']) { + continue; + } + + switch ($permission['privilege']) { case '{DAV:}read': $result |= Constants::PERMISSION_READ; break; @@ -116,4 +126,167 @@ class CalendarImpl implements ICalendar { return $result; } + + /** + * @since 32.0.0 + */ + 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->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'); + } + + // Build full calendar path + [, $user] = uriSplit($this->calendar->getPrincipalURI()); + $fullCalendarFilename = sprintf('calendars/%s/%s/%s', $user, $this->calendarInfo['uri'], $name); + + // Force calendar change URI + /** @var Schedule\Plugin $schedulingPlugin */ + $schedulingPlugin = $server->getPlugin('caldav-schedule'); + $schedulingPlugin->setPathOfCalendarObjectChange($fullCalendarFilename); + + $stream = fopen('php://memory', 'rb+'); + fwrite($stream, $calendarData); + rewind($stream); + try { + $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 59590def5ec..a2d2f1cda8a 100644 --- a/apps/dav/lib/CalDAV/CalendarManager.php +++ b/apps/dav/lib/CalDAV/CalendarManager.php @@ -1,43 +1,18 @@ <?php + /** - * @copyright 2017, 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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\DAV\CalDAV; use OCP\Calendar\IManager; use OCP\IConfig; use OCP\IL10N; +use Psr\Log\LoggerInterface; class CalendarManager { - /** @var CalDavBackend */ - private $backend; - - /** @var IL10N */ - private $l10n; - - /** @var IConfig */ - private $config; - /** * CalendarManager constructor. * @@ -45,10 +20,12 @@ class CalendarManager { * @param IL10N $l10n * @param IConfig $config */ - public function __construct(CalDavBackend $backend, IL10N $l10n, IConfig $config) { - $this->backend = $backend; - $this->l10n = $l10n; - $this->config = $config; + public function __construct( + private CalDavBackend $backend, + private IL10N $l10n, + private IConfig $config, + private LoggerInterface $logger, + ) { } /** @@ -65,8 +42,8 @@ class CalendarManager { * @param array $calendars */ private function register(IManager $cm, array $calendars) { - foreach($calendars as $calendarInfo) { - $calendar = new Calendar($this->backend, $calendarInfo, $this->l10n, $this->config); + foreach ($calendars as $calendarInfo) { + $calendar = new Calendar($this->backend, $calendarInfo, $this->l10n, $this->config, $this->logger); $cm->registerCalendar(new CalendarImpl( $calendar, $calendarInfo, diff --git a/apps/dav/lib/CalDAV/CalendarObject.php b/apps/dav/lib/CalDAV/CalendarObject.php index 0db592898af..02178b4236f 100644 --- a/apps/dav/lib/CalDAV/CalendarObject.php +++ b/apps/dav/lib/CalDAV/CalendarObject.php @@ -1,31 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2017, Georg Ehrke - * - * @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; - +use OCP\IL10N; use Sabre\VObject\Component; use Sabre\VObject\Property; use Sabre\VObject\Reader; @@ -36,11 +18,16 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { * CalendarObject constructor. * * @param CalDavBackend $caldavBackend + * @param IL10N $l10n * @param array $calendarInfo * @param array $objectData */ - public function __construct(CalDavBackend $caldavBackend, array $calendarInfo, - array $objectData) { + public function __construct( + CalDavBackend $caldavBackend, + protected IL10N $l10n, + array $calendarInfo, + array $objectData, + ) { parent::__construct($caldavBackend, $calendarInfo, $objectData); if ($this->isShared()) { @@ -51,7 +38,7 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { /** * @inheritdoc */ - function get() { + public function get() { $data = parent::get(); if (!$this->isShared()) { @@ -73,6 +60,10 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { return $vObject->serialize(); } + public function getId(): int { + return (int)$this->objectData['id']; + } + protected function isShared() { if (!isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) { return false; @@ -85,32 +76,33 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { * @param Component\VCalendar $vObject * @return void */ - private static 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) { + 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': - $property->setValue('Busy'); + $property->setValue($this->l10n->t('Busy')); break; default: $vElement->__unset($property->name); @@ -128,7 +120,7 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { private function removeVAlarms(Component\VCalendar $vObject) { $subcomponents = $vObject->getComponents(); - foreach($subcomponents as $subcomponent) { + foreach ($subcomponents as $subcomponent) { unset($subcomponent->VALARM); } } @@ -142,4 +134,19 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { } return true; } + + public function getCalendarId(): int { + return (int)$this->objectData['calendarid']; + } + + 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 new file mode 100644 index 00000000000..a8b818e59aa --- /dev/null +++ b/apps/dav/lib/CalDAV/CalendarProvider.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +/** + * 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; +use Psr\Log\LoggerInterface; + +class CalendarProvider implements ICalendarProvider { + + 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 = $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, + $calendarInfo, + $this->calDavBackend, + ); + } + 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 2c1c8bb4ef2..c0a313955bb 100644 --- a/apps/dav/lib/CalDAV/CalendarRoot.php +++ b/apps/dav/lib/CalDAV/CalendarRoot.php @@ -1,30 +1,49 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Lukas Reschke <lukas@statuscode.ch> - * @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; +use Psr\Log\LoggerInterface; +use Sabre\CalDAV\Backend; +use Sabre\DAVACL\PrincipalBackend; + class CalendarRoot extends \Sabre\CalDAV\CalendarRoot { + private array $returnCachedSubscriptions = []; + + public function __construct( + PrincipalBackend\BackendInterface $principalBackend, + Backend\BackendInterface $caldavBackend, + $principalPrefix, + private LoggerInterface $logger, + ) { + parent::__construct($principalBackend, $caldavBackend, $principalPrefix); + } + + public function getChildForPrincipal(array $principal) { + 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') { + $parts = explode('/', $this->principalPrefix); + + return $parts[1]; + } + + return parent::getName(); + } - function getChildForPrincipal(array $principal) { - return new CalendarHome($this->caldavBackend, $principal); + public function enableReturnCachedSubscriptions(string $principalUri): void { + $this->returnCachedSubscriptions['principals/users/' . $principalUri] = true; } -}
\ No newline at end of file +} 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 new file mode 100644 index 00000000000..08dc10f7bf4 --- /dev/null +++ b/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php @@ -0,0 +1,72 @@ +<?php + +/** + * 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 Psr\Log\LoggerInterface; +use Sabre\HTTP\ResponseInterface; +use Sabre\VObject\DateTimeParser; +use Sabre\VObject\InvalidDataException; +use Sabre\VObject\Property\ICalendar\Duration; + +/** + * Class ICSExportPlugin + * + * @package OCA\DAV\CalDAV\ICSExportPlugin + */ +class ICSExportPlugin extends \Sabre\CalDAV\ICSExportPlugin { + /** @var string */ + private const DEFAULT_REFRESH_INTERVAL = 'PT4H'; + + /** + * ICSExportPlugin constructor. + */ + public function __construct( + private IConfig $config, + private LoggerInterface $logger, + ) { + } + + /** + * @inheritDoc + */ + protected function generateResponse($path, $start, $end, $expand, $componentType, $format, $properties, ResponseInterface $response) { + if (!isset($properties['{http://nextcloud.com/ns}refresh-interval'])) { + $value = $this->config->getAppValue('dav', 'defaultRefreshIntervalExportedCalendars', self::DEFAULT_REFRESH_INTERVAL); + $properties['{http://nextcloud.com/ns}refresh-interval'] = $value; + } + + return parent::generateResponse($path, $start, $end, $expand, $componentType, $format, $properties, $response); + } + + /** + * @inheritDoc + */ + public function mergeObjects(array $properties, array $inputObjects) { + $vcalendar = parent::mergeObjects($properties, $inputObjects); + + if (isset($properties['{http://nextcloud.com/ns}refresh-interval'])) { + $refreshIntervalValue = $properties['{http://nextcloud.com/ns}refresh-interval']; + try { + DateTimeParser::parseDuration($refreshIntervalValue); + } catch (InvalidDataException $ex) { + $this->logger->debug('Invalid refresh interval for exported calendar, falling back to default value ...'); + $refreshIntervalValue = self::DEFAULT_REFRESH_INTERVAL; + } + + // https://tools.ietf.org/html/rfc7986#section-5.7 + $refreshInterval = new Duration($vcalendar, 'REFRESH-INTERVAL', $refreshIntervalValue); + $refreshInterval->add('VALUE', 'DURATION'); + $vcalendar->add($refreshInterval); + + // Legacy property for compatibility + $vcalendar->{'X-PUBLISHED-TTL'} = $refreshIntervalValue; + } + + return $vcalendar; + } +} diff --git a/apps/dav/lib/CalDAV/IRestorable.php b/apps/dav/lib/CalDAV/IRestorable.php new file mode 100644 index 00000000000..5850e0a5645 --- /dev/null +++ b/apps/dav/lib/CalDAV/IRestorable.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV; + +use Sabre\DAV\Exception; + +/** + * Interface for nodes that can be restored from the trashbin + */ +interface IRestorable { + + /** + * Restore this node + * + * @throws Exception + */ + public function restore(): void; +} diff --git a/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php b/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php new file mode 100644 index 00000000000..acf81638679 --- /dev/null +++ b/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php @@ -0,0 +1,110 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Integration; + +use Sabre\CalDAV; +use Sabre\DAV; + +/** + * Class ExternalCalendar + * + * @package OCA\DAV\CalDAV\Integration + * @since 19.0.0 + */ +abstract class ExternalCalendar implements CalDAV\ICalendar, DAV\IProperties { + + /** @var string */ + private const PREFIX = 'app-generated'; + + /** + * @var string + * + * Double dash is a valid delimiter, + * because it will always split the calendarURIs correctly: + * - our prefix contains only one dash and won't be split + * - appIds are not allowed to contain dashes as per spec: + * > must contain only lowercase ASCII characters and underscore + * - explode has a limit of three, so even if the app-generated + * calendar uri has double dashes, it won't be split + */ + private const DELIMITER = '--'; + + /** + * ExternalCalendar constructor. + * + * @param string $appId + * @param string $calendarUri + */ + public function __construct( + private string $appId, + private string $calendarUri, + ) { + } + + /** + * @inheritDoc + */ + final public function getName() { + return implode(self::DELIMITER, [ + self::PREFIX, + $this->appId, + $this->calendarUri, + ]); + } + + /** + * @inheritDoc + */ + final public function setName($name) { + throw new DAV\Exception\MethodNotAllowed('Renaming calendars is not yet supported'); + } + + /** + * @inheritDoc + */ + final public function createDirectory($name) { + throw new DAV\Exception\MethodNotAllowed('Creating collections in calendar objects is not allowed'); + } + + /** + * Checks whether the calendar uri is app-generated + * + * @param string $calendarUri + * @return bool + */ + public static function isAppGeneratedCalendar(string $calendarUri):bool { + return str_starts_with($calendarUri, self::PREFIX) && substr_count($calendarUri, self::DELIMITER) >= 2; + } + + /** + * Splits an app-generated calendar-uri into appId and calendarUri + * + * @param string $calendarUri + * @return array + */ + public static function splitAppGeneratedCalendarUri(string $calendarUri):array { + $array = array_slice(explode(self::DELIMITER, $calendarUri, 3), 1); + // Check the array has expected amount of elements + // and none of them is an empty string + if (\count($array) !== 2 || \in_array('', $array, true)) { + throw new \InvalidArgumentException('Provided calendar uri was not app-generated'); + } + + return $array; + } + + /** + * Checks whether a calendar-name, the user wants to create, violates + * the reserved name for calendar uris + * + * @param string $calendarUri + * @return bool + */ + public static function doesViolateReservedName(string $calendarUri):bool { + 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 new file mode 100644 index 00000000000..40a8860dcb4 --- /dev/null +++ b/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php @@ -0,0 +1,54 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Integration; + +/** + * Interface ICalendarProvider + * + * @package OCA\DAV\CalDAV\Integration + * @since 19.0.0 + */ +interface ICalendarProvider { + + /** + * Provides the appId of the plugin + * + * @since 19.0.0 + * @return string AppId + */ + public function getAppId(): string; + + /** + * Fetches all calendars for a given principal uri + * + * @since 19.0.0 + * @param string $principalUri E.g. principals/users/user1 + * @return ExternalCalendar[] Array of all calendars + */ + public function fetchAllForCalendarHome(string $principalUri): array; + + /** + * Checks whether plugin has a calendar for a given principalUri and calendarUri + * + * @since 19.0.0 + * @param string $principalUri E.g. principals/users/user1 + * @param string $calendarUri E.g. personal + * @return bool True if calendar for principalUri and calendarUri exists, false otherwise + */ + public function hasCalendarInCalendarHome(string $principalUri, string $calendarUri): bool; + + /** + * Fetches a calendar for a given principalUri and calendarUri + * Returns null if calendar does not exist + * + * @since 19.0.0 + * @param string $principalUri E.g. principals/users/user1 + * @param string $calendarUri E.g. personal + * @return ExternalCalendar|null Calendar if it exists, null otherwise + */ + public function getCalendarInCalendarHome(string $principalUri, string $calendarUri): ?ExternalCalendar; +} diff --git a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php new file mode 100644 index 00000000000..c8a7109abde --- /dev/null +++ b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php @@ -0,0 +1,129 @@ +<?php + +/** + * 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; + + /** + * InvitationResponseServer constructor. + */ + public function __construct(bool $public = true) { + $baseUri = \OC::$WEBROOT . '/remote.php/dav/'; + $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 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( + 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) + )); + + // 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); + } + }); + } + + /** + * @param Message $iTipMessage + * @return void + */ + public function handleITipMessage(Message $iTipMessage) { + /** @var \OCA\DAV\CalDAV\Schedule\Plugin $schedulingPlugin */ + $schedulingPlugin = $this->server->getPlugin('caldav-schedule'); + $schedulingPlugin->scheduleLocalDelivery($iTipMessage); + } + + public function isExternalAttendee(string $principalUri): bool { + /** @var \Sabre\DAVACL\Plugin $aclPlugin */ + $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 new file mode 100644 index 00000000000..608114d8093 --- /dev/null +++ b/apps/dav/lib/CalDAV/Outbox.php @@ -0,0 +1,116 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV; + +use OCP\IConfig; +use Sabre\CalDAV\Plugin as CalDAVPlugin; + +/** + * Class Outbox + * + * @package OCA\DAV\CalDAV + */ +class Outbox extends \Sabre\CalDAV\Schedule\Outbox { + + /** @var null|bool */ + private $disableFreeBusy = null; + + /** + * Outbox constructor. + * + * @param IConfig $config + * @param string $principalUri + */ + public function __construct( + private IConfig $config, + string $principalUri, + ) { + parent::__construct($principalUri); + } + + /** + * Returns a list of ACE's for this node. + * + * Each ACE has the following properties: + * * 'privilege', a string such as {DAV:}read or {DAV:}write. These are + * currently the only supported privileges + * * 'principal', a url to the principal who owns the node + * * 'protected' (optional), indicating that this ACE is not allowed to + * be updated. + * + * @return array + */ + public function getACL() { + // getACL is called so frequently that we cache the config result + if ($this->disableFreeBusy === null) { + $this->disableFreeBusy = ($this->config->getAppValue('dav', 'disableFreeBusy', 'no') === 'yes'); + } + + $commonAcl = [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner() . '/calendar-proxy-read', + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner() . '/calendar-proxy-write', + 'protected' => true, + ], + ]; + + // schedule-send is an aggregate privilege for: + // - schedule-send-invite + // - schedule-send-reply + // - schedule-send-freebusy + // + // If FreeBusy is disabled, we have to remove the latter privilege + + if ($this->disableFreeBusy) { + return array_merge($commonAcl, [ + [ + 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send-invite', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send-invite', + 'principal' => $this->getOwner() . '/calendar-proxy-write', + 'protected' => true, + ], + [ + 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send-reply', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send-reply', + 'principal' => $this->getOwner() . '/calendar-proxy-write', + 'protected' => true, + ], + ]); + } + + return array_merge($commonAcl, [ + [ + 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send', + 'principal' => $this->getOwner() . '/calendar-proxy-write', + 'protected' => true, + ], + ]); + } +} diff --git a/apps/dav/lib/CalDAV/Plugin.php b/apps/dav/lib/CalDAV/Plugin.php index 167ea4ffd69..24448ae71ab 100644 --- a/apps/dav/lib/CalDAV/Plugin.php +++ b/apps/dav/lib/CalDAV/Plugin.php @@ -1,43 +1,37 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud GmbH. - * - * @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: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud GmbH. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\DAV\CalDAV; -use Sabre\HTTP\URLUtil; - class Plugin extends \Sabre\CalDAV\Plugin { + public const SYSTEM_CALENDAR_ROOT = 'system-calendars'; /** - * @inheritdoc + * Returns the path to a principal's calendar home. + * + * The return url must not end with a slash. + * This function should return null in case a principal did not have + * a calendar home. + * + * @param string $principalUrl + * @return string|null */ - function getCalendarHomeForPrincipal($principalUrl) { - + public function getCalendarHomeForPrincipal($principalUrl) { if (strrpos($principalUrl, 'principals/users', -strlen($principalUrl)) !== false) { - list(, $principalId) = \Sabre\Uri\split($principalUrl); - return self::CALENDAR_ROOT .'/' . $principalId; + [, $principalId] = \Sabre\Uri\split($principalUrl); + return self::CALENDAR_ROOT . '/' . $principalId; + } + if (strrpos($principalUrl, 'principals/calendar-resources', -strlen($principalUrl)) !== false) { + [, $principalId] = \Sabre\Uri\split($principalUrl); + return self::SYSTEM_CALENDAR_ROOT . '/calendar-resources/' . $principalId; + } + if (strrpos($principalUrl, 'principals/calendar-rooms', -strlen($principalUrl)) !== false) { + [, $principalId] = \Sabre\Uri\split($principalUrl); + return self::SYSTEM_CALENDAR_ROOT . '/calendar-rooms/' . $principalId; } - - return; } - } diff --git a/apps/dav/lib/CalDAV/Principal/Collection.php b/apps/dav/lib/CalDAV/Principal/Collection.php index cadfc66c26b..b76fde66464 100644 --- a/apps/dav/lib/CalDAV/Principal/Collection.php +++ b/apps/dav/lib/CalDAV/Principal/Collection.php @@ -1,30 +1,11 @@ <?php /** - * @copyright Copyright (c) 2017, Christoph Seitz <christoph.seitz@posteo.de> - * - * @author Christoph Seitz <christoph.seitz@posteo.de> - * - * @license GNU AGPL version 3 or any later version - * - * 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-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\DAV\CalDAV\Principal; -use OCA\DAV\CalDAV\Principal\User; - /** * Class Collection * @@ -38,8 +19,7 @@ class Collection extends \Sabre\CalDAV\Principal\Collection { * @param array $principalInfo * @return User */ - function getChildForPrincipal(array $principalInfo) { + public function getChildForPrincipal(array $principalInfo) { return new User($this->principalBackend, $principalInfo); } - } diff --git a/apps/dav/lib/CalDAV/Principal/User.php b/apps/dav/lib/CalDAV/Principal/User.php index 85b0401e865..047d83827ed 100644 --- a/apps/dav/lib/CalDAV/Principal/User.php +++ b/apps/dav/lib/CalDAV/Principal/User.php @@ -1,26 +1,9 @@ <?php /** - * @copyright Copyright (c) 2017, Christoph Seitz <christoph.seitz@posteo.de> - * - * @author Christoph Seitz <christoph.seitz@posteo.de> - * - * @license GNU AGPL version 3 or any later version - * - * 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-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\DAV\CalDAV\Principal; /** @@ -42,7 +25,7 @@ class User extends \Sabre\CalDAV\Principal\User { * * @return array */ - function getACL() { + public function getACL() { $acl = parent::getACL(); $acl[] = [ 'privilege' => '{DAV:}read', @@ -51,5 +34,4 @@ class User extends \Sabre\CalDAV\Principal\User { ]; return $acl; } - } diff --git a/apps/dav/lib/CalDAV/Proxy/Proxy.php b/apps/dav/lib/CalDAV/Proxy/Proxy.php new file mode 100644 index 00000000000..ef1ad8c634f --- /dev/null +++ b/apps/dav/lib/CalDAV/Proxy/Proxy.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +/** + * 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() + * @method void setOwnerId(string $ownerId) + * @method string getProxyId() + * @method void setProxyId(string $proxyId) + * @method int getPermissions() + * @method void setPermissions(int $permissions) + */ +class Proxy extends Entity { + + /** @var string */ + protected $ownerId; + /** @var string */ + protected $proxyId; + /** @var int */ + protected $permissions; + + public function __construct() { + $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 new file mode 100644 index 00000000000..3b9b9c3d9eb --- /dev/null +++ b/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/** + * 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\QBMapper; +use OCP\IDBConnection; + +/** + * Class ProxyMapper + * + * @package OCA\DAV\CalDAV\Proxy + * + * @template-extends QBMapper<Proxy> + */ +class ProxyMapper extends QBMapper { + public const PERMISSION_READ = 1; + public const PERMISSION_WRITE = 2; + + /** + * ProxyMapper constructor. + * + * @param IDBConnection $db + */ + public function __construct(IDBConnection $db) { + parent::__construct($db, 'dav_cal_proxy', Proxy::class); + } + + /** + * @param string $proxyId The principal uri that can act as a proxy for the resulting calendars + * + * @return Proxy[] + */ + public function getProxiesFor(string $proxyId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('proxy_id', $qb->createNamedParameter($proxyId))); + + return $this->findEntities($qb); + } + + /** + * @param string $ownerId The principal uri that has the resulting proxies for their calendars + * + * @return Proxy[] + */ + public function getProxiesOf(string $ownerId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('owner_id', $qb->createNamedParameter($ownerId))); + + return $this->findEntities($qb); + } +} diff --git a/apps/dav/lib/CalDAV/PublicCalendar.php b/apps/dav/lib/CalDAV/PublicCalendar.php index f65ac9797b8..9af6e544165 100644 --- a/apps/dav/lib/CalDAV/PublicCalendar.php +++ b/apps/dav/lib/CalDAV/PublicCalendar.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017, 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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; @@ -42,7 +26,7 @@ class PublicCalendar extends Calendar { } $obj['acl'] = $this->getChildACL(); - return new PublicCalendarObject($this->caldavBackend, $this->calendarInfo, $obj); + return new PublicCalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj); } /** @@ -56,7 +40,7 @@ class PublicCalendar extends Calendar { continue; } $obj['acl'] = $this->getChildACL(); - $children[] = new PublicCalendarObject($this->caldavBackend, $this->calendarInfo, $obj); + $children[] = new PublicCalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj); } return $children; } @@ -73,7 +57,7 @@ class PublicCalendar extends Calendar { continue; } $obj['acl'] = $this->getChildACL(); - $children[] = new PublicCalendarObject($this->caldavBackend, $this->calendarInfo, $obj); + $children[] = new PublicCalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj); } return $children; } @@ -82,7 +66,7 @@ class PublicCalendar extends Calendar { * public calendars are always shared * @return bool */ - protected function isShared() { + public function isShared() { return true; } -}
\ No newline at end of file +} diff --git a/apps/dav/lib/CalDAV/PublicCalendarObject.php b/apps/dav/lib/CalDAV/PublicCalendarObject.php index aaeea64b237..2ab40b94347 100644 --- a/apps/dav/lib/CalDAV/PublicCalendarObject.php +++ b/apps/dav/lib/CalDAV/PublicCalendarObject.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017, 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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; @@ -31,4 +15,4 @@ class PublicCalendarObject extends CalendarObject { protected function isShared() { return true; } -}
\ No newline at end of file +} diff --git a/apps/dav/lib/CalDAV/PublicCalendarRoot.php b/apps/dav/lib/CalDAV/PublicCalendarRoot.php index 9385f487bda..edfb9f8dccc 100644 --- a/apps/dav/lib/CalDAV/PublicCalendarRoot.php +++ b/apps/dav/lib/CalDAV/PublicCalendarRoot.php @@ -1,44 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Thomas Citharel <tcit@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; use OCP\IConfig; use OCP\IL10N; +use Psr\Log\LoggerInterface; use Sabre\DAV\Collection; class PublicCalendarRoot extends Collection { - /** @var CalDavBackend */ - protected $caldavBackend; - - /** @var \OCP\IL10N */ - protected $l10n; - - /** @var \OCP\IConfig */ - protected $config; - /** * PublicCalendarRoot constructor. * @@ -46,33 +21,33 @@ class PublicCalendarRoot extends Collection { * @param IL10N $l10n * @param IConfig $config */ - function __construct(CalDavBackend $caldavBackend, IL10N $l10n, - IConfig $config) { - $this->caldavBackend = $caldavBackend; - $this->l10n = $l10n; - $this->config = $config; - + public function __construct( + protected CalDavBackend $caldavBackend, + protected IL10N $l10n, + protected IConfig $config, + private LoggerInterface $logger, + ) { } /** * @inheritdoc */ - function getName() { + public function getName() { return 'public-calendars'; } /** * @inheritdoc */ - function getChild($name) { + public function getChild($name) { $calendar = $this->caldavBackend->getPublicCalendar($name); - return new PublicCalendar($this->caldavBackend, $calendar, $this->l10n, $this->config); + return new PublicCalendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger); } /** * @inheritdoc */ - function getChildren() { + public function getChildren() { return []; } } diff --git a/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php b/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php index 53059818039..76378e7a1c5 100644 --- a/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php +++ b/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php @@ -1,43 +1,27 @@ <?php + /** - * @copyright Copyright (c) 2016 Thomas Citharel <tcit@tcit.fr> - * - * @author Thomas Citharel <tcit@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 Sabre\DAV\PropFind; +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; +use Sabre\DAV\Exception\NotFound; use Sabre\DAV\INode; +use Sabre\DAV\PropFind; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; -use Sabre\DAV\Exception\NotFound; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; -use Sabre\CalDAV\Xml\Property\AllowedSharingModes; -use OCA\DAV\CalDAV\Publishing\Xml\Publisher; -use OCA\DAV\CalDAV\Calendar; -use OCP\IURLGenerator; -use OCP\IConfig; class PublishPlugin extends ServerPlugin { - const NS_CALENDARSERVER = 'http://calendarserver.org/ns/'; + public const NS_CALENDARSERVER = 'http://calendarserver.org/ns/'; /** * Reference to SabreDAV server object. @@ -47,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, + ) { } /** @@ -92,7 +69,7 @@ class PublishPlugin extends ServerPlugin { * * @return string */ - public function getPluginName() { + public function getPluginName() { return 'oc-calendar-publishing'; } @@ -110,23 +87,31 @@ 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) { - return new AllowedSharingModes(!$node->isSubscription(), !$node->isSubscription()); + $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 = $canShare && ($node->getOwner() === $node->getPrincipalURI()); + $canPublish = $canPublish && ($node->getOwner() === $node->getPrincipalURI()); + } + + return new AllowedSharingModes($canShare, $canPublish); }); } } @@ -143,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; } @@ -170,60 +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'; + + // Getting ACL info + $acl = $this->server->getPlugin('acl'); - // We can only deal with IShareableCalendar objects - if (!$node instanceof Calendar) { - return; - } - $this->server->transactionType = 'post-publish-calendar'; + // If there's no ACL support, we allow everything + if ($acl) { + /** @var \Sabre\DAVACL\Plugin $acl */ + $acl->checkPrivileges($path, '{DAV:}write'); - // Getting ACL info - $acl = $this->server->getPlugin('acl'); + $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes'; + $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner(); + if ($limitSharingToOwner && !$isOwner) { + return; + } + } - // If there's no ACL support, we allow everything - if ($acl) { - $acl->checkPrivileges($path, '{DAV:}write'); - } + $node->setPublishStatus(true); - $node->setPublishStatus(true); + // iCloud sends back the 202, so we will too. + $response->setStatus(Http::STATUS_ACCEPTED); - // iCloud sends back the 202, so we will too. - $response->setStatus(202); + // 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) { - $acl->checkPrivileges($path, '{DAV:}write'); - } + $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 6d5e05e0cc3..fb9b7298f9b 100644 --- a/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php +++ b/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Thomas Citharel <tcit@tcit.fr> - * - * @author Thomas Citharel <tcit@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; @@ -28,33 +12,24 @@ use Sabre\Xml\XmlSerializable; class Publisher implements XmlSerializable { /** - * @var string $publishUrl - */ - protected $publishUrl; - - /** - * @var boolean $isPublished - */ - protected $isPublished; - - /** * @param string $publishUrl * @param boolean $isPublished */ - function __construct($publishUrl, $isPublished) { - $this->publishUrl = $publishUrl; - $this->isPublished = $isPublished; + public function __construct( + protected $publishUrl, + protected $isPublished, + ) { } /** * @return string */ - function getValue() { + public function getValue() { return $this->publishUrl; } /** - * 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. * @@ -72,7 +47,7 @@ class Publisher implements XmlSerializable { * @param Writer $writer * @return void */ - function xmlSerialize(Writer $writer) { + public function xmlSerialize(Writer $writer) { if (!$this->isPublished) { // for pre-publish-url $writer->write($this->publishUrl); diff --git a/apps/dav/lib/CalDAV/Reminder/Backend.php b/apps/dav/lib/CalDAV/Reminder/Backend.php new file mode 100644 index 00000000000..329af3a2f56 --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/Backend.php @@ -0,0 +1,197 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Reminder; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IDBConnection; + +/** + * Class Backend + * + * @package OCA\DAV\CalDAV\Reminder + */ +class Backend { + + /** + * Backend constructor. + * + * @param IDBConnection $db + * @param ITimeFactory $timeFactory + */ + public function __construct( + protected IDBConnection $db, + protected ITimeFactory $timeFactory, + ) { + } + + /** + * Get all reminders with a notification date before now + * + * @return array + * @throws \Exception + */ + public function getRemindersToProcess():array { + $query = $this->db->getQueryBuilder(); + $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')) + ->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'], + $stmt->fetchAll() + ); + } + + /** + * Get all scheduled reminders for an event + * + * @param int $objectId + * @return array + */ + public function getAllScheduledRemindersForEvent(int $objectId):array { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from('calendar_reminders') + ->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId))); + $stmt = $query->executeQuery(); + + return array_map( + [$this, 'fixRowTyping'], + $stmt->fetchAll() + ); + } + + /** + * Insert a new reminder into the database + * + * @param int $calendarId + * @param int $objectId + * @param string $uid + * @param bool $isRecurring + * @param int $recurrenceId + * @param bool $isRecurrenceException + * @param string $eventHash + * @param string $alarmHash + * @param string $type + * @param bool $isRelative + * @param int $notificationDate + * @param bool $isRepeatBased + * @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 { + $query = $this->db->getQueryBuilder(); + $query->insert('calendar_reminders') + ->values([ + 'calendar_id' => $query->createNamedParameter($calendarId), + 'object_id' => $query->createNamedParameter($objectId), + 'uid' => $query->createNamedParameter($uid), + 'is_recurring' => $query->createNamedParameter($isRecurring ? 1 : 0), + 'recurrence_id' => $query->createNamedParameter($recurrenceId), + 'is_recurrence_exception' => $query->createNamedParameter($isRecurrenceException ? 1 : 0), + 'event_hash' => $query->createNamedParameter($eventHash), + 'alarm_hash' => $query->createNamedParameter($alarmHash), + 'type' => $query->createNamedParameter($type), + 'is_relative' => $query->createNamedParameter($isRelative ? 1 : 0), + 'notification_date' => $query->createNamedParameter($notificationDate), + 'is_repeat_based' => $query->createNamedParameter($isRepeatBased ? 1 : 0), + ]) + ->executeStatement(); + + return $query->getLastInsertId(); + } + + /** + * Sets a new notificationDate on an existing reminder + * + * @param int $reminderId + * @param int $newNotificationDate + */ + public function updateReminder(int $reminderId, + 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))) + ->executeStatement(); + } + + /** + * Remove a reminder by it's id + * + * @param integer $reminderId + * @return void + */ + public function removeReminder(int $reminderId):void { + $query = $this->db->getQueryBuilder(); + + $query->delete('calendar_reminders') + ->where($query->expr()->eq('id', $query->createNamedParameter($reminderId))) + ->executeStatement(); + } + + /** + * Cleans reminders in database + * + * @param int $objectId + */ + public function cleanRemindersForEvent(int $objectId):void { + $query = $this->db->getQueryBuilder(); + + $query->delete('calendar_reminders') + ->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId))) + ->executeStatement(); + } + + /** + * Remove all reminders for a calendar + * + * @param int $calendarId + * @return void + */ + public function cleanRemindersForCalendar(int $calendarId):void { + $query = $this->db->getQueryBuilder(); + + $query->delete('calendar_reminders') + ->where($query->expr()->eq('calendar_id', $query->createNamedParameter($calendarId))) + ->executeStatement(); + } + + /** + * @param array $row + * @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']; + + return $row; + } +} diff --git a/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php b/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php new file mode 100644 index 00000000000..31d60f1531d --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Reminder; + +use OCP\IUser; +use Sabre\VObject\Component\VEvent; + +/** + * Interface INotificationProvider + * + * @package OCA\DAV\CalDAV\Reminder + */ +interface INotificationProvider { + + /** + * Send notification + * + * @param VEvent $vevent + * @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 $principalEmailAddresses, + array $users = []): void; +} diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php new file mode 100644 index 00000000000..94edff98e52 --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php @@ -0,0 +1,157 @@ +<?php + +declare(strict_types=1); + +/** + * 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\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; + +/** + * Class AbstractProvider + * + * @package OCA\DAV\CalDAV\Reminder\NotificationProvider + */ +abstract class AbstractProvider implements INotificationProvider { + + /** @var string */ + public const NOTIFICATION_TYPE = ''; + + /** @var IL10N[] */ + private $l10ns; + + /** @var string */ + private $fallbackLanguage; + + public function __construct( + protected LoggerInterface $logger, + protected L10NFactory $l10nFactory, + protected IURLGenerator $urlGenerator, + protected IConfig $config, + ) { + } + + /** + * Send notification + * + * @param VEvent $vevent + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses + * @param IUser[] $users + * @return void + */ + abstract public function send(VEvent $vevent, + ?string $calendarDisplayName, + array $principalEmailAddresses, + array $users = []): void; + + /** + * @return string + */ + protected function getFallbackLanguage():string { + if ($this->fallbackLanguage) { + return $this->fallbackLanguage; + } + + $fallbackLanguage = $this->l10nFactory->findGenericLanguage(); + $this->fallbackLanguage = $fallbackLanguage; + + return $fallbackLanguage; + } + + /** + * @param string $lang + * @return bool + */ + protected function hasL10NForLang(string $lang):bool { + return $this->l10nFactory->languageExists('dav', $lang); + } + + /** + * @param string $lang + * @return IL10N + */ + protected function getL10NForLang(string $lang):IL10N { + if (isset($this->l10ns[$lang])) { + return $this->l10ns[$lang]; + } + + $l10n = $this->l10nFactory->get('dav', $lang); + $this->l10ns[$lang] = $l10n; + + return $l10n; + } + + /** + * @param VEvent $vevent + * @return string + */ + private function getStatusOfEvent(VEvent $vevent):string { + if ($vevent->STATUS) { + return (string)$vevent->STATUS; + } + + // Doesn't say so in the standard, + // but we consider events without a status + // to be confirmed + return 'CONFIRMED'; + } + + /** + * @param VEvent $vevent + * @return bool + */ + protected function isEventTentative(VEvent $vevent):bool { + return $this->getStatusOfEvent($vevent) === 'TENTATIVE'; + } + + /** + * @param VEvent $vevent + * @return Property\ICalendar\DateTime + */ + protected function getDTEndFromEvent(VEvent $vevent):Property\ICalendar\DateTime { + if (isset($vevent->DTEND)) { + return $vevent->DTEND; + } + + if (isset($vevent->DURATION)) { + $isFloating = $vevent->DTSTART->isFloating(); + /** @var Property\ICalendar\DateTime $end */ + $end = clone $vevent->DTSTART; + $endDateTime = $end->getDateTime(); + $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); + $end->setDateTime($endDateTime, $isFloating); + + return $end; + } + + if (!$vevent->DTSTART->hasTime()) { + $isFloating = $vevent->DTSTART->isFloating(); + /** @var Property\ICalendar\DateTime $end */ + $end = clone $vevent->DTSTART; + $endDateTime = $end->getDateTime(); + $endDateTime = $endDateTime->modify('+1 day'); + $end->setDateTime($endDateTime, $isFloating); + + return $end; + } + + return clone $vevent->DTSTART; + } + + 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 new file mode 100644 index 00000000000..01d51489a3b --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; + +/** + * Class AudioProvider + * + * This class only extends PushProvider at the moment. It does not provide true + * audio-alarms yet, but it's better than no alarm at all right now. + * + * @package OCA\DAV\CalDAV\Reminder\NotificationProvider + */ +class AudioProvider extends PushProvider { + + /** @var string */ + public const NOTIFICATION_TYPE = 'AUDIO'; +} diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php new file mode 100644 index 00000000000..0fd39a9e459 --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php @@ -0,0 +1,442 @@ +<?php + +declare(strict_types=1); + +/** + * 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\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; +use Sabre\VObject\Property; + +/** + * Class EmailProvider + * + * @package OCA\DAV\CalDAV\Reminder\NotificationProvider + */ +class EmailProvider extends AbstractProvider { + /** @var string */ + public const NOTIFICATION_TYPE = 'EMAIL'; + + public function __construct( + IConfig $config, + private IMailer $mailer, + LoggerInterface $logger, + L10NFactory $l10nFactory, + IURLGenerator $urlGenerator, + ) { + parent::__construct($logger, $l10nFactory, $urlGenerator, $config); + } + + /** + * Send out notification via email + * + * @param VEvent $vevent + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses + * @param array $users + * @throws \Exception + */ + public function send(VEvent $vevent, + ?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 = []; + 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. + // => if there are duplicate email addresses, it will always take the system value + $emailAddresses = array_merge( + $emailAddressesOfAttendees, + $emailAddressesOfSharees + ); + + $sortedByLanguage = $this->sortEMailAddressesByLanguage($emailAddresses, $fallbackLanguage); + $organizer = $this->getOrganizerEMailAndNameFromEvent($vevent); + + foreach ($sortedByLanguage as $lang => $emailAddresses) { + if (!$this->hasL10NForLang($lang)) { + $lang = $fallbackLanguage; + } + $l10n = $this->getL10NForLang($lang); + $fromEMail = Util::getDefaultEmailAddress('reminders-noreply'); + + $template = $this->mailer->createEMailTemplate('dav.calendarReminder'); + $template->addHeader(); + $this->addSubjectAndHeading($template, $l10n, $vevent); + $this->addBulletList($template, $l10n, $calendarDisplayName ?? $this->getCalendarDisplayNameFallback($lang), $vevent); + $template->addFooter(); + + foreach ($emailAddresses as $emailAddress) { + if (!$this->mailer->validateMailAddress($emailAddress)) { + $this->logger->error('Email address {address} for reminder notification is incorrect', ['app' => 'dav', 'address' => $emailAddress]); + continue; + } + + $message = $this->mailer->createMessage(); + $message->setFrom([$fromEMail]); + if ($organizer) { + $message->setReplyTo($organizer); + } + $message->setTo([$emailAddress]); + $message->useTemplate($template); + $message->setAutoSubmitted(AutoSubmitted::VALUE_AUTO_GENERATED); + + try { + $failed = $this->mailer->send($message); + if ($failed) { + $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); + } + } catch (\Exception $ex) { + $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]); + } + } + } + } + + /** + * @param IEMailTemplate $template + * @param IL10N $l10n + * @param VEvent $vevent + */ + private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, VEvent $vevent):void { + $template->setSubject('Notification: ' . $this->getTitleFromVEvent($vevent, $l10n)); + $template->addHeading($this->getTitleFromVEvent($vevent, $l10n)); + } + + /** + * @param IEMailTemplate $template + * @param IL10N $l10n + * @param string $calendarDisplayName + * @param array $eventData + */ + private function addBulletList(IEMailTemplate $template, + IL10N $l10n, + string $calendarDisplayName, + VEvent $vevent):void { + $template->addBodyListItem($calendarDisplayName, $l10n->t('Calendar:'), + $this->getAbsoluteImagePath('actions/info.png')); + + $template->addBodyListItem($this->generateDateString($l10n, $vevent), $l10n->t('Date:'), + $this->getAbsoluteImagePath('places/calendar.png')); + + if (isset($vevent->LOCATION)) { + $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:'), + $this->getAbsoluteImagePath('actions/more.png')); + } + } + + private function getAbsoluteImagePath(string $path):string { + return $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath('core', $path) + ); + } + + /** + * @param VEvent $vevent + * @return array|null + */ + private function getOrganizerEMailAndNameFromEvent(VEvent $vevent):?array { + if (!$vevent->ORGANIZER) { + return null; + } + + $organizer = $vevent->ORGANIZER; + if (strcasecmp($organizer->getValue(), 'mailto:') !== 0) { + return null; + } + + $organizerEMail = substr($organizer->getValue(), 7); + + if (!$this->mailer->validateMailAddress($organizerEMail)) { + return null; + } + + $name = $organizer->offsetGet('CN'); + if ($name instanceof Parameter) { + return [$organizerEMail => $name]; + } + + return [$organizerEMail]; + } + + /** + * @param array<string, array{LANG?: string}> $emails + * @return array<string, string[]> + */ + private function sortEMailAddressesByLanguage(array $emails, + string $defaultLanguage):array { + $sortedByLanguage = []; + + foreach ($emails as $emailAddress => $parameters) { + if (isset($parameters['LANG'])) { + $lang = $parameters['LANG']; + } else { + $lang = $defaultLanguage; + } + + if (!isset($sortedByLanguage[$lang])) { + $sortedByLanguage[$lang] = []; + } + + $sortedByLanguage[$lang][] = $emailAddress; + } + + return $sortedByLanguage; + } + + /** + * @param VEvent $vevent + * @return array<string, array{LANG?: string}> + */ + private function getAllEMailAddressesFromEvent(VEvent $vevent):array { + $emailAddresses = []; + + if (isset($vevent->ATTENDEE)) { + foreach ($vevent->ATTENDEE as $attendee) { + if (!($attendee instanceof VObject\Property)) { + continue; + } + + $cuType = $this->getCUTypeOfAttendee($attendee); + if (\in_array($cuType, ['RESOURCE', 'ROOM', 'UNKNOWN'])) { + // Don't send emails to things + continue; + } + + $partstat = $this->getPartstatOfAttendee($attendee); + if ($partstat === 'DECLINED') { + // Don't send out emails to people who declined + continue; + } + if ($partstat === 'DELEGATED') { + $delegates = $attendee->offsetGet('DELEGATED-TO'); + if (!($delegates instanceof VObject\Parameter)) { + continue; + } + + $emailAddressesOfDelegates = $delegates->getParts(); + foreach ($emailAddressesOfDelegates as $addressesOfDelegate) { + if (strcasecmp($addressesOfDelegate, 'mailto:') === 0) { + $delegateEmail = substr($addressesOfDelegate, 7); + if ($this->mailer->validateMailAddress($delegateEmail)) { + $emailAddresses[$delegateEmail] = []; + } + } + } + + continue; + } + + $emailAddressOfAttendee = $this->getEMailAddressOfAttendee($attendee); + if ($emailAddressOfAttendee !== null) { + $properties = []; + + $langProp = $attendee->offsetGet('LANG'); + if ($langProp instanceof VObject\Parameter && $langProp->getValue() !== null) { + $properties['LANG'] = $langProp->getValue(); + } + + $emailAddresses[$emailAddressOfAttendee] = $properties; + } + } + } + + if (isset($vevent->ORGANIZER) && $this->hasAttendeeMailURI($vevent->ORGANIZER)) { + $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER); + if ($organizerEmailAddress !== null) { + $emailAddresses[$organizerEmailAddress] = []; + } + } + + return $emailAddresses; + } + + private function getCUTypeOfAttendee(VObject\Property $attendee):string { + $cuType = $attendee->offsetGet('CUTYPE'); + if ($cuType instanceof VObject\Parameter) { + return strtoupper($cuType->getValue()); + } + + return 'INDIVIDUAL'; + } + + private function getPartstatOfAttendee(VObject\Property $attendee):string { + $partstat = $attendee->offsetGet('PARTSTAT'); + if ($partstat instanceof VObject\Parameter) { + return strtoupper($partstat->getValue()); + } + + return 'NEEDS-ACTION'; + } + + private function hasAttendeeMailURI(VObject\Property $attendee): bool { + return stripos($attendee->getValue(), 'mailto:') === 0; + } + + 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 $attendeeEMail; + } + + /** + * @param IUser[] $users + * @return array<string, array{LANG?: string}> + */ + private function getEMailAddressesOfAllUsersWithWriteAccessToCalendar(array $users):array { + $emailAddresses = []; + + foreach ($users as $user) { + $emailAddress = $user->getEMailAddress(); + if ($emailAddress) { + $lang = $this->l10nFactory->getUserLanguage($user); + if ($lang) { + $emailAddresses[$emailAddress] = [ + 'LANG' => $lang, + ]; + } else { + $emailAddresses[$emailAddress] = []; + } + } + } + + return $emailAddresses; + } + + /** + * @throws \Exception + */ + private function generateDateString(IL10N $l10n, VEvent $vevent): string { + $isAllDay = $vevent->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 = $vevent->DTSTART->getDateTime(); + /** @var \DateTimeImmutable $dtendDt */ + $dtendDt = $this->getDTEndFromEvent($vevent)->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 $this->getDateString($l10n, $dtstartDt); + } + + return implode(' - ', [ + $this->getDateString($l10n, $dtstartDt), + $this->getDateString($l10n, $dtendDt), + ]); + } + + $startTimezone = $endTimezone = null; + if (!$vevent->DTSTART->isFloating()) { + $startTimezone = $vevent->DTSTART->getDateTime()->getTimezone()->getName(); + $endTimezone = $this->getDTEndFromEvent($vevent)->getDateTime()->getTimezone()->getName(); + } + + $localeStart = implode(', ', [ + $this->getWeekDayName($l10n, $dtstartDt), + $this->getDateTimeString($l10n, $dtstartDt) + ]); + + // always show full date with timezone if timezones are different + if ($startTimezone !== $endTimezone) { + $localeEnd = implode(', ', [ + $this->getWeekDayName($l10n, $dtendDt), + $this->getDateTimeString($l10n, $dtendDt) + ]); + + return $localeStart + . ' (' . $startTimezone . ') ' + . ' - ' + . $localeEnd + . ' (' . $endTimezone . ')'; + } + + // Show only the time if the day is the same + $localeEnd = $this->isDayEqual($dtstartDt, $dtendDt) + ? $this->getTimeString($l10n, $dtendDt) + : implode(', ', [ + $this->getWeekDayName($l10n, $dtendDt), + $this->getDateTimeString($l10n, $dtendDt) + ]); + + return $localeStart + . ' - ' + . $localeEnd + . ' (' . $startTimezone . ')'; + } + + private function isDayEqual(DateTime $dtStart, + DateTime $dtEnd):bool { + return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); + } + + private function getWeekDayName(IL10N $l10n, DateTime $dt):string { + return (string)$l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); + } + + private function getDateString(IL10N $l10n, DateTime $dt):string { + return (string)$l10n->l('date', $dt, ['width' => 'medium']); + } + + private function getDateTimeString(IL10N $l10n, DateTime $dt):string { + return (string)$l10n->l('datetime', $dt, ['width' => 'medium|short']); + } + + private function getTimeString(IL10N $l10n, DateTime $dt):string { + return (string)$l10n->l('time', $dt, ['width' => 'short']); + } + + private function getTitleFromVEvent(VEvent $vevent, IL10N $l10n):string { + if (isset($vevent->SUMMARY)) { + return (string)$vevent->SUMMARY; + } + + return $l10n->t('Untitled event'); + } +} diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php new file mode 100644 index 00000000000..15994bacf49 --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; + +class ProviderNotAvailableException extends \Exception { + + /** + * ProviderNotAvailableException constructor. + * + * @since 16.0.0 + * + * @param string $type ReminderType + */ + public function __construct(string $type) { + parent::__construct("No notification provider for type $type available"); + } +} diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php new file mode 100644 index 00000000000..a3f0cce547a --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +/** + * 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\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; + +/** + * Class PushProvider + * + * @package OCA\DAV\CalDAV\Reminder\NotificationProvider + */ +class PushProvider extends AbstractProvider { + + /** @var string */ + public const NOTIFICATION_TYPE = 'DISPLAY'; + + public function __construct( + IConfig $config, + private IManager $manager, + LoggerInterface $logger, + L10NFactory $l10nFactory, + IURLGenerator $urlGenerator, + private ITimeFactory $timeFactory, + ) { + parent::__construct($logger, $l10nFactory, $urlGenerator, $config); + } + + /** + * Send push notification to all users. + * + * @param VEvent $vevent + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses + * @param IUser[] $users + * @throws \Exception + */ + public function send(VEvent $vevent, + ?string $calendarDisplayName, + array $principalEmailAddresses, + array $users = []):void { + if ($this->config->getAppValue('dav', 'sendEventRemindersPush', 'yes') !== 'yes') { + return; + } + + $eventDetails = $this->extractEventDetails($vevent); + $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) + ->setUser($user->getUID()) + ->setDateTime($this->timeFactory->getDateTime()) + ->setObject(Application::APP_ID, $eventUUIDHash) + ->setSubject('calendar_reminder', [ + 'title' => $eventDetails['title'], + 'start_atom' => $eventDetails['start_atom'] + ]) + ->setMessage('calendar_reminder', $eventDetails); + + $this->manager->notify($notification); + } + } + + /** + * @throws \Exception + */ + protected function extractEventDetails(VEvent $vevent):array { + /** @var Property\ICalendar\DateTime $start */ + $start = $vevent->DTSTART; + $end = $this->getDTEndFromEvent($vevent); + + return [ + 'title' => isset($vevent->SUMMARY) + ? ((string)$vevent->SUMMARY) + : null, + 'description' => isset($vevent->DESCRIPTION) + ? ((string)$vevent->DESCRIPTION) + : null, + 'location' => isset($vevent->LOCATION) + ? ((string)$vevent->LOCATION) + : null, + 'all_day' => $start instanceof Property\ICalendar\Date, + 'start_atom' => $start->getDateTime()->format(\DateTimeInterface::ATOM), + 'start_is_floating' => $start->isFloating(), + 'start_timezone' => $start->getDateTime()->getTimezone()->getName(), + 'end_atom' => $end->getDateTime()->format(\DateTimeInterface::ATOM), + 'end_is_floating' => $end->isFloating(), + 'end_timezone' => $end->getDateTime()->getTimezone()->getName(), + ]; + } +} diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php b/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php new file mode 100644 index 00000000000..265db09b061 --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +/** + * 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 + * + * @package OCA\DAV\CalDAV\Reminder + */ +class NotificationProviderManager { + + /** @var INotificationProvider[] */ + private $providers = []; + + /** + * Checks whether a provider for a given ACTION exists + * + * @param string $type + * @return bool + */ + public function hasProvider(string $type):bool { + return (\in_array($type, ReminderService::REMINDER_TYPES, true) + && isset($this->providers[$type])); + } + + /** + * Get provider for a given ACTION + * + * @param string $type + * @return INotificationProvider + * @throws NotificationProvider\ProviderNotAvailableException + * @throws NotificationTypeDoesNotExistException + */ + public function getProvider(string $type):INotificationProvider { + if (in_array($type, ReminderService::REMINDER_TYPES, true)) { + if (isset($this->providers[$type])) { + return $this->providers[$type]; + } + throw new ProviderNotAvailableException($type); + } + throw new NotificationTypeDoesNotExistException($type); + } + + /** + * Registers a new provider + * + * @param string $providerClassName + * @throws QueryException + */ + public function registerProvider(string $providerClassName):void { + $provider = Server::get($providerClassName); + + if (!$provider instanceof INotificationProvider) { + throw new \InvalidArgumentException('Invalid notification provider registered'); + } + + $this->providers[$provider::NOTIFICATION_TYPE] = $provider; + } +} diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php b/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php new file mode 100644 index 00000000000..6fd2a29ede5 --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Reminder; + +class NotificationTypeDoesNotExistException extends \Exception { + + /** + * NotificationTypeDoesNotExistException constructor. + * + * @since 16.0.0 + * + * @param string $type ReminderType + */ + public function __construct(string $type) { + parent::__construct("Type $type is not an accepted type of notification"); + } +} diff --git a/apps/dav/lib/CalDAV/Reminder/Notifier.php b/apps/dav/lib/CalDAV/Reminder/Notifier.php new file mode 100644 index 00000000000..137fb509f56 --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/Notifier.php @@ -0,0 +1,311 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Reminder; + +use DateTime; +use OCA\DAV\AppInfo\Application; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\L10N\IFactory; +use OCP\Notification\AlreadyProcessedException; +use OCP\Notification\INotification; +use OCP\Notification\INotifier; +use OCP\Notification\UnknownNotificationException; + +/** + * Class Notifier + * + * @package OCA\DAV\CalDAV\Reminder + */ +class Notifier implements INotifier { + + /** @var IL10N */ + private $l10n; + + /** + * Notifier constructor. + * + * @param IFactory $l10nFactory + * @param IURLGenerator $urlGenerator + * @param ITimeFactory $timeFactory + */ + public function __construct( + private IFactory $l10nFactory, + private IURLGenerator $urlGenerator, + private ITimeFactory $timeFactory, + ) { + } + + /** + * Identifier of the notifier, only use [a-z0-9_] + * + * @return string + * @since 17.0.0 + */ + public function getID():string { + return Application::APP_ID; + } + + /** + * Human readable name describing the notifier + * + * @return string + * @since 17.0.0 + */ + public function getName():string { + return $this->l10nFactory->get('dav')->t('Calendar'); + } + + /** + * Prepare sending the notification + * + * @param INotification $notification + * @param string $languageCode The code of the language that should be used to prepare the notification + * @return INotification + * @throws UnknownNotificationException + */ + public function prepare(INotification $notification, + string $languageCode):INotification { + if ($notification->getApp() !== Application::APP_ID) { + throw new UnknownNotificationException('Notification not from this app'); + } + + // Read the language from the notification + $this->l10n = $this->l10nFactory->get('dav', $languageCode); + + // Handle notifier subjects + switch ($notification->getSubject()) { + case 'calendar_reminder': + return $this->prepareReminderNotification($notification); + + default: + throw new UnknownNotificationException('Unknown subject'); + + } + } + + /** + * @param INotification $notification + * @return INotification + */ + private function prepareReminderNotification(INotification $notification):INotification { + $imagePath = $this->urlGenerator->imagePath('core', 'places/calendar.svg'); + $iconUrl = $this->urlGenerator->getAbsoluteURL($imagePath); + $notification->setIcon($iconUrl); + + $this->prepareNotificationSubject($notification); + $this->prepareNotificationMessage($notification); + + return $notification; + } + + /** + * Sets the notification subject based on the parameters set in PushProvider + * + * @param INotification $notification + */ + private function prepareNotificationSubject(INotification $notification): void { + $parameters = $notification->getSubjectParameters(); + + $startTime = \DateTime::createFromFormat(\DateTimeInterface::ATOM, $parameters['start_atom']); + $now = $this->timeFactory->getDateTime(); + $title = $this->getTitleFromParameters($parameters); + + $diff = $startTime->diff($now); + if ($diff === false) { + return; + } + + $components = []; + if ($diff->y) { + $components[] = $this->l10n->n('%n year', '%n years', $diff->y); + } + if ($diff->m) { + $components[] = $this->l10n->n('%n month', '%n months', $diff->m); + } + if ($diff->d) { + $components[] = $this->l10n->n('%n day', '%n days', $diff->d); + } + if ($diff->h) { + $components[] = $this->l10n->n('%n hour', '%n hours', $diff->h); + } + if ($diff->i) { + $components[] = $this->l10n->n('%n minute', '%n minutes', $diff->i); + } + + 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]); + } + } + + $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 + */ + private function prepareNotificationMessage(INotification $notification): void { + $parameters = $notification->getMessageParameters(); + + $description = [ + $this->l10n->t('Calendar: %s', $parameters['calendar_displayname']), + $this->l10n->t('Date: %s', $this->generateDateString($parameters)), + ]; + if ($parameters['description']) { + $description[] = $this->l10n->t('Description: %s', $parameters['description']); + } + if ($parameters['location']) { + $description[] = $this->l10n->t('Where: %s', $parameters['location']); + } + + $message = implode("\r\n", $description); + $notification->setParsedMessage($message); + } + + /** + * @param array $parameters + * @return string + */ + private function getTitleFromParameters(array $parameters):string { + return $parameters['title'] ?? $this->l10n->t('Untitled event'); + } + + /** + * @param array $parameters + * @return string + * @throws \Exception + */ + private function generateDateString(array $parameters):string { + $startDateTime = DateTime::createFromFormat(\DateTimeInterface::ATOM, $parameters['start_atom']); + $endDateTime = DateTime::createFromFormat(\DateTimeInterface::ATOM, $parameters['end_atom']); + + // If the event has already ended, dismiss the notification + if ($endDateTime < $this->timeFactory->getDateTime()) { + throw new AlreadyProcessedException(); + } + + $isAllDay = $parameters['all_day']; + $diff = $startDateTime->diff($endDateTime); + + if ($isAllDay) { + // One day event + if ($diff->days === 1) { + return $this->getDateString($startDateTime); + } + + return implode(' - ', [ + $this->getDateString($startDateTime), + $this->getDateString($endDateTime), + ]); + } + + $startTimezone = $endTimezone = null; + if (!$parameters['start_is_floating']) { + $startTimezone = $parameters['start_timezone']; + $endTimezone = $parameters['end_timezone']; + } + + $localeStart = implode(', ', [ + $this->getWeekDayName($startDateTime), + $this->getDateTimeString($startDateTime) + ]); + + // always show full date with timezone if timezones are different + if ($startTimezone !== $endTimezone) { + $localeEnd = implode(', ', [ + $this->getWeekDayName($endDateTime), + $this->getDateTimeString($endDateTime) + ]); + + return $localeStart + . ' (' . $startTimezone . ') ' + . ' - ' + . $localeEnd + . ' (' . $endTimezone . ')'; + } + + // Show only the time if the day is the same + $localeEnd = $this->isDayEqual($startDateTime, $endDateTime) + ? $this->getTimeString($endDateTime) + : implode(', ', [ + $this->getWeekDayName($endDateTime), + $this->getDateTimeString($endDateTime) + ]); + + return $localeStart + . ' - ' + . $localeEnd + . ' (' . $startTimezone . ')'; + } + + /** + * @param DateTime $dtStart + * @param DateTime $dtEnd + * @return bool + */ + private function isDayEqual(DateTime $dtStart, + DateTime $dtEnd):bool { + return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); + } + + /** + * @param DateTime $dt + * @return string + */ + private function getWeekDayName(DateTime $dt):string { + return (string)$this->l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); + } + + /** + * @param DateTime $dt + * @return string + */ + private function getDateString(DateTime $dt):string { + return (string)$this->l10n->l('date', $dt, ['width' => 'medium']); + } + + /** + * @param DateTime $dt + * @return string + */ + private function getDateTimeString(DateTime $dt):string { + return (string)$this->l10n->l('datetime', $dt, ['width' => 'medium|short']); + } + + /** + * @param DateTime $dt + * @return string + */ + private function getTimeString(DateTime $dt):string { + 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 new file mode 100644 index 00000000000..c75090e1560 --- /dev/null +++ b/apps/dav/lib/CalDAV/Reminder/ReminderService.php @@ -0,0 +1,836 @@ +<?php + +declare(strict_types=1); + +/** + * 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 { + + public const REMINDER_TYPE_EMAIL = 'EMAIL'; + public const REMINDER_TYPE_DISPLAY = 'DISPLAY'; + public const REMINDER_TYPE_AUDIO = 'AUDIO'; + + /** + * @var String[] + * + * Official RFC5545 reminder types + */ + public const REMINDER_TYPES = [ + self::REMINDER_TYPE_EMAIL, + self::REMINDER_TYPE_DISPLAY, + self::REMINDER_TYPE_AUDIO + ]; + + 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, + ) { + } + + /** + * Process reminders to activate + * + * @throws NotificationProvider\ProviderNotAvailableException + * @throws NotificationTypeDoesNotExistException + */ + 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']) + ? stream_get_contents($reminder['calendardata']) + : $reminder['calendardata']; + + if (!$calendarData) { + continue; + } + + $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; + } + + 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', 'sendEventRemindersToSharedUsers', 'yes') === 'no') { + $users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']); + } else { + $users = []; + } + + $user = $this->getUserFromPrincipalURI($reminder['principaluri']); + if ($user) { + $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'], $userPrincipalEmailAddresses, $users); + + $this->deleteOrProcessNext($reminder, $vevent); + } + } + + /** + * @param array $objectData + * @throws VObject\InvalidDataException + */ + public function onCalendarObjectCreate(array $objectData):void { + // We only support VEvents for now + if (strcasecmp($objectData['component'], 'vevent') !== 0) { + return; + } + + $calendarData = is_resource($objectData['calendardata']) + ? stream_get_contents($objectData['calendardata']) + : $objectData['calendardata']; + + if (!$calendarData) { + return; + } + + $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; + $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); + $masterItem = $this->getMasterItemFromListOfVEvents($vevents); + $now = $this->timeFactory->getDateTime(); + $isRecurring = $masterItem ? $this->isRecurring($masterItem) : false; + + foreach ($recurrenceExceptions as $recurrenceException) { + $eventHash = $this->getEventHash($recurrenceException); + + if (!isset($recurrenceException->VALARM)) { + continue; + } + + foreach ($recurrenceException->VALARM as $valarm) { + /** @var VAlarm $valarm */ + $alarmHash = $this->getAlarmHash($valarm); + $triggerTime = $valarm->getEffectiveTriggerTime(); + $diff = $now->diff($triggerTime); + if ($diff->invert === 1) { + continue; + } + + $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone, + $eventHash, $alarmHash, true, true); + $this->writeRemindersToDatabase($alarms); + } + } + + if ($masterItem) { + $processedAlarms = []; + $masterAlarms = []; + $masterHash = $this->getEventHash($masterItem); + + if (!isset($masterItem->VALARM)) { + return; + } + + foreach ($masterItem->VALARM as $valarm) { + $masterAlarms[] = $this->getAlarmHash($valarm); + } + + try { + $iterator = new EventIterator($vevents, $uid); + } catch (NoInstancesException $e) { + // This event is recurring, but it doesn't have a single + // 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)) { + $event = $iterator->getEventObject(); + + // Recurrence-exceptions are handled separately, so just ignore them here + if (\in_array($event, $recurrenceExceptions, true)) { + $iterator->next(); + continue; + } + + foreach ($event->VALARM as $valarm) { + /** @var VAlarm $valarm */ + $alarmHash = $this->getAlarmHash($valarm); + if (\in_array($alarmHash, $processedAlarms, true)) { + continue; + } + + 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; + continue; + } + + 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; + } + + // If effective trigger time is in the past + // just skip and generate for next event + $diff = $now->diff($triggerTime); + if ($diff->invert === 1) { + // If an absolute alarm is in the past, + // just add it to processedAlarms, so + // we don't extend till eternity + if (!$this->isAlarmRelative($valarm)) { + $processedAlarms[] = $alarmHash; + } + + continue; + } + + $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone, $masterHash, $alarmHash, $isRecurring, false); + $this->writeRemindersToDatabase($alarms); + $processedAlarms[] = $alarmHash; + } + + $iterator->next(); + } + } + } + + /** + * @param array $objectData + * @throws VObject\InvalidDataException + */ + public function onCalendarObjectEdit(array $objectData):void { + // TODO - this can be vastly improved + // - get cached reminders + // - ... + + $this->onCalendarObjectDelete($objectData); + $this->onCalendarObjectCreate($objectData); + } + + /** + * @param array $objectData + * @throws VObject\InvalidDataException + */ + public function onCalendarObjectDelete(array $objectData):void { + // We only support VEvents for now + if (strcasecmp($objectData['component'], 'vevent') !== 0) { + return; + } + + $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 + * @param bool $isRecurrenceException + * @return array + */ + private function getRemindersForVAlarm(VAlarm $valarm, + 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); + } + if ($alarmHash === null) { + $alarmHash = $this->getAlarmHash($valarm); + } + + $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($valarm->parent); + $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()); + + $alarms = []; + + $alarms[] = [ + 'calendar_id' => $objectData['calendarid'], + 'object_id' => $objectData['id'], + '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, + 'is_relative' => $isRelative, + 'notification_date' => $notificationDate->getTimestamp(), + 'is_repeat_based' => false, + ]; + + $repeat = isset($valarm->REPEAT) ? (int)$valarm->REPEAT->getValue() : 0; + for ($i = 0; $i < $repeat; $i++) { + if ($valarm->DURATION === null) { + continue; + } + + $clonedNotificationDate->add($valarm->DURATION->getDateInterval()); + $alarms[] = [ + 'calendar_id' => $objectData['calendarid'], + 'object_id' => $objectData['id'], + '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, + 'is_relative' => $isRelative, + 'notification_date' => $clonedNotificationDate->getTimestamp(), + 'is_repeat_based' => true, + ]; + } + + return $alarms; + } + + /** + * @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'], + $reminder['uid'], + $reminder['is_recurring'], + (int)$reminder['recurrence_id'], + $reminder['is_recurrence_exception'], + $reminder['event_hash'], + $reminder['alarm_hash'], + $reminder['type'], + $reminder['is_relative'], + (int)$reminder['notification_date'], + $reminder['is_repeat_based'] + ); + } + } + + /** + * @param array $reminder + * @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']) { + $this->backend->removeReminder($reminder['id']); + return; + } + + $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']); + } catch (NoInstancesException $e) { + // This event is recurring, but it doesn't have a single + // instance. We are skipping this event from the output + // entirely. + return; + } + + try { + 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; + } + + foreach ($event->VALARM as $valarm) { + /** @var VAlarm $valarm */ + $alarmHash = $this->getAlarmHash($valarm); + if ($alarmHash !== $reminder['alarm_hash']) { + 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) { + 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(); + } + } 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']); + } + + /** + * @param int $calendarId + * @return IUser[] + */ + private function getAllUsersWithWriteAccessToCalendar(int $calendarId):array { + $shares = $this->caldavBackend->getShares($calendarId); + + $users = []; + $userIds = []; + $groups = []; + foreach ($shares as $share) { + // Only consider writable shares + if ($share['readOnly']) { + continue; + } + + $principal = explode('/', $share['{http://owncloud.org/ns}principal']); + if ($principal[1] === 'users') { + $user = $this->userManager->get($principal[2]); + if ($user) { + $users[] = $user; + $userIds[] = $principal[2]; + } + } elseif ($principal[1] === 'groups') { + $groups[] = $principal[2]; + } + } + + foreach ($groups as $gid) { + $group = $this->groupManager->get($gid); + if ($group instanceof IGroup) { + foreach ($group->getUsers() as $user) { + if (!\in_array($user->getUID(), $userIds, true)) { + $users[] = $user; + $userIds[] = $user->getUID(); + } + } + } + } + + return $users; + } + + /** + * Gets a hash of the event. + * If the hash changes, we have to update all relative alarms. + * + * @param VEvent $vevent + * @return string + */ + private function getEventHash(VEvent $vevent):string { + $properties = [ + (string)$vevent->DTSTART->serialize(), + ]; + + if ($vevent->DTEND) { + $properties[] = (string)$vevent->DTEND->serialize(); + } + if ($vevent->DURATION) { + $properties[] = (string)$vevent->DURATION->serialize(); + } + if ($vevent->{'RECURRENCE-ID'}) { + $properties[] = (string)$vevent->{'RECURRENCE-ID'}->serialize(); + } + if ($vevent->RRULE) { + $properties[] = (string)$vevent->RRULE->serialize(); + } + if ($vevent->EXDATE) { + $properties[] = (string)$vevent->EXDATE->serialize(); + } + if ($vevent->RDATE) { + $properties[] = (string)$vevent->RDATE->serialize(); + } + + return md5(implode('::', $properties)); + } + + /** + * Gets a hash of the alarm. + * If the hash changes, we have to update oc_dav_reminders. + * + * @param VAlarm $valarm + * @return string + */ + private function getAlarmHash(VAlarm $valarm):string { + $properties = [ + (string)$valarm->ACTION->serialize(), + (string)$valarm->TRIGGER->serialize(), + ]; + + if ($valarm->DURATION) { + $properties[] = (string)$valarm->DURATION->serialize(); + } + if ($valarm->REPEAT) { + $properties[] = (string)$valarm->REPEAT->serialize(); + } + + return md5(implode('::', $properties)); + } + + /** + * @param VObject\Component\VCalendar $vcalendar + * @param int $recurrenceId + * @param bool $isRecurrenceException + * @return VEvent|null + */ + private function getVEventByRecurrenceId(VObject\Component\VCalendar $vcalendar, + int $recurrenceId, + bool $isRecurrenceException):?VEvent { + $vevents = $this->getAllVEventsFromVCalendar($vcalendar); + if (count($vevents) === 0) { + return null; + } + + $uid = (string)$vevents[0]->UID; + $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); + $masterItem = $this->getMasterItemFromListOfVEvents($vevents); + + // Handle recurrence-exceptions first, because recurrence-expansion is expensive + if ($isRecurrenceException) { + foreach ($recurrenceExceptions as $recurrenceException) { + if ($this->getEffectiveRecurrenceIdOfVEvent($recurrenceException) === $recurrenceId) { + return $recurrenceException; + } + } + + return null; + } + + if ($masterItem) { + try { + $iterator = new EventIterator($vevents, $uid); + } catch (NoInstancesException $e) { + // This event is recurring, but it doesn't have a single + // instance. We are skipping this event from the output + // entirely. + return null; + } + + 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; + } + + if ($this->getEffectiveRecurrenceIdOfVEvent($event) === $recurrenceId) { + return $event; + } + + $iterator->next(); + } + } + + return null; + } + + /** + * @param VEvent $vevent + * @return string + */ + private function getStatusOfEvent(VEvent $vevent):string { + if ($vevent->STATUS) { + return (string)$vevent->STATUS; + } + + // Doesn't say so in the standard, + // but we consider events without a status + // to be confirmed + return 'CONFIRMED'; + } + + /** + * @param VObject\Component\VEvent $vevent + * @return bool + */ + private function wasEventCancelled(VObject\Component\VEvent $vevent):bool { + return $this->getStatusOfEvent($vevent) === 'CANCELLED'; + } + + /** + * @param string $calendarData + * @return VObject\Component\VCalendar|null + */ + private function parseCalendarData(string $calendarData):?VObject\Component\VCalendar { + try { + return VObject\Reader::read($calendarData, + VObject\Reader::OPTION_FORGIVING); + } catch (ParseException $ex) { + return null; + } + } + + /** + * @param string $principalUri + * @return IUser|null + */ + private function getUserFromPrincipalURI(string $principalUri):?IUser { + if (!$principalUri) { + return null; + } + + if (stripos($principalUri, 'principals/users/') !== 0) { + return null; + } + + $userId = substr($principalUri, 17); + return $this->userManager->get($userId); + } + + /** + * @param VObject\Component\VCalendar $vcalendar + * @return VObject\Component\VEvent[] + */ + private function getAllVEventsFromVCalendar(VObject\Component\VCalendar $vcalendar):array { + $vevents = []; + + foreach ($vcalendar->children() as $child) { + if (!($child instanceof VObject\Component)) { + continue; + } + + if ($child->name !== 'VEVENT') { + continue; + } + // Ignore invalid events with no DTSTART + if ($child->DTSTART === null) { + continue; + } + + $vevents[] = $child; + } + + return $vevents; + } + + /** + * @param array $vevents + * @return VObject\Component\VEvent[] + */ + private function getRecurrenceExceptionFromListOfVEvents(array $vevents):array { + return array_values(array_filter($vevents, function (VEvent $vevent) { + return $vevent->{'RECURRENCE-ID'} !== null; + })); + } + + /** + * @param array $vevents + * @return VEvent|null + */ + private function getMasterItemFromListOfVEvents(array $vevents):?VEvent { + $elements = array_values(array_filter($vevents, function (VEvent $vevent) { + return $vevent->{'RECURRENCE-ID'} === null; + })); + + if (count($elements) === 0) { + return null; + } + if (count($elements) > 1) { + throw new \TypeError('Multiple master objects'); + } + + return $elements[0]; + } + + /** + * @param VAlarm $valarm + * @return bool + */ + private function isAlarmRelative(VAlarm $valarm):bool { + $trigger = $valarm->TRIGGER; + return $trigger instanceof VObject\Property\ICalendar\Duration; + } + + /** + * @param VEvent $vevent + * @return int + */ + private function getEffectiveRecurrenceIdOfVEvent(VEvent $vevent):int { + if (isset($vevent->{'RECURRENCE-ID'})) { + return $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp(); + } + + return $vevent->DTSTART->getDateTime()->getTimestamp(); + } + + /** + * @param VEvent $vevent + * @return bool + */ + 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 new file mode 100644 index 00000000000..68bb3373346 --- /dev/null +++ b/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php @@ -0,0 +1,494 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019 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 OCA\DAV\Traits\PrincipalProxyTrait; +use OCP\Calendar\Resource\IResourceMetadata; +use OCP\Calendar\Room\IRoomMetadata; +use OCP\DB\Exception; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Sabre\DAV\PropPatch; +use Sabre\DAVACL\PrincipalBackend\BackendInterface; +use function array_intersect; +use function array_map; +use function array_merge; +use function array_unique; +use function array_values; + +abstract class AbstractPrincipalBackend implements BackendInterface { + + /** @var string */ + private $dbTableName; + + /** @var string */ + private $dbMetaDataTableName; + + /** @var string */ + private $dbForeignKeyName; + + 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'; + } + + use PrincipalProxyTrait; + + /** + * Returns a list of principals based on a prefix. + * + * This prefix will often contain something like 'principals'. You are only + * expected to return principals that are in this base path. + * + * You are expected to return at least a 'uri' for every user, you can + * return any additional properties if you wish so. Common properties are: + * {DAV:}displayname + * + * @param string $prefixPath + * @return string[] + */ + public function getPrincipalsByPrefix($prefixPath): array { + $principals = []; + + if ($prefixPath === $this->principalPrefix) { + $query = $this->db->getQueryBuilder(); + $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname']) + ->from($this->dbTableName); + $stmt = $query->execute(); + + $metaDataQuery = $this->db->getQueryBuilder(); + $metaDataQuery->select([$this->dbForeignKeyName, 'key', 'value']) + ->from($this->dbMetaDataTableName); + $metaDataStmt = $metaDataQuery->execute(); + $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC); + + $metaDataById = []; + foreach ($metaDataRows as $metaDataRow) { + if (!isset($metaDataById[$metaDataRow[$this->dbForeignKeyName]])) { + $metaDataById[$metaDataRow[$this->dbForeignKeyName]] = []; + } + + $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] + = $metaDataRow['value']; + } + + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $id = $row['id']; + + if (isset($metaDataById[$id])) { + $principals[] = $this->rowToPrincipal($row, $metaDataById[$id]); + } else { + $principals[] = $this->rowToPrincipal($row); + } + } + + $stmt->closeCursor(); + } + + return $principals; + } + + /** + * Returns a specific principal, specified by its path. + * The returned structure should be the exact same as from + * getPrincipalsByPrefix. + * + * @param string $prefixPath + * + * @return array + */ + public function getPrincipalByPath($path) { + if (!str_starts_with($path, $this->principalPrefix)) { + return null; + } + [, $name] = \Sabre\Uri\split($path); + + [$backendId, $resourceId] = explode('-', $name, 2); + + $query = $this->db->getQueryBuilder(); + $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname']) + ->from($this->dbTableName) + ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId))) + ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId))); + $stmt = $query->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + + $metaDataQuery = $this->db->getQueryBuilder(); + $metaDataQuery->select(['key', 'value']) + ->from($this->dbMetaDataTableName) + ->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id']))); + $metaDataStmt = $metaDataQuery->execute(); + $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC); + $metadata = []; + + foreach ($metaDataRows as $metaDataRow) { + $metadata[$metaDataRow['key']] = $metaDataRow['value']; + } + + return $this->rowToPrincipal($row, $metadata); + } + + /** + * @param int $id + * @return string[]|null + */ + public function getPrincipalById($id): ?array { + $query = $this->db->getQueryBuilder(); + $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname']) + ->from($this->dbTableName) + ->where($query->expr()->eq('id', $query->createNamedParameter($id))); + $stmt = $query->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + + $metaDataQuery = $this->db->getQueryBuilder(); + $metaDataQuery->select(['key', 'value']) + ->from($this->dbMetaDataTableName) + ->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id']))); + $metaDataStmt = $metaDataQuery->execute(); + $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC); + $metadata = []; + + foreach ($metaDataRows as $metaDataRow) { + $metadata[$metaDataRow['key']] = $metaDataRow['value']; + } + + return $this->rowToPrincipal($row, $metadata); + } + + /** + * @param string $path + * @param PropPatch $propPatch + * @return int + */ + public function updatePrincipal($path, PropPatch $propPatch): int { + return 0; + } + + /** + * @param string $prefixPath + * @param string $test + * + * @return array + */ + public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') { + $results = []; + if (\count($searchProperties) === 0) { + return []; + } + if ($prefixPath !== $this->principalPrefix) { + return []; + } + + $user = $this->userSession->getUser(); + if (!$user) { + return []; + } + $usersGroups = $this->groupManager->getUserGroupIds($user); + + foreach ($searchProperties as $prop => $value) { + switch ($prop) { + case '{http://sabredav.org/ns}email-address': + $query = $this->db->getQueryBuilder(); + $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions']) + ->from($this->dbTableName) + ->where($query->expr()->iLike('email', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%'))); + + $stmt = $query->execute(); + $principals = []; + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + if (!$this->isAllowedToAccessResource($row, $usersGroups)) { + continue; + } + $principals[] = $this->rowToPrincipal($row)['uri']; + } + $results[] = $principals; + + $stmt->closeCursor(); + break; + + case '{DAV:}displayname': + $query = $this->db->getQueryBuilder(); + $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions']) + ->from($this->dbTableName) + ->where($query->expr()->iLike('displayname', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%'))); + + $stmt = $query->execute(); + $principals = []; + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + if (!$this->isAllowedToAccessResource($row, $usersGroups)) { + continue; + } + $principals[] = $this->rowToPrincipal($row)['uri']; + } + $results[] = $principals; + + $stmt->closeCursor(); + break; + + case '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set': + // If you add support for more search properties that qualify as a user-address, + // please also add them to the array below + $results[] = $this->searchPrincipals($this->principalPrefix, [ + '{http://sabredav.org/ns}email-address' => $value, + ], 'anyof'); + break; + + case IRoomMetadata::FEATURES: + $results[] = $this->searchPrincipalsByRoomFeature($prop, $value); + break; + + case IRoomMetadata::CAPACITY: + case IResourceMetadata::VEHICLE_SEATING_CAPACITY: + $results[] = $this->searchPrincipalsByCapacity($prop, $value); + break; + + default: + $results[] = $this->searchPrincipalsByMetadataKey($prop, $value, $usersGroups); + break; + } + } + + // results is an array of arrays, so this is not the first search result + // but the results of the first searchProperty + if (count($results) === 1) { + return $results[0]; + } + + switch ($test) { + case 'anyof': + return array_values(array_unique(array_merge(...$results))); + + case 'allof': + default: + return array_values(array_intersect(...$results)); + } + } + + /** + * @param string $key + * @return IQueryBuilder + */ + private function getMetadataQuery(string $key): IQueryBuilder { + $query = $this->db->getQueryBuilder(); + $query->select([$this->dbForeignKeyName]) + ->from($this->dbMetaDataTableName) + ->where($query->expr()->eq('key', $query->createNamedParameter($key))); + return $query; + } + + /** + * Searches principals based on their metadata keys. + * This allows to search for all principals with a specific key. + * e.g.: + * '{http://nextcloud.com/ns}room-building-address' => 'ABC Street 123, ...' + * + * @param string $key + * @param string $value + * @param string[] $usersGroups + * @return string[] + */ + private function searchPrincipalsByMetadataKey(string $key, string $value, array $usersGroups = []): array { + $query = $this->getMetadataQuery($key); + $query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%'))); + return $this->getRows($query, $usersGroups); + } + + /** + * Searches principals based on room features + * e.g.: + * '{http://nextcloud.com/ns}room-features' => 'TV,PROJECTOR' + * + * @param string $key + * @param string $value + * @param string[] $usersGroups + * @return string[] + */ + private function searchPrincipalsByRoomFeature(string $key, string $value, array $usersGroups = []): array { + $query = $this->getMetadataQuery($key); + foreach (explode(',', $value) as $v) { + $query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($v) . '%'))); + } + return $this->getRows($query, $usersGroups); + } + + /** + * Searches principals based on room seating capacity or vehicle capacity + * e.g.: + * '{http://nextcloud.com/ns}room-seating-capacity' => '100' + * + * @param string $key + * @param string $value + * @param string[] $usersGroups + * @return string[] + */ + private function searchPrincipalsByCapacity(string $key, string $value, array $usersGroups = []): array { + $query = $this->getMetadataQuery($key); + $query->andWhere($query->expr()->gte('value', $query->createNamedParameter($value))); + return $this->getRows($query, $usersGroups); + } + + /** + * @param IQueryBuilder $query + * @param string[] $usersGroups + * @return string[] + */ + private function getRows(IQueryBuilder $query, array $usersGroups): array { + try { + $stmt = $query->executeQuery(); + } catch (Exception $e) { + $this->logger->error('Could not search resources: ' . $e->getMessage(), ['exception' => $e]); + } + + $rows = []; + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $principalRow = $this->getPrincipalById($row[$this->dbForeignKeyName]); + if (!$principalRow) { + continue; + } + + $rows[] = $principalRow; + } + + $stmt->closeCursor(); + + $filteredRows = array_filter($rows, function ($row) use ($usersGroups) { + return $this->isAllowedToAccessResource($row, $usersGroups); + }); + + return array_map(static function ($row): string { + return $row['uri']; + }, $filteredRows); + } + + /** + * @param string $uri + * @param string $principalPrefix + * @return null|string + * @throws Exception + */ + public function findByUri($uri, $principalPrefix): ?string { + $user = $this->userSession->getUser(); + if (!$user) { + return null; + } + $usersGroups = $this->groupManager->getUserGroupIds($user); + + 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']) + ->from($this->dbTableName) + ->where($query->expr()->eq('email', $query->createNamedParameter($email))); + + $stmt = $query->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + if (!$this->isAllowedToAccessResource($row, $usersGroups)) { + return null; + } + + return $this->rowToPrincipal($row)['uri']; + } + + if (str_starts_with($uri, 'principal:')) { + $path = substr($uri, 10); + if (!str_starts_with($path, $this->principalPrefix)) { + return null; + } + + [, $name] = \Sabre\Uri\split($path); + [$backendId, $resourceId] = explode('-', $name, 2); + + $query = $this->db->getQueryBuilder(); + $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions']) + ->from($this->dbTableName) + ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId))) + ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId))); + $stmt = $query->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + if (!$this->isAllowedToAccessResource($row, $usersGroups)) { + return null; + } + + return $this->rowToPrincipal($row)['uri']; + } + + return null; + } + + /** + * convert database row to principal + * + * @param string[] $row + * @param string[] $metadata + * @return string[] + */ + private function rowToPrincipal(array $row, array $metadata = []): array { + return array_merge([ + 'uri' => $this->principalPrefix . '/' . $row['backend_id'] . '-' . $row['resource_id'], + '{DAV:}displayname' => $row['displayname'], + '{http://sabredav.org/ns}email-address' => $row['email'], + '{urn:ietf:params:xml:ns:caldav}calendar-user-type' => $this->cuType, + ], $metadata); + } + + /** + * @param array $row + * @param array $userGroups + * @return bool + */ + private function isAllowedToAccessResource(array $row, array $userGroups): bool { + 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'], 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; + } + + // empty array => no group restrictions + if (empty($json)) { + return true; + } + + return !empty(array_intersect($json, $userGroups)); + } +} diff --git a/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php new file mode 100644 index 00000000000..c70d93daf52 --- /dev/null +++ b/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php @@ -0,0 +1,33 @@ +<?php + +/** + * 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\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * Class ResourcePrincipalBackend + * + * @package OCA\DAV\CalDAV\ResourceBooking + */ +class ResourcePrincipalBackend extends AbstractPrincipalBackend { + + /** + * ResourcePrincipalBackend constructor. + */ + public function __construct(IDBConnection $dbConnection, + 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 new file mode 100644 index 00000000000..5704b23ae14 --- /dev/null +++ b/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php @@ -0,0 +1,33 @@ +<?php + +/** + * 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\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * Class RoomPrincipalBackend + * + * @package OCA\DAV\CalDAV\ResourceBooking + */ +class RoomPrincipalBackend extends AbstractPrincipalBackend { + + /** + * RoomPrincipalBackend constructor. + */ + public function __construct(IDBConnection $dbConnection, + 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 new file mode 100644 index 00000000000..399d1a46639 --- /dev/null +++ b/apps/dav/lib/CalDAV/RetentionService.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV; + +use OCA\DAV\AppInfo\Application; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use function max; + +class RetentionService { + public const RETENTION_CONFIG_KEY = 'calendarRetentionObligation'; + private const DEFAULT_RETENTION_SECONDS = 30 * 24 * 60 * 60; + + public function __construct( + private IConfig $config, + private ITimeFactory $time, + private CalDavBackend $calDavBackend, + ) { + } + + public function getDuration(): int { + return max( + (int)$this->config->getAppValue( + Application::APP_ID, + self::RETENTION_CONFIG_KEY, + (string)self::DEFAULT_RETENTION_SECONDS + ), + 0 // Just making sure we don't delete things in the future when a negative number is passed + ); + } + + public function cleanUp(): void { + $retentionTime = $this->getDuration(); + $now = $this->time->getTime(); + + $calendars = $this->calDavBackend->getDeletedCalendars($now - $retentionTime); + foreach ($calendars as $calendar) { + $this->calDavBackend->deleteCalendar($calendar['id'], true); + } + + $objects = $this->calDavBackend->getDeletedCalendarObjects($now - $retentionTime); + foreach ($objects as $object) { + $this->calDavBackend->deleteCalendarObject( + $object['calendarid'], + $object['uri'], + $object['calendartype'], + true + ); + } + } +} diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 781baa2032d..2af6b162d8d 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -1,48 +1,35 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2017, Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Leon Klingele <leon@struktur.de> - * @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\IL10N; -use OCP\ILogger; -use OCP\IURLGenerator; -use OCP\L10N\IFactory as L10NFactory; -use OCP\Mail\IEMailTemplate; +use OCP\IAppConfig; +use OCP\IUserSession; use OCP\Mail\IMailer; +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\Xml\Element\Prop; +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. * @@ -59,56 +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 Defaults */ - private $defaults; - - const MAX_DATE = '2038-01-01'; + 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; + + 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(''); + } - const METHOD_REQUEST = 'request'; - const METHOD_REPLY = 'reply'; - const METHOD_CANCEL = 'cancel'; + 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 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, $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->defaults = $defaults; + 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); } /** @@ -119,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'; @@ -128,345 +105,234 @@ 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 - if ($this->isEventInThePast($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 (!$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; + } + $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; + } - $senderName = $iTipMessage->senderName ?: null; - $recipientName = $iTipMessage->recipientName ?: null; - - /** @var VEvent $vevent */ - $vevent = $iTipMessage->message->VEVENT; - - $attendee = $this->getCurrentAttendee($iTipMessage); - $defaultLang = $this->config->getUserValue($this->userId, 'core', 'lang', $this->l10nFactory->findLanguage()); - $lang = $this->getAttendeeLangOrDefault($defaultLang, $attendee); - $l10n = $this->l10nFactory->get('dav', $lang); - - $meetingAttendeeName = $recipientName ?: $recipient; - $meetingInviteeName = $senderName ?: $sender; - - $meetingTitle = $vevent->SUMMARY; - $meetingDescription = $vevent->DESCRIPTION; - - $start = $vevent->DTSTART; - if (isset($vevent->DTEND)) { - $end = $vevent->DTEND; - } elseif (isset($vevent->DURATION)) { - $isFloating = $vevent->DTSTART->isFloating(); - $end = clone $vevent->DTSTART; - $endDateTime = $end->getDateTime(); - $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); - $end->setDateTime($endDateTime, $isFloating); - } elseif (!$vevent->DTSTART->hasTime()) { - $isFloating = $vevent->DTSTART->isFloating(); - $end = clone $vevent->DTSTART; - $endDateTime = $end->getDateTime(); - $endDateTime = $endDateTime->modify('+1 day'); - $end->setDateTime($endDateTime, $isFloating); + // 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 { - $end = clone $vevent->DTSTART; + $senderName = ''; } - $meetingWhen = $this->generateWhenString($l10n, $start, $end); - - $meetingUrl = $vevent->URL; - $meetingLocation = $vevent->LOCATION; - - $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 = array( - '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, - ); - - $fromEMail = \OCP\Util::getDefaultEmailAddress('invitations-noreply'); - $fromName = $l10n->t('%s via %s', [$senderName, $this->defaults->getName()]); + $data['attendee_name'] = ($recipientName ?: $recipient); + $data['invitee_name'] = ($senderName ?: $sender); - $message = $this->mailer->createMessage() - ->setFrom([$fromEMail => $fromName]) - ->setReplyTo([$sender => $senderName]) - ->setTo([$recipient => $recipientName]); + $fromEMail = Util::getDefaultEmailAddress('invitations-noreply'); + $fromName = $this->imipService->getFrom($senderName, $this->defaults->getName()); $template = $this->mailer->createEMailTemplate('dav.calendarInvite.' . $method, $data); $template->addHeader(); - $this->addSubjectAndHeading($template, $l10n, $method, $summary, - $meetingAttendeeName, $meetingInviteeName); - $this->addBulletList($template, $l10n, $meetingWhen, $meetingLocation, - $meetingDescription, $meetingUrl); + $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 (strcasecmp($method, self::METHOD_REQUEST) === 0 && $this->imipService->getAttendeeRsvpOrReqForParticipant($attendee)) { + + /* + ** Only offer invitation accept/reject buttons, which link back to the + ** nextcloud server, to recipients who can access the nextcloud server via + ** their internet/intranet. Issue #12156 + ** + ** The app setting is stored in the appconfig database table. + ** + ** For nextcloud servers accessible to the public internet, the default + ** "invitation_link_recipients" value "yes" (all recipients) is appropriate. + ** + ** When the nextcloud server is restricted behind a firewall, accessible + ** only via an internal network or via vpn, you can set "dav.invitation_link_recipients" + ** to the email address or email domain, or comma separated list of addresses or domains, + ** of recipients who can access the server. + ** + ** To always deliver URLs, set invitation_link_recipients to "yes". + ** To suppress URLs entirely, set invitation_link_recipients to boolean "no". + */ + + $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)) { + $token = $this->imipService->createInvitationToken($iTipMessage, $vEvent, $lastOccurrence); + $this->imipService->addResponseButtons($template, $token); + $this->imipService->addMoreOptionsButton($template, $token); + } + } $template->addFooter(); - $message->useTemplate($template); + // convert iTip Message to string + $itip_msg = $iTipMessage->message->serialize(); - $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 bool - */ - private function isEventInThePast(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(); } - } - - $currentTime = $this->timeFactory->getTime(); - return $lastOccurrence < $currentTime; - } - - - /** - * @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 IL10N $l10n - * @param Property $dtstart - * @param Property $dtend - */ - private function generateWhenString(IL10N $l10n, Property $dtstart, Property $dtend) { - $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(\DateTime::ATOM)); - $dtendDt = new \DateTime($dtendDt->format(\DateTime::ATOM)); - - if ($isAllDay) { - // One day event - if ($diff->days === 1) { - return $l10n->l('date', $dtstartDt, ['width' => 'medium']); - } - - //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(); + // 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); } - $prop = $dtend->offsetGet('TZID'); - if ($prop instanceof Parameter) { - $endTimezone = $prop->getValue(); + $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'; } + } catch (\Exception $ex) { + $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]); + $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; } - - $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 + * @return ?VCalendar */ - private function isDayEqual(\DateTime $dtStart, \DateTime $dtEnd) { - return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); + public function getVCalendar(): ?VCalendar { + return $this->vCalendar; } /** - * @param IEMailTemplate $template - * @param IL10N $l10n - * @param string $method - * @param string $summary - * @param string $attendeeName - * @param string $inviteeName + * @param ?VCalendar $vCalendar */ - private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, - $method, $summary, $attendeeName, $inviteeName) { - if ($method === self::METHOD_CANCEL) { - $template->setSubject('Cancelled: ' . $summary); - $template->addHeading($l10n->t('Invitation canceled'), $l10n->t('Hello %s,', [$attendeeName])); - $template->addBodyText($l10n->t('The meeting »%s« with %s was canceled.', [$summary, $inviteeName])); - } else if ($method === self::METHOD_REPLY) { - $template->setSubject('Re: ' . $summary); - $template->addHeading($l10n->t('Invitation updated'), $l10n->t('Hello %s,', [$attendeeName])); - $template->addBodyText($l10n->t('The meeting »%s« with %s was updated.', [$summary, $inviteeName])); - } else { - $template->setSubject('Invitation: ' . $summary); - $template->addHeading($l10n->t('%s invited you to »%s«', [$inviteeName, $summary]), $l10n->t('Hello %s,', [$attendeeName])); - } - - } - - /** - * @param IEMailTemplate $template - * @param IL10N $l10n - * @param string $time - * @param string $location - * @param string $description - * @param string $url - */ - private function addBulletList(IEMailTemplate $template, IL10N $l10n, $time, $location, $description, $url) { - $template->addBodyListItem($time, $l10n->t('When:'), - $this->getAbsoluteImagePath('filetypes/text-calendar.svg')); - - if ($location) { - $template->addBodyListItem($location, $l10n->t('Where:'), - $this->getAbsoluteImagePath('filetypes/location.svg')); - } - if ($description) { - $template->addBodyListItem((string)$description, $l10n->t('Description:'), - $this->getAbsoluteImagePath('filetypes/text.svg')); - } - if ($url) { - $template->addBodyListItem((string)$url, $l10n->t('Link:'), - $this->getAbsoluteImagePath('filetypes/link.svg')); - } + public function setVCalendar(?VCalendar $vCalendar): void { + $this->vCalendar = $vCalendar; } - /** - * @param string $path - * @return string - */ - private function getAbsoluteImagePath($path) { - return $this->urlGenerator->getAbsoluteURL( - $this->urlGenerator->imagePath('core', $path) - ); - } } 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 34df666637c..a001df8b2a8 100644 --- a/apps/dav/lib/CalDAV/Schedule/Plugin.php +++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php @@ -1,48 +1,129 @@ <?php + /** - * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl> - * @copyright Copyright (c) 2016, Joas Schilling <coding@schilljs.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: 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; +use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; +use Sabre\VObject\DateTimeParser; +use Sabre\VObject\FreeBusyGenerator; +use Sabre\VObject\ITip; +use Sabre\VObject\ITip\SameOrganizerForAllComponentsException; +use Sabre\VObject\Parameter; +use Sabre\VObject\Property; +use Sabre\VObject\Reader; +use function Sabre\Uri\split; class Plugin extends \Sabre\CalDAV\Schedule\Plugin { + /** @var ITip\Message[] */ + private $schedulingResponses = []; + + /** @var string|null */ + private $pathOfCalendarObjectChange = null; + + public const CALENDAR_USER_TYPE = '{' . self::NS_CALDAV . '}calendar-user-type'; + public const SCHEDULE_DEFAULT_CALENDAR_URL = '{' . Plugin::NS_CALDAV . '}schedule-default-calendar-URL'; + + /** + * @param IConfig $config + */ + public function __construct( + private IConfig $config, + private LoggerInterface $logger, + private DefaultCalendarValidator $defaultCalendarValidator, + ) { + } + /** * Initializes the plugin * * @param Server $server * @return void */ - function initialize(Server $server) { + public function initialize(Server $server) { parent::initialize($server); $server->on('propFind', [$this, 'propFindDefaultCalendarUrl'], 90); + $server->on('afterWriteContent', [$this, 'dispatchSchedulingResponses']); + $server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']); + + // We allow mutating the default calendar URL through the CustomPropertiesBackend + // (oc_properties table) + $server->protectedProperties = array_filter( + $server->protectedProperties, + static fn (string $property) => $property !== self::SCHEDULE_DEFAULT_CALENDAR_URL, + ); + } + + /** + * Returns an instance of the iTip\Broker. + */ + protected function createITipBroker(): TipBroker { + return new TipBroker(); + } + + /** + * Allow manual setting of the object change URL + * to support public write + * + * @param string $path + */ + public function setPathOfCalendarObjectChange(string $path): void { + $this->pathOfCalendarObjectChange = $path; + } + + /** + * This method handler is invoked during fetching of properties. + * + * We use this event to add calendar-auto-schedule-specific properties. + * + * @param PropFind $propFind + * @param INode $node + * @return void + */ + public function propFind(PropFind $propFind, INode $node) { + if ($node instanceof IPrincipal) { + // overwrite Sabre/Dav's implementation + $propFind->handle(self::CALENDAR_USER_TYPE, function () use ($node) { + if ($node instanceof IProperties) { + $props = $node->getProperties([self::CALENDAR_USER_TYPE]); + + if (isset($props[self::CALENDAR_USER_TYPE])) { + return $props[self::CALENDAR_USER_TYPE]; + } + } + + return 'INDIVIDUAL'; + }); + } + + parent::propFind($propFind, $node); } /** @@ -58,38 +139,319 @@ 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; } /** + * @param RequestInterface $request + * @param ResponseInterface $response + * @param VCalendar $vCal + * @param mixed $calendarPath + * @param mixed $modified + * @param mixed $isNew + */ + public function calendarObjectChange(RequestInterface $request, ResponseInterface $response, VCalendar $vCal, $calendarPath, &$modified, $isNew) { + // Save the first path we get as a calendar-object-change request + if (!$this->pathOfCalendarObjectChange) { + $this->pathOfCalendarObjectChange = $request->getPath(); + } + + try { + + // 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 { + /** @var VEvent|null $vevent */ + $vevent = $iTipMessage->message->VEVENT ?? null; + + // Strip VALARMs from incoming VEVENT + if ($vevent && isset($vevent->VALARM)) { + $vevent->remove('VALARM'); + } + + parent::scheduleLocalDelivery($iTipMessage); + // We only care when the message was successfully delivered locally + // Log all possible codes returned from the parent method that mean something went wrong + // 3.7, 3.8, 5.0, 5.2 + if ($iTipMessage->scheduleStatus !== '1.2;Message delivered locally') { + $this->logger->debug('Message not delivered locally with status: ' . $iTipMessage->scheduleStatus); + return; + } + // We only care about request. reply and cancel are properly handled + // by parent::scheduleLocalDelivery already + if (strcasecmp($iTipMessage->method, 'REQUEST') !== 0) { + return; + } + + // If parent::scheduleLocalDelivery set scheduleStatus to 1.2, + // it means that it was successfully delivered locally. + // Meaning that the ACL plugin is loaded and that a principal + // exists for the given recipient id, no need to double check + /** @var \Sabre\DAVACL\Plugin $aclPlugin */ + $aclPlugin = $this->server->getPlugin('acl'); + $principalUri = $aclPlugin->getPrincipalByUri($iTipMessage->recipient); + $calendarUserType = $this->getCalendarUserTypeForPrincipal($principalUri); + if (strcasecmp($calendarUserType, 'ROOM') !== 0 && strcasecmp($calendarUserType, 'RESOURCE') !== 0) { + $this->logger->debug('Calendar user type is 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 (!$vevent) { + $this->logger->debug('No VEVENT set to process on scheduling message'); + return; + } + + // We don't support autoresponses for recurrencing events for now + if (isset($vevent->RRULE) || isset($vevent->RDATE)) { + $this->logger->debug('VEVENT is a recurring event, autoresponding not supported'); + return; + } + + $dtstart = $vevent->DTSTART; + $dtend = $this->getDTEndFromVEvent($vevent); + $uid = $vevent->UID->getValue(); + $sequence = isset($vevent->SEQUENCE) ? $vevent->SEQUENCE->getValue() : 0; + $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->serialize() : ''; + + $message = <<<EOF +BEGIN:VCALENDAR +PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN +METHOD:REPLY +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;PARTSTAT=%s:%s +ORGANIZER:%s +UID:%s +SEQUENCE:%s +REQUEST-STATUS:2.0;Success +%sEND:VEVENT +END:VCALENDAR +EOF; + + if ($this->isAvailableAtTime($attendee->getValue(), $dtstart->getDateTime(), $dtend->getDateTime(), $uid)) { + $partStat = 'ACCEPTED'; + } else { + $partStat = 'DECLINED'; + } + + $vObject = Reader::read(vsprintf($message, [ + $partStat, + $iTipMessage->recipient, + $iTipMessage->sender, + $uid, + $sequence, + $recurrenceId + ])); + + $responseITipMessage = new ITip\Message(); + $responseITipMessage->uid = $uid; + $responseITipMessage->component = 'VEVENT'; + $responseITipMessage->method = 'REPLY'; + $responseITipMessage->sequence = $sequence; + $responseITipMessage->sender = $iTipMessage->recipient; + $responseITipMessage->recipient = $iTipMessage->sender; + $responseITipMessage->message = $vObject; + + // We can't dispatch them now already, because the organizers calendar-object + // was not yet created. Hence Sabre/DAV won't find a calendar-object, when we + // send our reply. + $this->schedulingResponses[] = $responseITipMessage; + } + + /** + * @param string $uri + */ + public function dispatchSchedulingResponses(string $uri):void { + if ($uri !== $this->pathOfCalendarObjectChange) { + return; + } + + foreach ($this->schedulingResponses as $schedulingResponse) { + $this->scheduleLocalDelivery($schedulingResponse); + } + } + + /** * Always use the personal calendar as target for scheduled events * * @param PropFind $propFind * @param INode $node * @return void */ - function propFindDefaultCalendarUrl(PropFind $propFind, INode $node) { + public function propFindDefaultCalendarUrl(PropFind $propFind, INode $node) { if ($node instanceof IPrincipal) { - $propFind->handle('{' . self::NS_CALDAV . '}schedule-default-calendar-URL', function() use ($node) { + $propFind->handle(self::SCHEDULE_DEFAULT_CALENDAR_URL, function () use ($node) { /** @var \OCA\DAV\CalDAV\Plugin $caldavPlugin */ $caldavPlugin = $this->server->getPlugin('caldav'); $principalUrl = $node->getPrincipalUrl(); $calendarHomePath = $caldavPlugin->getCalendarHomeForPrincipal($principalUrl); - if (!$calendarHomePath) { return null; } + $isResourceOrRoom = str_starts_with($principalUrl, 'principals/calendar-resources') + || str_starts_with($principalUrl, 'principals/calendar-rooms'); + + if (str_starts_with($principalUrl, 'principals/users')) { + [, $userId] = split($principalUrl); + $uri = $this->config->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI); + $displayName = CalDavBackend::PERSONAL_CALENDAR_NAME; + } elseif ($isResourceOrRoom) { + $uri = CalDavBackend::RESOURCE_BOOKING_CALENDAR_URI; + $displayName = CalDavBackend::RESOURCE_BOOKING_CALENDAR_NAME; + } else { + // How did we end up here? + // TODO - throw exception or just ignore? + return null; + } + /** @var CalendarHome $calendarHome */ $calendarHome = $this->server->tree->getNodeForPath($calendarHomePath); - if (!$calendarHome->childExists(CalDavBackend::PERSONAL_CALENDAR_URI)) { - $calendarHome->getCalDAVBackend()->createCalendar($principalUrl, CalDavBackend::PERSONAL_CALENDAR_URI, [ - '{DAV:}displayname' => CalDavBackend::PERSONAL_CALENDAR_NAME, - ]); + $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 . '/' . CalDavBackend::PERSONAL_CALENDAR_URI, [], 1); + $result = $this->server->getPropertiesForPath($calendarHomePath . '/' . $uri, [], 1); if (empty($result)) { return null; } @@ -98,4 +460,299 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { }); } } + + /** + * Returns a list of addresses that are associated with a principal. + * + * @param string $principal + * @return string|null + */ + protected function getCalendarUserTypeForPrincipal($principal):?string { + $calendarUserType = '{' . self::NS_CALDAV . '}calendar-user-type'; + $properties = $this->server->getProperties( + $principal, + [$calendarUserType] + ); + + // If we can't find this information, we'll stop processing + if (!isset($properties[$calendarUserType])) { + return null; + } + + return $properties[$calendarUserType]; + } + + /** + * @param ITip\Message $iTipMessage + * @return null|Property + */ + private function getCurrentAttendee(ITip\Message $iTipMessage):?Property { + /** @var VEvent $vevent */ + $vevent = $iTipMessage->message->VEVENT; + $attendees = $vevent->select('ATTENDEE'); + foreach ($attendees as $attendee) { + /** @var Property $attendee */ + if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) { + return $attendee; + } + } + return null; + } + + /** + * @param Property|null $attendee + * @return bool + */ + private function getAttendeeRSVP(?Property $attendee = null):bool { + if ($attendee !== null) { + $rsvp = $attendee->offsetGet('RSVP'); + if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { + return true; + } + } + // RFC 5545 3.2.17: default RSVP is false + return false; + } + + /** + * @param VEvent $vevent + * @return Property\ICalendar\DateTime + */ + private function getDTEndFromVEvent(VEvent $vevent):Property\ICalendar\DateTime { + if (isset($vevent->DTEND)) { + return $vevent->DTEND; + } + + if (isset($vevent->DURATION)) { + $isFloating = $vevent->DTSTART->isFloating(); + /** @var Property\ICalendar\DateTime $end */ + $end = clone $vevent->DTSTART; + $endDateTime = $end->getDateTime(); + $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); + $end->setDateTime($endDateTime, $isFloating); + return $end; + } + + if (!$vevent->DTSTART->hasTime()) { + $isFloating = $vevent->DTSTART->isFloating(); + /** @var Property\ICalendar\DateTime $end */ + $end = clone $vevent->DTSTART; + $endDateTime = $end->getDateTime(); + $endDateTime = $endDateTime->modify('+1 day'); + $end->setDateTime($endDateTime, $isFloating); + return $end; + } + + return clone $vevent->DTSTART; + } + + /** + * @param string $email + * @param \DateTimeInterface $start + * @param \DateTimeInterface $end + * @param string $ignoreUID + * @return bool + */ + private function isAvailableAtTime(string $email, \DateTimeInterface $start, \DateTimeInterface $end, string $ignoreUID):bool { + // This method is heavily inspired by Sabre\CalDAV\Schedule\Plugin::scheduleLocalDelivery + // and Sabre\CalDAV\Schedule\Plugin::getFreeBusyForEmail + + $aclPlugin = $this->server->getPlugin('acl'); + $this->server->removeListener('propFind', [$aclPlugin, 'propFind']); + + $result = $aclPlugin->principalSearch( + ['{http://sabredav.org/ns}email-address' => $this->stripOffMailTo($email)], + [ + '{DAV:}principal-URL', + '{' . self::NS_CALDAV . '}calendar-home-set', + '{' . self::NS_CALDAV . '}schedule-inbox-URL', + '{http://sabredav.org/ns}email-address', + + ] + ); + $this->server->on('propFind', [$aclPlugin, 'propFind'], 20); + + + // Grabbing the calendar list + $objects = []; + $calendarTimeZone = new DateTimeZone('UTC'); + + $homePath = $result[0][200]['{' . self::NS_CALDAV . '}calendar-home-set']->getHref(); + /** @var Calendar $node */ + foreach ($this->server->tree->getNodeForPath($homePath)->getChildren() as $node) { + + if (!$node instanceof ICalendar) { + continue; + } + + // Getting the list of object uris within the time-range + $urls = $node->calendarQuery([ + 'name' => 'VCALENDAR', + 'comp-filters' => [ + [ + 'name' => 'VEVENT', + 'is-not-defined' => false, + 'time-range' => [ + 'start' => $start, + 'end' => $end, + ], + 'comp-filters' => [], + 'prop-filters' => [], + ], + [ + 'name' => 'VEVENT', + 'is-not-defined' => false, + 'time-range' => null, + 'comp-filters' => [], + 'prop-filters' => [ + [ + 'name' => 'UID', + 'is-not-defined' => false, + 'time-range' => null, + 'text-match' => [ + 'value' => $ignoreUID, + 'negate-condition' => true, + 'collation' => 'i;octet', + ], + 'param-filters' => [], + ], + ] + ], + ], + 'prop-filters' => [], + 'is-not-defined' => false, + 'time-range' => null, + ]); + + foreach ($urls as $url) { + $objects[] = $node->getChild($url)->get(); + } + } + + $inboxProps = $this->server->getProperties( + $result[0][200]['{' . self::NS_CALDAV . '}schedule-inbox-URL']->getHref(), + ['{' . self::NS_CALDAV . '}calendar-availability'] + ); + + $vcalendar = new VCalendar(); + $vcalendar->METHOD = 'REPLY'; + + $generator = new FreeBusyGenerator(); + $generator->setObjects($objects); + $generator->setTimeRange($start, $end); + $generator->setBaseObject($vcalendar); + $generator->setTimeZone($calendarTimeZone); + + if (isset($inboxProps['{' . self::NS_CALDAV . '}calendar-availability'])) { + $generator->setVAvailability( + Reader::read( + $inboxProps['{' . self::NS_CALDAV . '}calendar-availability'] + ) + ); + } + + $result = $generator->getResult(); + if (!isset($result->VFREEBUSY)) { + return false; + } + + /** @var Component $freeBusyComponent */ + $freeBusyComponent = $result->VFREEBUSY; + $freeBusyProperties = $freeBusyComponent->select('FREEBUSY'); + // If there is no Free-busy property at all, the time-range is empty and available + if (count($freeBusyProperties) === 0) { + return true; + } + + // If more than one Free-Busy property was returned, it means that an event + // starts or ends inside this time-range, so it's not available and we return false + if (count($freeBusyProperties) > 1) { + return false; + } + + /** @var Property $freeBusyProperty */ + $freeBusyProperty = $freeBusyProperties[0]; + if (!$freeBusyProperty->offsetExists('FBTYPE')) { + // If there is no FBTYPE, it means it's busy + return false; + } + + $fbTypeParameter = $freeBusyProperty->offsetGet('FBTYPE'); + if (!($fbTypeParameter instanceof Parameter)) { + return false; + } + + return (strcasecmp($fbTypeParameter->getValue(), 'FREE') === 0); + } + + /** + * @param string $email + * @return string + */ + private function stripOffMailTo(string $email): string { + if (stripos($email, 'mailto:') === 0) { + return substr($email, 7); + } + + return $email; + } + + private function getCalendar(CalendarHome $calendarHome, string $uri): INode { + return $calendarHome->getChild($uri); + } + + private function isCalendarDeleted(CalendarHome $calendarHome, string $uri): bool { + $calendar = $this->getCalendar($calendarHome, $uri); + return $calendar instanceof Calendar && $calendar->isDeleted(); + } + + private function createCalendar(CalendarHome $calendarHome, string $principalUri, string $uri, string $displayName): void { + $calendarHome->getCalDAVBackend()->createCalendar($principalUri, $uri, [ + '{DAV:}displayname' => $displayName, + ]); + } + + 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 37000aa2eb8..27e39a76305 100644 --- a/apps/dav/lib/CalDAV/Search/SearchPlugin.php +++ b/apps/dav/lib/CalDAV/Search/SearchPlugin.php @@ -1,34 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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; -use OCA\DAV\CalDAV\CalendarHome; class SearchPlugin extends ServerPlugin { - const NS_Nextcloud = 'http://nextcloud.com/ns'; + public const NS_Nextcloud = 'http://nextcloud.com/ns'; /** * Reference to SabreDAV server object. @@ -77,8 +62,8 @@ class SearchPlugin extends ServerPlugin { $server->on('report', [$this, 'report']); - $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search'] = - 'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport'; + $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search'] + = CalendarSearchReport::class; } /** @@ -125,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) { @@ -138,11 +123,10 @@ class SearchPlugin extends ServerPlugin { // If we're dealing with the calendar home, the calendar home itself is // responsible for the calendar-query if ($node instanceof CalendarHome && $depth === 2) { - $nodePaths = $node->calendarSearch($report->filters, $report->limit, $report->offset); foreach ($nodePaths as $path) { - list($properties) = $this->server->getPropertiesForPath( + [$properties] = $this->server->getPropertiesForPath( $this->server->getRequestUri() . '/' . $path, $report->properties); $result[] = $properties; @@ -151,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 5cc8f799f90..21a4fff1caf 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php @@ -1,31 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2017 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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; +use OCA\DAV\CalDAV\Search\SearchPlugin; use Sabre\DAV\Exception\BadRequest; use Sabre\Xml\Reader; use Sabre\Xml\XmlDeserializable; -use OCA\DAV\CalDAV\Search\SearchPlugin; class CompFilter implements XmlDeserializable { @@ -34,7 +18,7 @@ class CompFilter implements XmlDeserializable { * @throws BadRequest * @return string */ - static function xmlDeserialize(Reader $reader) { + public static function xmlDeserialize(Reader $reader) { $att = $reader->parseAttributes(); $componentName = $att['name']; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php index 5fc38315438..a98b325397b 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php @@ -1,31 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2017 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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; +use OCA\DAV\CalDAV\Search\SearchPlugin; use Sabre\DAV\Exception\BadRequest; use Sabre\Xml\Reader; use Sabre\Xml\XmlDeserializable; -use OCA\DAV\CalDAV\Search\SearchPlugin; class LimitFilter implements XmlDeserializable { @@ -34,12 +18,12 @@ class LimitFilter implements XmlDeserializable { * @throws BadRequest * @return int */ - static function xmlDeserialize(Reader $reader) { + public static function xmlDeserialize(Reader $reader) { $value = $reader->parseInnerTree(); if (!is_int($value) && !is_string($value)) { throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}limit has illegal value'); } - return intval($value); + return (int)$value; } -}
\ No newline at end of file +} diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php index 7aac59809a2..ef438aa0258 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php @@ -1,31 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2017 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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; +use OCA\DAV\CalDAV\Search\SearchPlugin; use Sabre\DAV\Exception\BadRequest; use Sabre\Xml\Reader; use Sabre\Xml\XmlDeserializable; -use OCA\DAV\CalDAV\Search\SearchPlugin; class OffsetFilter implements XmlDeserializable { @@ -34,12 +18,12 @@ class OffsetFilter implements XmlDeserializable { * @throws BadRequest * @return int */ - static function xmlDeserialize(Reader $reader) { + public static function xmlDeserialize(Reader $reader) { $value = $reader->parseInnerTree(); if (!is_int($value) && !is_string($value)) { throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}offset has illegal value'); } - return intval($value); + return (int)$value; } -}
\ No newline at end of file +} diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php index bfcb960d402..0c31f32348a 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php @@ -1,31 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2017 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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; +use OCA\DAV\CalDAV\Search\SearchPlugin; use Sabre\DAV\Exception\BadRequest; use Sabre\Xml\Reader; use Sabre\Xml\XmlDeserializable; -use OCA\DAV\CalDAV\Search\SearchPlugin; class ParamFilter implements XmlDeserializable { @@ -34,7 +18,7 @@ class ParamFilter implements XmlDeserializable { * @throws BadRequest * @return string */ - static function xmlDeserialize(Reader $reader) { + public static function xmlDeserialize(Reader $reader) { $att = $reader->parseAttributes(); $property = $att['property']; $parameter = $att['name']; @@ -43,7 +27,6 @@ class ParamFilter implements XmlDeserializable { if (!is_string($property)) { throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}param-filter requires a valid property attribute'); - } if (!is_string($parameter)) { throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}param-filter requires a valid parameter attribute'); diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php index cfb2211fb59..251120e35cc 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php @@ -1,31 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2017 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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; +use OCA\DAV\CalDAV\Search\SearchPlugin; use Sabre\DAV\Exception\BadRequest; use Sabre\Xml\Reader; use Sabre\Xml\XmlDeserializable; -use OCA\DAV\CalDAV\Search\SearchPlugin; class PropFilter implements XmlDeserializable { @@ -34,7 +18,7 @@ class PropFilter implements XmlDeserializable { * @throws BadRequest * @return string */ - static function xmlDeserialize(Reader $reader) { + public static function xmlDeserialize(Reader $reader) { $att = $reader->parseAttributes(); $componentName = $att['name']; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php index ddd36818223..6d6bf958496 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php @@ -1,31 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2017 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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; +use OCA\DAV\CalDAV\Search\SearchPlugin; use Sabre\DAV\Exception\BadRequest; use Sabre\Xml\Reader; use Sabre\Xml\XmlDeserializable; -use OCA\DAV\CalDAV\Search\SearchPlugin; class SearchTermFilter implements XmlDeserializable { @@ -34,7 +18,7 @@ class SearchTermFilter implements XmlDeserializable { * @throws BadRequest * @return string */ - static function xmlDeserialize(Reader $reader) { + public static function xmlDeserialize(Reader $reader) { $value = $reader->parseInnerTree(); if (!is_string($value)) { throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}search-term has illegal value'); @@ -42,4 +26,4 @@ class SearchTermFilter implements XmlDeserializable { return $value; } -}
\ No newline at end of file +} diff --git a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php index e7c229c7b03..6ece88fa87b 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php @@ -1,31 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2017 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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Request; +use OCA\DAV\CalDAV\Search\SearchPlugin; use Sabre\DAV\Exception\BadRequest; use Sabre\Xml\Reader; use Sabre\Xml\XmlDeserializable; -use OCA\DAV\CalDAV\Search\SearchPlugin; /** * CalendarSearchReport request parser. @@ -82,22 +66,22 @@ class CalendarSearchReport implements XmlDeserializable { * @param Reader $reader * @return mixed */ - static function xmlDeserialize(Reader $reader) { + public static function xmlDeserialize(Reader $reader) { $elems = $reader->parseInnerTree([ - '{http://nextcloud.com/ns}comp-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\CompFilter', - '{http://nextcloud.com/ns}prop-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter', + '{http://nextcloud.com/ns}comp-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\CompFilter', + '{http://nextcloud.com/ns}prop-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter', '{http://nextcloud.com/ns}param-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\ParamFilter', - '{http://nextcloud.com/ns}search-term' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter', - '{http://nextcloud.com/ns}limit' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\LimitFilter', - '{http://nextcloud.com/ns}offset' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\OffsetFilter', - '{DAV:}prop' => 'Sabre\\Xml\\Element\\KeyValue', + '{http://nextcloud.com/ns}search-term' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter', + '{http://nextcloud.com/ns}limit' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\LimitFilter', + '{http://nextcloud.com/ns}offset' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\OffsetFilter', + '{DAV:}prop' => 'Sabre\\Xml\\Element\\KeyValue', ]); $newProps = [ - 'filters' => [], + 'filters' => [], 'properties' => [], - 'limit' => null, - 'offset' => null + 'limit' => null, + 'offset' => null ]; if (!is_array($elems)) { 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 new file mode 100644 index 00000000000..d8c429f2056 --- /dev/null +++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Trashbin; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\IRestorable; +use Sabre\CalDAV\ICalendarObject; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAVACL\ACLTrait; +use Sabre\DAVACL\IACL; + +class DeletedCalendarObject implements IACL, ICalendarObject, IRestorable { + use ACLTrait; + + public function __construct( + private string $name, + /** @var mixed[] */ + private array $objectData, + private string $principalUri, + private CalDavBackend $calDavBackend, + ) { + } + + public function delete() { + $this->calDavBackend->deleteCalendarObject( + $this->objectData['calendarid'], + $this->objectData['uri'], + CalDavBackend::CALENDAR_TYPE_CALENDAR, + true + ); + } + + public function getName() { + return $this->name; + } + + public function setName($name) { + throw new Forbidden(); + } + + public function getLastModified() { + return 0; + } + + public function put($data) { + throw new Forbidden(); + } + + public function get() { + return $this->objectData['calendardata']; + } + + public function getContentType() { + $mime = 'text/calendar; charset=utf-8'; + if (isset($this->objectData['component']) && $this->objectData['component']) { + $mime .= '; component=' . $this->objectData['component']; + } + + return $mime; + } + + public function getETag() { + return $this->objectData['etag']; + } + + public function getSize() { + return (int)$this->objectData['size']; + } + + public function restore(): void { + $this->calDavBackend->restoreCalendarObject($this->objectData); + } + + public function getDeletedAt(): ?int { + return $this->objectData['deleted_at'] ? (int)$this->objectData['deleted_at'] : null; + } + + public function getCalendarUri(): string { + return $this->objectData['calendaruri']; + } + + public function getACL(): array { + return [ + [ + 'privilege' => '{DAV:}read', // For queries + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}unbind', // For moving and deletion + 'principal' => '{DAV:}owner', + 'protected' => true, + ], + ]; + } + + public function getOwner() { + return $this->principalUri; + } +} diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php new file mode 100644 index 00000000000..f75e19689f1 --- /dev/null +++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php @@ -0,0 +1,133 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Trashbin; + +use OCA\DAV\CalDAV\CalDavBackend; +use Sabre\CalDAV\ICalendarObjectContainer; +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, IACL { + use ACLTrait; + + public const NAME = 'objects'; + + public function __construct( + protected CalDavBackend $caldavBackend, + /** @var mixed[] */ + private array $principalInfo, + ) { + } + + /** + * @see \OCA\DAV\CalDAV\Trashbin\DeletedCalendarObjectsCollection::calendarQuery + */ + public function getChildren() { + throw new NotImplemented(); + } + + public function getChild($name) { + if (!preg_match("/(\d+)\\.ics/", $name, $matches)) { + throw new NotFound(); + } + + $data = $this->caldavBackend->getCalendarObjectById( + $this->principalInfo['uri'], + (int)$matches[1], + ); + + // If the object hasn't been deleted yet then we don't want to find it here + if ($data === null) { + throw new NotFound(); + } + if (!isset($data['deleted_at'])) { + throw new BadRequest('The calendar object you\'re trying to restore is not marked as deleted'); + } + + return new DeletedCalendarObject( + $this->getRelativeObjectPath($data), + $data, + $this->principalInfo['uri'], + $this->caldavBackend + ); + } + + public function createFile($name, $data = null) { + throw new Forbidden(); + } + + public function createDirectory($name) { + throw new Forbidden(); + } + + public function childExists($name) { + try { + $this->getChild($name); + } catch (NotFound $e) { + return false; + } + + return true; + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + return self::NAME; + } + + public function setName($name) { + throw new Forbidden(); + } + + public function getLastModified(): int { + return 0; + } + + public function calendarQuery(array $filters) { + return array_map(function (array $calendarObjectInfo) { + return $this->getRelativeObjectPath($calendarObjectInfo); + }, $this->caldavBackend->getDeletedCalendarObjectsByPrincipal($this->principalInfo['uri'])); + } + + private function getRelativeObjectPath(array $calendarInfo): string { + return implode( + '.', + [$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 new file mode 100644 index 00000000000..6f58b1f3110 --- /dev/null +++ b/apps/dav/lib/CalDAV/Trashbin/Plugin.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Trashbin; + +use Closure; +use DateTimeImmutable; +use DateTimeInterface; +use OCA\DAV\CalDAV\Calendar; +use OCA\DAV\CalDAV\RetentionService; +use OCP\IRequest; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use function array_slice; +use function implode; + +class Plugin extends ServerPlugin { + public const PROPERTY_DELETED_AT = '{http://nextcloud.com/ns}deleted-at'; + public const PROPERTY_CALENDAR_URI = '{http://nextcloud.com/ns}calendar-uri'; + public const PROPERTY_RETENTION_DURATION = '{http://nextcloud.com/ns}trash-bin-retention-duration'; + + /** @var bool */ + private $disableTrashbin; + + /** @var Server */ + private $server; + + public function __construct( + IRequest $request, + private RetentionService $retentionService, + ) { + $this->disableTrashbin = $request->getHeader('X-NC-CalDAV-No-Trashbin') === '1'; + } + + public function initialize(Server $server): void { + $this->server = $server; + $server->on('beforeMethod:*', [$this, 'beforeMethod']); + $server->on('propFind', Closure::fromCallable([$this, 'propFind'])); + } + + public function beforeMethod(RequestInterface $request, ResponseInterface $response): void { + if (!$this->disableTrashbin) { + return; + } + + $path = $request->getPath(); + $pathParts = explode('/', ltrim($path, '/')); + if (\count($pathParts) < 3) { + // We are looking for a path like calendars/username/calendarname + return; + } + + // $calendarPath will look like calendars/username/calendarname + $calendarPath = implode( + '/', + array_slice($pathParts, 0, 3) + ); + try { + $calendar = $this->server->tree->getNodeForPath($calendarPath); + if (!($calendar instanceof Calendar)) { + // This is odd + return; + } + + /** @var Calendar $calendar */ + $calendar->disableTrashbin(); + } catch (NotFound $ex) { + return; + } + } + + private function propFind( + PropFind $propFind, + INode $node): void { + if ($node instanceof DeletedCalendarObject) { + $propFind->handle(self::PROPERTY_DELETED_AT, function () use ($node) { + $ts = $node->getDeletedAt(); + if ($ts === null) { + return null; + } + + return (new DateTimeImmutable()) + ->setTimestamp($ts) + ->format(DateTimeInterface::ATOM); + }); + $propFind->handle(self::PROPERTY_CALENDAR_URI, function () use ($node) { + return $node->getCalendarUri(); + }); + } + if ($node instanceof TrashbinHome) { + $propFind->handle(self::PROPERTY_RETENTION_DURATION, function () use ($node) { + return $this->retentionService->getDuration(); + }); + } + } + + public function getFeatures(): array { + return ['nc-calendar-trashbin']; + } + + public function getPluginName(): string { + return 'nc-calendar-trashbin'; + } +} diff --git a/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php b/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php new file mode 100644 index 00000000000..6641148de2b --- /dev/null +++ b/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Trashbin; + +use OCA\DAV\CalDAV\IRestorable; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; +use Sabre\DAV\IMoveTarget; +use Sabre\DAV\INode; + +class RestoreTarget implements ICollection, IMoveTarget { + public const NAME = 'restore'; + + public function createFile($name, $data = null) { + throw new Forbidden(); + } + + public function createDirectory($name) { + throw new Forbidden(); + } + + public function getChild($name) { + throw new NotFound(); + } + + public function getChildren(): array { + return []; + } + + public function childExists($name): bool { + return false; + } + + public function moveInto($targetName, $sourcePath, INode $sourceNode): bool { + if ($sourceNode instanceof IRestorable) { + $sourceNode->restore(); + return true; + } + + return false; + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + return 'restore'; + } + + public function setName($name) { + throw new Forbidden(); + } + + public function getLastModified() { + return 0; + } +} diff --git a/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php b/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php new file mode 100644 index 00000000000..1c76bd2295d --- /dev/null +++ b/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Trashbin; + +use OCA\DAV\CalDAV\CalDavBackend; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; +use Sabre\DAV\INode; +use Sabre\DAV\IProperties; +use Sabre\DAV\PropPatch; +use Sabre\DAV\Xml\Property\ResourceType; +use Sabre\DAVACL\ACLTrait; +use Sabre\DAVACL\IACL; +use function in_array; +use function sprintf; + +class TrashbinHome implements IACL, ICollection, IProperties { + use ACLTrait; + + public const NAME = 'trashbin'; + + public function __construct( + private CalDavBackend $caldavBackend, + private array $principalInfo, + ) { + } + + public function getOwner(): string { + return $this->principalInfo['uri']; + } + + public function createFile($name, $data = null) { + throw new Forbidden('Permission denied to create files in the trashbin'); + } + + public function createDirectory($name) { + throw new Forbidden('Permission denied to create a directory in the trashbin'); + } + + public function getChild($name): INode { + switch ($name) { + case RestoreTarget::NAME: + return new RestoreTarget(); + case DeletedCalendarObjectsCollection::NAME: + return new DeletedCalendarObjectsCollection( + $this->caldavBackend, + $this->principalInfo + ); + } + + throw new NotFound(); + } + + public function getChildren(): array { + return [ + new RestoreTarget(), + new DeletedCalendarObjectsCollection( + $this->caldavBackend, + $this->principalInfo + ), + ]; + } + + public function childExists($name): bool { + return in_array($name, [ + RestoreTarget::NAME, + DeletedCalendarObjectsCollection::NAME, + ], true); + } + + public function delete() { + throw new Forbidden('Permission denied to delete the trashbin'); + } + + public function getName(): string { + return self::NAME; + } + + public function setName($name) { + throw new Forbidden('Permission denied to rename the trashbin'); + } + + public function getLastModified(): int { + return 0; + } + + public function propPatch(PropPatch $propPatch): void { + throw new Forbidden('not implemented'); + } + + public function getProperties($properties): array { + return [ + '{DAV:}resourcetype' => new ResourceType([ + '{DAV:}collection', + sprintf('{%s}trash-bin', \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD), + ]), + ]; + } +} 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 new file mode 100644 index 00000000000..e07be39c7b4 --- /dev/null +++ b/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php @@ -0,0 +1,141 @@ +<?php + +declare(strict_types=1); + +/** + * 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\CalendarRoot; +use OCP\IRequest; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class Plugin extends ServerPlugin { + + /** + * list of regular expressions for calendar user agents, + * 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/', + '/Evolution/', + '/KIO/' + ]; + + /** + * @var bool + */ + private $enabled = false; + + /** + * @var Server + */ + private $server; + + /** + * Plugin constructor. + * + * @param IRequest $request + */ + public function __construct(IRequest $request) { + if ($request->isUserAgent(self::ENABLE_FOR_CLIENTS)) { + $this->enabled = true; + } + + $magicHeader = $request->getHeader('X-NC-CalDAV-Webcal-Caching'); + if ($magicHeader === 'On') { + $this->enabled = true; + } + + $isExportRequest = $request->getMethod() === 'GET' && array_key_exists('export', $request->getParams()); + if ($isExportRequest) { + $this->enabled = true; + } + } + + /** + * This initializes the plugin. + * + * This function is called by Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param Server $server + */ + public function initialize(Server $server) { + $this->server = $server; + $server->on('beforeMethod:*', [$this, 'beforeMethod'], 15); + } + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + */ + public function beforeMethod(RequestInterface $request, ResponseInterface $response) { + if (!$this->enabled) { + return; + } + + $path = $request->getPath(); + if (!str_starts_with($path, 'calendars/')) { + return; + } + + $pathParts = explode('/', ltrim($path, '/')); + if (\count($pathParts) < 2) { + return; + } + + try { + $calendarRoot = $this->server->tree->getNodeForPath($pathParts[0]); + if ($calendarRoot instanceof CalendarRoot) { + $calendarRoot->enableReturnCachedSubscriptions($pathParts[1]); + } + } catch (NotFound $ex) { + return; + } + } + + /** + * @return bool + */ + public function isCachingEnabledForThisRequest():bool { + return $this->enabled; + } + + /** + * This method should return a list of server-features. + * + * This is for example 'versioning' and is added to the DAV: header + * in an OPTIONS response. + * + * @return string[] + */ + public function getFeatures():array { + return ['nc-calendar-webcal-cache']; + } + + /** + * Returns a plugin name. + * + * Using this name other plugins will be able to access other plugins + * using Sabre\DAV\Server::getPlugin + * + * @return string + */ + public function getPluginName():string { + return 'nc-calendar-webcal-cache'; + } +} diff --git a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php new file mode 100644 index 00000000000..a0981e6dec1 --- /dev/null +++ b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php @@ -0,0 +1,264 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\WebcalCaching; + +use OCA\DAV\CalDAV\CalDavBackend; +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\VObject\Component; +use Sabre\VObject\DateTimeParser; +use Sabre\VObject\InvalidDataException; +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 { + + 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'; + + public function __construct( + private CalDavBackend $calDavBackend, + private LoggerInterface $logger, + private Connection $connection, + private ITimeFactory $time, + ) { + } + + public function refreshSubscription(string $principalUri, string $uri) { + $subscription = $this->getSubscription($principalUri, $uri); + $mutations = []; + if (!$subscription) { + return; + } + + // 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; + + try { + $splitter = new ICalendar($webcalData, Reader::OPTION_FORGIVING); + + while ($vObject = $splitter->getNext()) { + /** @var Component $vObject */ + $compName = null; + $uid = null; + + foreach ($vObject->getComponents() as $component) { + if ($component->name === 'VTIMEZONE') { + continue; + } + + $compName = $component->name; + + if ($stripAlarms) { + unset($component->{'VALARM'}); + } + if ($stripAttachments) { + unset($component->{'ATTACH'}); + } + + $uid = $component->{ 'UID' }->getValue(); + } + + if ($stripTodos && $compName === 'VTODO') { + continue; + } + + if (!isset($uid)) { + continue; + } + + try { + $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); + if ($newRefreshRate) { + $mutations[self::REFRESH_RATE] = $newRefreshRate; + } + + $this->updateSubscription($subscription, $mutations); + } catch (ParseException $ex) { + $this->logger->error('Subscription {subscriptionId} could not be refreshed due to a parsing error', ['exception' => $ex, 'subscriptionId' => $subscription['id']]); + } + } + + /** + * loads subscription from backend + */ + public function getSubscription(string $principalUri, string $uri): ?array { + $subscriptions = array_values(array_filter( + $this->calDavBackend->getSubscriptionsForUser($principalUri), + function ($sub) use ($uri) { + return $sub['uri'] === $uri; + } + )); + + if (count($subscriptions) === 0) { + return null; + } + + return $subscriptions[0]; + } + + + /** + * check if: + * - current subscription stores a refreshrate + * - the webcal feed suggests a refreshrate + * - return suggested refreshrate if user didn't set a custom one + * + */ + 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) { + return null; + } + + /** @var Component\VCalendar $vCalendar */ + $vCalendar = Reader::read($webcalData); + + $newRefreshRate = null; + if (isset($vCalendar->{'X-PUBLISHED-TTL'})) { + $newRefreshRate = $vCalendar->{'X-PUBLISHED-TTL'}->getValue(); + } + if (isset($vCalendar->{'REFRESH-INTERVAL'})) { + $newRefreshRate = $vCalendar->{'REFRESH-INTERVAL'}->getValue(); + } + + if (!$newRefreshRate) { + return null; + } + + // check if new refresh rate is even valid + try { + DateTimeParser::parseDuration($newRefreshRate); + } catch (InvalidDataException $ex) { + return null; + } + + return $newRefreshRate; + } + + /** + * update subscription stored in database + * used to set: + * - refreshrate + * - source + * + * @param array $subscription + * @param array $mutations + */ + private function updateSubscription(array $subscription, array $mutations) { + if (empty($mutations)) { + return; + } + + $propPatch = new PropPatch($mutations); + $this->calDavBackend->updateSubscription($subscription['id'], $propPatch); + $propPatch->commit(); + } + + /** + * Returns a random uri for a calendar-object + * + * @return string + */ + public function getRandomCalendarObjectUri():string { + return UUIDUtil::getUUID() . '.ics'; + } + + private function compareWithoutDtstamp(Component $vObject, array $calendarObject): bool { + foreach ($vObject->getComponents() as $component) { + unset($component->{'DTSTAMP'}); + } + + $localVobject = Reader::read($calendarObject['calendardata']); + foreach ($localVobject->getComponents() as $component) { + unset($component->{'DTSTAMP'}); + } + + return strcasecmp($localVobject->serialize(), $vObject->serialize()) === 0; + } +} |