diff options
Diffstat (limited to 'apps/dav/lib')
411 files changed, 25690 insertions, 16354 deletions
diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php index 8c7f21698a1..9807b585080 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -3,54 +3,23 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tobia De Koninck <tobia@ledfan.be> - * - * @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\AppInfo; -use Exception; -use OCA\DAV\BackgroundJob\UpdateCalendarResourcesRoomsBackgroundJob; -use OCA\DAV\CalDAV\Activity\Backend; -use OCA\DAV\CalDAV\BirthdayService; -use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\AppCalendar\AppCalendarPlugin; +use OCA\DAV\CalDAV\CachedSubscriptionProvider; use OCA\DAV\CalDAV\CalendarManager; use OCA\DAV\CalDAV\CalendarProvider; -use OCA\DAV\CalDAV\Reminder\Backend as ReminderBackend; use OCA\DAV\CalDAV\Reminder\NotificationProvider\AudioProvider; use OCA\DAV\CalDAV\Reminder\NotificationProvider\EmailProvider; use OCA\DAV\CalDAV\Reminder\NotificationProvider\PushProvider; use OCA\DAV\CalDAV\Reminder\NotificationProviderManager; use OCA\DAV\CalDAV\Reminder\Notifier; - -use OCA\DAV\CalDAV\WebcalCaching\RefreshWebcalService; use OCA\DAV\Capabilities; -use OCA\DAV\CardDAV\CardDavBackend; use OCA\DAV\CardDAV\ContactsManager; -use OCA\DAV\CardDAV\PhotoCache; use OCA\DAV\CardDAV\SyncService; use OCA\DAV\Events\AddressBookCreatedEvent; use OCA\DAV\Events\AddressBookDeletedEvent; @@ -59,42 +28,77 @@ use OCA\DAV\Events\AddressBookUpdatedEvent; use OCA\DAV\Events\CalendarCreatedEvent; use OCA\DAV\Events\CalendarDeletedEvent; use OCA\DAV\Events\CalendarMovedToTrashEvent; -use OCA\DAV\Events\CalendarObjectCreatedEvent; -use OCA\DAV\Events\CalendarObjectDeletedEvent; -use OCA\DAV\Events\CalendarObjectMovedToTrashEvent; -use OCA\DAV\Events\CalendarObjectRestoredEvent; -use OCA\DAV\Events\CalendarObjectUpdatedEvent; +use OCA\DAV\Events\CalendarPublishedEvent; use OCA\DAV\Events\CalendarRestoredEvent; use OCA\DAV\Events\CalendarShareUpdatedEvent; +use OCA\DAV\Events\CalendarUnpublishedEvent; use OCA\DAV\Events\CalendarUpdatedEvent; use OCA\DAV\Events\CardCreatedEvent; use OCA\DAV\Events\CardDeletedEvent; use OCA\DAV\Events\CardUpdatedEvent; -use OCA\DAV\HookManager; +use OCA\DAV\Events\SubscriptionCreatedEvent; +use OCA\DAV\Events\SubscriptionDeletedEvent; use OCA\DAV\Listener\ActivityUpdaterListener; +use OCA\DAV\Listener\AddMissingIndicesListener; use OCA\DAV\Listener\AddressbookListener; +use OCA\DAV\Listener\BirthdayListener; use OCA\DAV\Listener\CalendarContactInteractionListener; use OCA\DAV\Listener\CalendarDeletionDefaultUpdaterListener; use OCA\DAV\Listener\CalendarObjectReminderUpdaterListener; +use OCA\DAV\Listener\CalendarPublicationListener; +use OCA\DAV\Listener\CalendarShareUpdateListener; use OCA\DAV\Listener\CardListener; +use OCA\DAV\Listener\ClearPhotoCacheListener; +use OCA\DAV\Listener\DavAdminSettingsListener; +use OCA\DAV\Listener\OutOfOfficeListener; +use OCA\DAV\Listener\SubscriptionListener; +use OCA\DAV\Listener\TrustedServerRemovedListener; +use OCA\DAV\Listener\UserEventsListener; +use OCA\DAV\Listener\UserPreferenceListener; use OCA\DAV\Search\ContactsSearchProvider; use OCA\DAV\Search\EventsSearchProvider; use OCA\DAV\Search\TasksSearchProvider; +use OCA\DAV\Settings\Admin\SystemAddressBookSettings; +use OCA\DAV\SetupChecks\NeedsSystemAddressBookSync; +use OCA\DAV\SetupChecks\WebdavEndpoint; use OCA\DAV\UserMigration\CalendarMigrator; use OCA\DAV\UserMigration\ContactsMigrator; +use OCP\Accounts\UserUpdatedEvent; +use OCP\App\IAppManager; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\IAppContainer; +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\IManager as ICalendarManager; +use OCP\Config\BeforePreferenceDeletedEvent; +use OCP\Config\BeforePreferenceSetEvent; use OCP\Contacts\IManager as IContactsManager; -use OCP\ILogger; -use OCP\IServerContainer; -use OCP\IUser; +use OCP\DB\Events\AddMissingIndicesEvent; +use OCP\Federation\Events\TrustedServerRemovedEvent; +use OCP\IUserSession; +use OCP\Server; +use OCP\Settings\Events\DeclarativeSettingsGetValueEvent; +use OCP\Settings\Events\DeclarativeSettingsSetValueEvent; +use OCP\User\Events\BeforeUserDeletedEvent; +use OCP\User\Events\BeforeUserIdUnassignedEvent; +use OCP\User\Events\OutOfOfficeChangedEvent; +use OCP\User\Events\OutOfOfficeClearedEvent; +use OCP\User\Events\OutOfOfficeScheduledEvent; +use OCP\User\Events\UserChangedEvent; +use OCP\User\Events\UserCreatedEvent; +use OCP\User\Events\UserDeletedEvent; +use OCP\User\Events\UserFirstTimeLoggedInEvent; +use OCP\User\Events\UserIdAssignedEvent; +use OCP\User\Events\UserIdUnassignedEvent; use Psr\Container\ContainerInterface; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\EventDispatcher\GenericEvent; +use Psr\Log\LoggerInterface; use Throwable; use function is_null; @@ -107,13 +111,10 @@ class Application extends App implements IBootstrap { public function register(IRegistrationContext $context): void { $context->registerServiceAlias('CardDAVSyncService', SyncService::class); - $context->registerService(PhotoCache::class, function (ContainerInterface $c) { - /** @var IServerContainer $server */ - $server = $c->get(IServerContainer::class); - - return new PhotoCache( - $server->getAppDataDir('dav-photocache'), - $c->get(ILogger::class) + $context->registerService(AppCalendarPlugin::class, function (ContainerInterface $c) { + return new AppCalendarPlugin( + $c->get(ICalendarManager::class), + $c->get(LoggerInterface::class) ); }); @@ -132,6 +133,8 @@ class Application extends App implements IBootstrap { /** * Register event listeners */ + $context->registerEventListener(AddMissingIndicesEvent::class, AddMissingIndicesListener::class); + $context->registerEventListener(CalendarCreatedEvent::class, ActivityUpdaterListener::class); $context->registerEventListener(CalendarDeletedEvent::class, ActivityUpdaterListener::class); $context->registerEventListener(CalendarDeletedEvent::class, CalendarObjectReminderUpdaterListener::class); @@ -149,11 +152,19 @@ class Application extends App implements IBootstrap { $context->registerEventListener(CalendarObjectUpdatedEvent::class, CalendarObjectReminderUpdaterListener::class); $context->registerEventListener(CalendarObjectDeletedEvent::class, ActivityUpdaterListener::class); $context->registerEventListener(CalendarObjectDeletedEvent::class, CalendarObjectReminderUpdaterListener::class); + $context->registerEventListener(CalendarObjectMovedEvent::class, ActivityUpdaterListener::class); + $context->registerEventListener(CalendarObjectMovedEvent::class, CalendarObjectReminderUpdaterListener::class); $context->registerEventListener(CalendarObjectMovedToTrashEvent::class, ActivityUpdaterListener::class); $context->registerEventListener(CalendarObjectMovedToTrashEvent::class, CalendarObjectReminderUpdaterListener::class); $context->registerEventListener(CalendarObjectRestoredEvent::class, ActivityUpdaterListener::class); $context->registerEventListener(CalendarObjectRestoredEvent::class, CalendarObjectReminderUpdaterListener::class); $context->registerEventListener(CalendarShareUpdatedEvent::class, CalendarContactInteractionListener::class); + $context->registerEventListener(CalendarPublishedEvent::class, CalendarPublicationListener::class); + $context->registerEventListener(CalendarUnpublishedEvent::class, CalendarPublicationListener::class); + $context->registerEventListener(CalendarShareUpdatedEvent::class, CalendarShareUpdateListener::class); + + $context->registerEventListener(SubscriptionCreatedEvent::class, SubscriptionListener::class); + $context->registerEventListener(SubscriptionDeletedEvent::class, SubscriptionListener::class); $context->registerEventListener(AddressBookCreatedEvent::class, AddressbookListener::class); @@ -163,179 +174,60 @@ class Application extends App implements IBootstrap { $context->registerEventListener(CardCreatedEvent::class, CardListener::class); $context->registerEventListener(CardDeletedEvent::class, CardListener::class); $context->registerEventListener(CardUpdatedEvent::class, CardListener::class); + $context->registerEventListener(CardCreatedEvent::class, BirthdayListener::class); + $context->registerEventListener(CardDeletedEvent::class, BirthdayListener::class); + $context->registerEventListener(CardUpdatedEvent::class, BirthdayListener::class); + $context->registerEventListener(CardDeletedEvent::class, ClearPhotoCacheListener::class); + $context->registerEventListener(CardUpdatedEvent::class, ClearPhotoCacheListener::class); + $context->registerEventListener(TrustedServerRemovedEvent::class, TrustedServerRemovedListener::class); + + $context->registerEventListener(BeforePreferenceDeletedEvent::class, UserPreferenceListener::class); + $context->registerEventListener(BeforePreferenceSetEvent::class, UserPreferenceListener::class); + + $context->registerEventListener(OutOfOfficeChangedEvent::class, OutOfOfficeListener::class); + $context->registerEventListener(OutOfOfficeClearedEvent::class, OutOfOfficeListener::class); + $context->registerEventListener(OutOfOfficeScheduledEvent::class, OutOfOfficeListener::class); + + $context->registerEventListener(UserFirstTimeLoggedInEvent::class, UserEventsListener::class); + $context->registerEventListener(UserIdAssignedEvent::class, UserEventsListener::class); + $context->registerEventListener(BeforeUserIdUnassignedEvent::class, UserEventsListener::class); + $context->registerEventListener(UserIdUnassignedEvent::class, UserEventsListener::class); + $context->registerEventListener(BeforeUserDeletedEvent::class, UserEventsListener::class); + $context->registerEventListener(UserDeletedEvent::class, UserEventsListener::class); + $context->registerEventListener(UserCreatedEvent::class, UserEventsListener::class); + $context->registerEventListener(UserChangedEvent::class, UserEventsListener::class); + $context->registerEventListener(UserUpdatedEvent::class, UserEventsListener::class); $context->registerNotifierService(Notifier::class); $context->registerCalendarProvider(CalendarProvider::class); + $context->registerCalendarProvider(CachedSubscriptionProvider::class); $context->registerUserMigrator(CalendarMigrator::class); $context->registerUserMigrator(ContactsMigrator::class); - } - - public function boot(IBootContext $context): void { - // Load all dav apps - \OC_App::loadApps(['dav']); - - $context->injectFn([$this, 'registerHooks']); - $context->injectFn([$this, 'registerContactsManager']); - $context->injectFn([$this, 'registerCalendarManager']); - $context->injectFn([$this, 'registerCalendarReminders']); - } - - public function registerHooks(HookManager $hm, - EventDispatcherInterface $dispatcher, - IAppContainer $container, - IServerContainer $serverContainer) { - $hm->setup(); - // first time login event setup - $dispatcher->addListener(IUser::class . '::firstLogin', function ($event) use ($hm) { - if ($event instanceof GenericEvent) { - $hm->firstLogin($event->getSubject()); - } - }); - - $birthdayListener = function ($event) use ($container): void { - if ($event instanceof GenericEvent) { - /** @var BirthdayService $b */ - $b = $container->query(BirthdayService::class); - $b->onCardChanged( - (int) $event->getArgument('addressBookId'), - (string) $event->getArgument('cardUri'), - (string) $event->getArgument('cardData') - ); - } - }; - - $dispatcher->addListener('\OCA\DAV\CardDAV\CardDavBackend::createCard', $birthdayListener); - $dispatcher->addListener('\OCA\DAV\CardDAV\CardDavBackend::updateCard', $birthdayListener); - $dispatcher->addListener('\OCA\DAV\CardDAV\CardDavBackend::deleteCard', function ($event) use ($container) { - if ($event instanceof GenericEvent) { - /** @var BirthdayService $b */ - $b = $container->query(BirthdayService::class); - $b->onCardDeleted( - (int) $event->getArgument('addressBookId'), - (string) $event->getArgument('cardUri') - ); - } - }); - - $clearPhotoCache = function ($event) use ($container): void { - if ($event instanceof GenericEvent) { - /** @var PhotoCache $p */ - $p = $container->query(PhotoCache::class); - $p->delete( - $event->getArgument('addressBookId'), - $event->getArgument('cardUri') - ); - } - }; - $dispatcher->addListener('\OCA\DAV\CardDAV\CardDavBackend::updateCard', $clearPhotoCache); - $dispatcher->addListener('\OCA\DAV\CardDAV\CardDavBackend::deleteCard', $clearPhotoCache); - - $dispatcher->addListener('OC\AccountManager::userUpdated', function (GenericEvent $event) use ($container) { - $user = $event->getSubject(); - /** @var SyncService $syncService */ - $syncService = $container->query(SyncService::class); - $syncService->updateUser($user); - }); + $context->registerSetupCheck(NeedsSystemAddressBookSync::class); + $context->registerSetupCheck(WebdavEndpoint::class); + // register admin settings form and listener(s) + $context->registerDeclarativeSettings(SystemAddressBookSettings::class); + $context->registerEventListener(DeclarativeSettingsGetValueEvent::class, DavAdminSettingsListener::class); + $context->registerEventListener(DeclarativeSettingsSetValueEvent::class, DavAdminSettingsListener::class); - $dispatcher->addListener('\OCA\DAV\CalDAV\CalDavBackend::updateShares', function (GenericEvent $event) use ($container) { - /** @var Backend $backend */ - $backend = $container->query(Backend::class); - $backend->onCalendarUpdateShares( - $event->getArgument('calendarData'), - $event->getArgument('shares'), - $event->getArgument('add'), - $event->getArgument('remove') - ); - - // Here we should recalculate if reminders should be sent to new or old sharees - }); - - $dispatcher->addListener('\OCA\DAV\CalDAV\CalDavBackend::publishCalendar', function (GenericEvent $event) use ($container) { - /** @var Backend $backend */ - $backend = $container->query(Backend::class); - $backend->onCalendarPublication( - $event->getArgument('calendarData'), - $event->getArgument('public') - ); - }); - - - $dispatcher->addListener('OCP\Federation\TrustedServerEvent::remove', - function (GenericEvent $event) { - /** @var CardDavBackend $cardDavBackend */ - $cardDavBackend = \OC::$server->query(CardDavBackend::class); - $addressBookUri = $event->getSubject(); - $addressBook = $cardDavBackend->getAddressBooksByUri('principals/system/system', $addressBookUri); - if (!is_null($addressBook)) { - $cardDavBackend->deleteAddressBook($addressBook['id']); - } - } - ); - - $dispatcher->addListener('\OCA\DAV\CalDAV\CalDavBackend::createSubscription', - function (GenericEvent $event) use ($container, $serverContainer) { - $jobList = $serverContainer->getJobList(); - $subscriptionData = $event->getArgument('subscriptionData'); - - /** - * Initial subscription refetch - * - * @var RefreshWebcalService $refreshWebcalService - */ - $refreshWebcalService = $container->query(RefreshWebcalService::class); - $refreshWebcalService->refreshSubscription( - (string) $subscriptionData['principaluri'], - (string) $subscriptionData['uri'] - ); - - $jobList->add(\OCA\DAV\BackgroundJob\RefreshWebcalJob::class, [ - 'principaluri' => $subscriptionData['principaluri'], - 'uri' => $subscriptionData['uri'] - ]); - } - ); - - $dispatcher->addListener('\OCA\DAV\CalDAV\CalDavBackend::deleteSubscription', - function (GenericEvent $event) use ($container, $serverContainer) { - $jobList = $serverContainer->getJobList(); - $subscriptionData = $event->getArgument('subscriptionData'); - - $jobList->remove(\OCA\DAV\BackgroundJob\RefreshWebcalJob::class, [ - 'principaluri' => $subscriptionData['principaluri'], - 'uri' => $subscriptionData['uri'] - ]); - - /** @var CalDavBackend $calDavBackend */ - $calDavBackend = $container->get(CalDavBackend::class); - $calDavBackend->purgeAllCachedEventsForSubscription($subscriptionData['id']); - /** @var ReminderBackend $calDavBackend */ - $reminderBackend = $container->get(ReminderBackend::class); - $reminderBackend->cleanRemindersForCalendar((int) $subscriptionData['id']); - } - ); + } - $eventHandler = function () use ($container, $serverContainer): void { - try { - /** @var UpdateCalendarResourcesRoomsBackgroundJob $job */ - $job = $container->query(UpdateCalendarResourcesRoomsBackgroundJob::class); - $job->run([]); - $serverContainer->getJobList()->setLastRun($job); - } catch (Exception $ex) { - $serverContainer->getLogger()->logException($ex); - } - }; + public function boot(IBootContext $context): void { + // Load all dav apps + $context->getServerContainer()->get(IAppManager::class)->loadApps(['dav']); - $dispatcher->addListener('\OCP\Calendar\Resource\ForceRefreshEvent', $eventHandler); - $dispatcher->addListener('\OCP\Calendar\Room\ForceRefreshEvent', $eventHandler); + $context->injectFn($this->registerContactsManager(...)); + $context->injectFn($this->registerCalendarManager(...)); + $context->injectFn($this->registerCalendarReminders(...)); } public function registerContactsManager(IContactsManager $cm, IAppContainer $container): void { $cm->register(function () use ($container, $cm): void { - $user = \OC::$server->getUserSession()->getUser(); + $user = Server::get(IUserSession::class)->getUser(); if (!is_null($user)) { $this->setupContactsProvider($cm, $container, $user->getUID()); } else { @@ -345,26 +237,25 @@ class Application extends App implements IBootstrap { } private function setupContactsProvider(IContactsManager $contactsManager, - IAppContainer $container, - string $userID): void { + IAppContainer $container, + string $userID): void { /** @var ContactsManager $cm */ $cm = $container->query(ContactsManager::class); $urlGenerator = $container->getServer()->getURLGenerator(); $cm->setupContactsProvider($contactsManager, $userID, $urlGenerator); } - private function setupSystemContactsProvider(IContactsManager $contactsManager, - IAppContainer $container): void { + private function setupSystemContactsProvider(IContactsManager $contactsManager, IAppContainer $container): void { /** @var ContactsManager $cm */ $cm = $container->query(ContactsManager::class); $urlGenerator = $container->getServer()->getURLGenerator(); - $cm->setupSystemContactsProvider($contactsManager, $urlGenerator); + $cm->setupSystemContactsProvider($contactsManager, null, $urlGenerator); } public function registerCalendarManager(ICalendarManager $calendarManager, - IAppContainer $container): void { - $calendarManager->register(function () use ($container, $calendarManager) { - $user = \OC::$server->getUserSession()->getUser(); + IAppContainer $container): void { + $calendarManager->register(function () use ($container, $calendarManager): void { + $user = Server::get(IUserSession::class)->getUser(); if ($user !== null) { $this->setupCalendarProvider($calendarManager, $container, $user->getUID()); } @@ -372,20 +263,20 @@ class Application extends App implements IBootstrap { } private function setupCalendarProvider(ICalendarManager $calendarManager, - IAppContainer $container, - $userId) { + IAppContainer $container, + $userId) { $cm = $container->query(CalendarManager::class); $cm->setupCalendarProvider($calendarManager, $userId); } public function registerCalendarReminders(NotificationProviderManager $manager, - ILogger $logger): void { + LoggerInterface $logger): void { try { $manager->registerProvider(AudioProvider::class); $manager->registerProvider(EmailProvider::class); $manager->registerProvider(PushProvider::class); } catch (Throwable $ex) { - $logger->logException($ex); + $logger->error($ex->getMessage(), ['exception' => $ex]); } } } diff --git a/apps/dav/lib/AppInfo/PluginManager.php b/apps/dav/lib/AppInfo/PluginManager.php index 0b83d6a9205..428547e3f61 100644 --- a/apps/dav/lib/AppInfo/PluginManager.php +++ b/apps/dav/lib/AppInfo/PluginManager.php @@ -3,32 +3,14 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud GmbH. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud GmbH. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\AppInfo; use OC\ServerContainer; +use OCA\DAV\CalDAV\AppCalendar\AppCalendarPlugin; use OCA\DAV\CalDAV\Integration\ICalendarProvider; use OCA\DAV\CardDAV\Integration\IAddressBookProvider; use OCP\App\IAppManager; @@ -46,16 +28,6 @@ use function is_array; class PluginManager { /** - * @var ServerContainer - */ - private $container; - - /** - * @var IAppManager - */ - private $appManager; - - /** * App plugins * * @var ServerPlugin[] @@ -92,9 +64,10 @@ class PluginManager { * @param ServerContainer $container server container for resolving plugin classes * @param IAppManager $appManager app manager to loading apps and their info */ - public function __construct(ServerContainer $container, IAppManager $appManager) { - $this->container = $container; - $this->appManager = $appManager; + public function __construct( + private ServerContainer $container, + private IAppManager $appManager, + ) { } /** @@ -144,7 +117,9 @@ class PluginManager { } $this->populated = true; - foreach ($this->appManager->getInstalledApps() as $app) { + $this->calendarPlugins[] = $this->container->get(AppCalendarPlugin::class); + + foreach ($this->appManager->getEnabledApps() as $app) { // load plugins and collections from info.xml $info = $this->appManager->getAppInfo($app); if (!isset($info['types']) || !in_array('dav', $info['types'], true)) { @@ -253,7 +228,7 @@ class PluginManager { private function createClass(string $className): object { try { - return $this->container->query($className); + return $this->container->get($className); } catch (QueryException $e) { if (class_exists($className)) { return new $className(); diff --git a/apps/dav/lib/Avatars/AvatarHome.php b/apps/dav/lib/Avatars/AvatarHome.php index ba52daeb2b3..c3b95db1f4f 100644 --- a/apps/dav/lib/Avatars/AvatarHome.php +++ b/apps/dav/lib/Avatars/AvatarHome.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud GmbH - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Avatars; @@ -33,20 +16,16 @@ use Sabre\Uri; class AvatarHome implements ICollection { - /** @var array */ - private $principalInfo; - /** @var IAvatarManager */ - private $avatarManager; - /** * AvatarHome constructor. * * @param array $principalInfo * @param IAvatarManager $avatarManager */ - public function __construct($principalInfo, IAvatarManager $avatarManager) { - $this->principalInfo = $principalInfo; - $this->avatarManager = $avatarManager; + public function __construct( + private $principalInfo, + private IAvatarManager $avatarManager, + ) { } public function createFile($name, $data = null) { @@ -59,8 +38,8 @@ class AvatarHome implements ICollection { public function getChild($name) { $elements = pathinfo($name); - $ext = isset($elements['extension']) ? $elements['extension'] : ''; - $size = (int)(isset($elements['filename']) ? $elements['filename'] : '64'); + $ext = $elements['extension'] ?? ''; + $size = (int)($elements['filename'] ?? '64'); if (!in_array($ext, ['jpeg', 'png'], true)) { throw new MethodNotAllowed('File format not allowed'); } diff --git a/apps/dav/lib/Avatars/AvatarNode.php b/apps/dav/lib/Avatars/AvatarNode.php index ade523561f2..b3a605fbb02 100644 --- a/apps/dav/lib/Avatars/AvatarNode.php +++ b/apps/dav/lib/Avatars/AvatarNode.php @@ -1,24 +1,9 @@ <?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: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Avatars; @@ -26,10 +11,6 @@ use OCP\IAvatar; use Sabre\DAV\File; class AvatarNode extends File { - private $ext; - private $size; - private $avatar; - /** * AvatarNode constructor. * @@ -37,10 +18,11 @@ class AvatarNode extends File { * @param string $ext * @param IAvatar $avatar */ - public function __construct($size, $ext, $avatar) { - $this->size = $size; - $this->ext = $ext; - $this->avatar = $avatar; + public function __construct( + private $size, + private $ext, + private $avatar, + ) { } /** @@ -87,10 +69,6 @@ class AvatarNode extends File { } public function getLastModified() { - $timestamp = $this->avatar->getFile($this->size)->getMTime(); - if (!empty($timestamp)) { - return (int)$timestamp; - } - return $timestamp; + return $this->avatar->getFile($this->size)->getMTime(); } } diff --git a/apps/dav/lib/Avatars/RootCollection.php b/apps/dav/lib/Avatars/RootCollection.php index c5e78624d44..033dcaf7a5c 100644 --- a/apps/dav/lib/Avatars/RootCollection.php +++ b/apps/dav/lib/Avatars/RootCollection.php @@ -1,29 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2016 Thomas Müller <thomas.mueller@tmit.eu> - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @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: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Avatars; +use OCP\IAvatarManager; +use OCP\Server; use Sabre\DAVACL\AbstractPrincipalCollection; class RootCollection extends AbstractPrincipalCollection { @@ -39,7 +24,7 @@ class RootCollection extends AbstractPrincipalCollection { * @return AvatarHome */ public function getChildForPrincipal(array $principalInfo) { - $avatarManager = \OC::$server->getAvatarManager(); + $avatarManager = Server::get(IAvatarManager::class); return new AvatarHome($principalInfo, $avatarManager); } diff --git a/apps/dav/lib/BackgroundJob/BuildReminderIndexBackgroundJob.php b/apps/dav/lib/BackgroundJob/BuildReminderIndexBackgroundJob.php index 3d4e4dd5e6b..1165367c33f 100644 --- a/apps/dav/lib/BackgroundJob/BuildReminderIndexBackgroundJob.php +++ b/apps/dav/lib/BackgroundJob/BuildReminderIndexBackgroundJob.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright 2019 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\BackgroundJob; @@ -32,7 +13,7 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\BackgroundJob\QueuedJob; use OCP\IDBConnection; -use OCP\ILogger; +use Psr\Log\LoggerInterface; /** * Class BuildReminderIndexBackgroundJob @@ -41,51 +22,28 @@ use OCP\ILogger; */ class BuildReminderIndexBackgroundJob extends QueuedJob { - /** @var IDBConnection */ - private $db; - - /** @var ReminderService */ - private $reminderService; - - /** @var ILogger */ - private $logger; - - /** @var IJobList */ - private $jobList; - /** @var ITimeFactory */ private $timeFactory; /** * BuildReminderIndexBackgroundJob constructor. - * - * @param IDBConnection $db - * @param ReminderService $reminderService - * @param ILogger $logger - * @param IJobList $jobList - * @param ITimeFactory $timeFactory */ - public function __construct(IDBConnection $db, - ReminderService $reminderService, - ILogger $logger, - IJobList $jobList, - ITimeFactory $timeFactory) { + public function __construct( + private IDBConnection $db, + private ReminderService $reminderService, + private LoggerInterface $logger, + private IJobList $jobList, + ITimeFactory $timeFactory, + ) { parent::__construct($timeFactory); - $this->db = $db; - $this->reminderService = $reminderService; - $this->logger = $logger; - $this->jobList = $jobList; $this->timeFactory = $timeFactory; } - /** - * @param $arguments - */ - public function run($arguments) { - $offset = (int) $arguments['offset']; - $stopAt = (int) $arguments['stopAt']; + public function run($argument) { + $offset = (int)$argument['offset']; + $stopAt = (int)$argument['stopAt']; - $this->logger->info('Building calendar reminder index (' . $offset .'/' . $stopAt . ')'); + $this->logger->info('Building calendar reminder index (' . $offset . '/' . $stopAt . ')'); $offset = $this->buildIndex($offset, $stopAt); @@ -115,9 +73,9 @@ class BuildReminderIndexBackgroundJob extends QueuedJob { ->andWhere($query->expr()->gt('id', $query->createNamedParameter($offset))) ->orderBy('id', 'ASC'); - $stmt = $query->execute(); - while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { - $offset = $row['id']; + $result = $query->executeQuery(); + while ($row = $result->fetch(\PDO::FETCH_ASSOC)) { + $offset = (int)$row['id']; if (is_resource($row['calendardata'])) { $row['calendardata'] = stream_get_contents($row['calendardata']); } @@ -126,14 +84,16 @@ class BuildReminderIndexBackgroundJob extends QueuedJob { try { $this->reminderService->onCalendarObjectCreate($row); } catch (\Exception $ex) { - $this->logger->logException($ex); + $this->logger->error($ex->getMessage(), ['exception' => $ex]); } if (($this->timeFactory->getTime() - $startTime) > 15) { + $result->closeCursor(); return $offset; } } + $result->closeCursor(); return $stopAt; } } diff --git a/apps/dav/lib/BackgroundJob/CalendarRetentionJob.php b/apps/dav/lib/BackgroundJob/CalendarRetentionJob.php index b57ed07d5c2..c6edac4f228 100644 --- a/apps/dav/lib/BackgroundJob/CalendarRetentionJob.php +++ b/apps/dav/lib/BackgroundJob/CalendarRetentionJob.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\BackgroundJob; @@ -30,13 +13,11 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; class CalendarRetentionJob extends TimedJob { - /** @var RetentionService */ - private $service; - - public function __construct(ITimeFactory $time, - RetentionService $service) { + public function __construct( + ITimeFactory $time, + private RetentionService $service, + ) { parent::__construct($time); - $this->service = $service; // Run four times a day $this->setInterval(6 * 60 * 60); diff --git a/apps/dav/lib/BackgroundJob/CleanupDirectLinksJob.php b/apps/dav/lib/BackgroundJob/CleanupDirectLinksJob.php index 073fc53e07a..49b6b1607ef 100644 --- a/apps/dav/lib/BackgroundJob/CleanupDirectLinksJob.php +++ b/apps/dav/lib/BackgroundJob/CleanupDirectLinksJob.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\BackgroundJob; @@ -31,12 +13,11 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; class CleanupDirectLinksJob extends TimedJob { - /** @var DirectMapper */ - private $mapper; - - public function __construct(ITimeFactory $timeFactory, DirectMapper $mapper) { + public function __construct( + ITimeFactory $timeFactory, + private DirectMapper $mapper, + ) { parent::__construct($timeFactory); - $this->mapper = $mapper; // Run once a day at off-peak time $this->setInterval(24 * 60 * 60); diff --git a/apps/dav/lib/BackgroundJob/CleanupInvitationTokenJob.php b/apps/dav/lib/BackgroundJob/CleanupInvitationTokenJob.php index 6339e721c93..7b664d03181 100644 --- a/apps/dav/lib/BackgroundJob/CleanupInvitationTokenJob.php +++ b/apps/dav/lib/BackgroundJob/CleanupInvitationTokenJob.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018, 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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\BackgroundJob; @@ -32,12 +14,11 @@ use OCP\IDBConnection; class CleanupInvitationTokenJob extends TimedJob { - /** @var IDBConnection */ - private $db; - - public function __construct(IDBConnection $db, ITimeFactory $time) { + public function __construct( + private IDBConnection $db, + ITimeFactory $time, + ) { parent::__construct($time); - $this->db = $db; // Run once a day at off-peak time $this->setInterval(24 * 60 * 60); diff --git a/apps/dav/lib/BackgroundJob/CleanupOrphanedChildrenJob.php b/apps/dav/lib/BackgroundJob/CleanupOrphanedChildrenJob.php new file mode 100644 index 00000000000..8a5e34381a7 --- /dev/null +++ b/apps/dav/lib/BackgroundJob/CleanupOrphanedChildrenJob.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\BackgroundJob; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; + +class CleanupOrphanedChildrenJob extends QueuedJob { + public const ARGUMENT_CHILD_TABLE = 'childTable'; + public const ARGUMENT_PARENT_TABLE = 'parentTable'; + public const ARGUMENT_PARENT_ID = 'parentId'; + public const ARGUMENT_LOG_MESSAGE = 'logMessage'; + + private const BATCH_SIZE = 1000; + + public function __construct( + ITimeFactory $time, + private readonly IDBConnection $connection, + private readonly LoggerInterface $logger, + private readonly IJobList $jobList, + ) { + parent::__construct($time); + } + + protected function run($argument): void { + $childTable = $argument[self::ARGUMENT_CHILD_TABLE]; + $parentTable = $argument[self::ARGUMENT_PARENT_TABLE]; + $parentId = $argument[self::ARGUMENT_PARENT_ID]; + $logMessage = $argument[self::ARGUMENT_LOG_MESSAGE]; + + $orphanCount = $this->cleanUpOrphans($childTable, $parentTable, $parentId); + $this->logger->debug(sprintf($logMessage, $orphanCount)); + + // Requeue if there might be more orphans + if ($orphanCount >= self::BATCH_SIZE) { + $this->jobList->add(self::class, $argument); + } + } + + private function cleanUpOrphans( + string $childTable, + string $parentTable, + string $parentId, + ): int { + // We can't merge both queries into a single one here as DELETEing from a table while + // SELECTing it in a sub query is not supported by Oracle DB. + // Ref https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/delete.html#idm46006185488144 + + $selectQb = $this->connection->getQueryBuilder(); + + $selectQb->select('c.id') + ->from($childTable, 'c') + ->leftJoin('c', $parentTable, 'p', $selectQb->expr()->eq('c.' . $parentId, 'p.id')) + ->where($selectQb->expr()->isNull('p.id')) + ->setMaxResults(self::BATCH_SIZE); + + if (\in_array($parentTable, ['calendars', 'calendarsubscriptions'], true)) { + $calendarType = $parentTable === 'calendarsubscriptions' ? CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION : CalDavBackend::CALENDAR_TYPE_CALENDAR; + $selectQb->andWhere($selectQb->expr()->eq('c.calendartype', $selectQb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + } + + $result = $selectQb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + if (empty($rows)) { + return 0; + } + + $orphanItems = array_map(static fn ($row) => $row['id'], $rows); + $deleteQb = $this->connection->getQueryBuilder(); + $deleteQb->delete($childTable) + ->where($deleteQb->expr()->in('id', $deleteQb->createNamedParameter($orphanItems, IQueryBuilder::PARAM_INT_ARRAY))); + $deleteQb->executeStatement(); + + return count($orphanItems); + } +} diff --git a/apps/dav/lib/BackgroundJob/DeleteOutdatedSchedulingObjects.php b/apps/dav/lib/BackgroundJob/DeleteOutdatedSchedulingObjects.php new file mode 100644 index 00000000000..bc306d58fe1 --- /dev/null +++ b/apps/dav/lib/BackgroundJob/DeleteOutdatedSchedulingObjects.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\BackgroundJob; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; + +class DeleteOutdatedSchedulingObjects extends TimedJob { + public function __construct( + private CalDavBackend $calDavBackend, + private LoggerInterface $logger, + ITimeFactory $timeFactory, + ) { + parent::__construct($timeFactory); + $this->setInterval(23 * 60 * 60); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + /** + * @param array $argument + */ + protected function run($argument): void { + $time = $this->time->getTime() - (60 * 60); + $this->calDavBackend->deleteOutdatedSchedulingObjects($time, 50000); + $this->logger->info('Removed outdated scheduling objects'); + } +} diff --git a/apps/dav/lib/BackgroundJob/EventReminderJob.php b/apps/dav/lib/BackgroundJob/EventReminderJob.php index ab7dadd8c0b..0e21e06fc35 100644 --- a/apps/dav/lib/BackgroundJob/EventReminderJob.php +++ b/apps/dav/lib/BackgroundJob/EventReminderJob.php @@ -3,29 +3,14 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Thomas Citharel <nextcloud@tcit.fr> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\BackgroundJob; +use OC\User\NoUserException; +use OCA\DAV\CalDAV\Reminder\NotificationProvider\ProviderNotAvailableException; +use OCA\DAV\CalDAV\Reminder\NotificationTypeDoesNotExistException; use OCA\DAV\CalDAV\Reminder\ReminderService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; @@ -33,18 +18,12 @@ use OCP\IConfig; class EventReminderJob extends TimedJob { - /** @var ReminderService */ - private $reminderService; - - /** @var IConfig */ - private $config; - - public function __construct(ITimeFactory $time, - ReminderService $reminderService, - IConfig $config) { + public function __construct( + ITimeFactory $time, + private ReminderService $reminderService, + private IConfig $config, + ) { parent::__construct($time); - $this->reminderService = $reminderService; - $this->config = $config; // Run every 5 minutes $this->setInterval(5 * 60); @@ -52,12 +31,11 @@ class EventReminderJob extends TimedJob { } /** - * @param $arg - * @throws \OCA\DAV\CalDAV\Reminder\NotificationProvider\ProviderNotAvailableException - * @throws \OCA\DAV\CalDAV\Reminder\NotificationTypeDoesNotExistException - * @throws \OC\User\NoUserException + * @throws ProviderNotAvailableException + * @throws NotificationTypeDoesNotExistException + * @throws NoUserException */ - public function run($arg):void { + public function run($argument):void { if ($this->config->getAppValue('dav', 'sendEventReminders', 'yes') !== 'yes') { return; } diff --git a/apps/dav/lib/BackgroundJob/GenerateBirthdayCalendarBackgroundJob.php b/apps/dav/lib/BackgroundJob/GenerateBirthdayCalendarBackgroundJob.php index a338a172d66..6d94f4810ed 100644 --- a/apps/dav/lib/BackgroundJob/GenerateBirthdayCalendarBackgroundJob.php +++ b/apps/dav/lib/BackgroundJob/GenerateBirthdayCalendarBackgroundJob.php @@ -3,25 +3,8 @@ 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\BackgroundJob; @@ -32,29 +15,19 @@ use OCP\IConfig; class GenerateBirthdayCalendarBackgroundJob extends QueuedJob { - /** @var BirthdayService */ - private $birthdayService; - - /** @var IConfig */ - private $config; - - public function __construct(ITimeFactory $time, - BirthdayService $birthdayService, - IConfig $config) { + public function __construct( + ITimeFactory $time, + private BirthdayService $birthdayService, + private IConfig $config, + ) { parent::__construct($time); - - $this->birthdayService = $birthdayService; - $this->config = $config; } - /** - * @param array $arguments - */ - public function run($arguments) { - $userId = $arguments['userId']; - $purgeBeforeGenerating = $arguments['purgeBeforeGenerating'] ?? false; + public function run($argument) { + $userId = $argument['userId']; + $purgeBeforeGenerating = $argument['purgeBeforeGenerating'] ?? false; - // make sure admin didn't change his mind + // make sure admin didn't change their mind $isGloballyEnabled = $this->config->getAppValue('dav', 'generateBirthdayCalendar', 'yes'); if ($isGloballyEnabled !== 'yes') { return; diff --git a/apps/dav/lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php b/apps/dav/lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php new file mode 100644 index 00000000000..cc4fd5dce9d --- /dev/null +++ b/apps/dav/lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\BackgroundJob; + +use OCA\DAV\CalDAV\TimezoneService; +use OCA\DAV\Db\AbsenceMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUserManager; +use OCP\User\Events\OutOfOfficeEndedEvent; +use OCP\User\Events\OutOfOfficeStartedEvent; +use Psr\Log\LoggerInterface; + +class OutOfOfficeEventDispatcherJob extends QueuedJob { + public const EVENT_START = 'start'; + public const EVENT_END = 'end'; + + public function __construct( + ITimeFactory $time, + private AbsenceMapper $absenceMapper, + private LoggerInterface $logger, + private IEventDispatcher $eventDispatcher, + private IUserManager $userManager, + private TimezoneService $timezoneService, + ) { + parent::__construct($time); + } + + public function run($argument): void { + $id = $argument['id']; + $event = $argument['event']; + + try { + $absence = $this->absenceMapper->findById($id); + } catch (DoesNotExistException|\OCP\DB\Exception $e) { + $this->logger->error('Failed to dispatch out-of-office event: ' . $e->getMessage(), [ + 'exception' => $e, + 'argument' => $argument, + ]); + return; + } + + $userId = $absence->getUserId(); + $user = $this->userManager->get($userId); + if ($user === null) { + $this->logger->error("Failed to dispatch out-of-office event: User $userId does not exist", [ + 'argument' => $argument, + ]); + return; + } + + $data = $absence->toOutOufOfficeData( + $user, + $this->timezoneService->getUserTimezone($userId) ?? $this->timezoneService->getDefaultTimezone(), + ); + if ($event === self::EVENT_START) { + $this->eventDispatcher->dispatchTyped(new OutOfOfficeStartedEvent($data)); + } elseif ($event === self::EVENT_END) { + $this->eventDispatcher->dispatchTyped(new OutOfOfficeEndedEvent($data)); + } else { + $this->logger->error("Invalid out-of-office event: $event", [ + 'argument' => $argument, + ]); + } + } +} diff --git a/apps/dav/lib/BackgroundJob/PruneOutdatedSyncTokensJob.php b/apps/dav/lib/BackgroundJob/PruneOutdatedSyncTokensJob.php new file mode 100644 index 00000000000..8746588acc7 --- /dev/null +++ b/apps/dav/lib/BackgroundJob/PruneOutdatedSyncTokensJob.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\BackgroundJob; + +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CardDAV\CardDavBackend; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\IConfig; +use Psr\Log\LoggerInterface; + +class PruneOutdatedSyncTokensJob extends TimedJob { + + public function __construct( + ITimeFactory $timeFactory, + private CalDavBackend $calDavBackend, + private CardDavBackend $cardDavBackend, + private IConfig $config, + private LoggerInterface $logger, + ) { + parent::__construct($timeFactory); + $this->setInterval(60 * 60 * 24); // One day + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + public function run($argument) { + $limit = max(1, (int)$this->config->getAppValue(Application::APP_ID, 'totalNumberOfSyncTokensToKeep', '10000')); + $retention = max(7, (int)$this->config->getAppValue(Application::APP_ID, 'syncTokensRetentionDays', '60')) * 24 * 3600; + + $prunedCalendarSyncTokens = $this->calDavBackend->pruneOutdatedSyncTokens($limit, $retention); + $prunedAddressBookSyncTokens = $this->cardDavBackend->pruneOutdatedSyncTokens($limit, $retention); + + $this->logger->info('Pruned {calendarSyncTokensNumber} calendar sync tokens and {addressBooksSyncTokensNumber} address book sync tokens', [ + 'calendarSyncTokensNumber' => $prunedCalendarSyncTokens, + 'addressBooksSyncTokensNumber' => $prunedAddressBookSyncTokens + ]); + } +} diff --git a/apps/dav/lib/BackgroundJob/RefreshWebcalJob.php b/apps/dav/lib/BackgroundJob/RefreshWebcalJob.php index fbb944159fd..e96735ca50b 100644 --- a/apps/dav/lib/BackgroundJob/RefreshWebcalJob.php +++ b/apps/dav/lib/BackgroundJob/RefreshWebcalJob.php @@ -3,28 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\BackgroundJob; @@ -34,42 +14,18 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\BackgroundJob\Job; use OCP\IConfig; -use OCP\ILogger; +use Psr\Log\LoggerInterface; use Sabre\VObject\DateTimeParser; use Sabre\VObject\InvalidDataException; class RefreshWebcalJob extends Job { - - /** - * @var RefreshWebcalService - */ - private $refreshWebcalService; - - /** - * @var IConfig - */ - private $config; - - /** @var ILogger */ - private $logger; - - /** @var ITimeFactory */ - private $timeFactory; - - /** - * RefreshWebcalJob constructor. - * - * @param RefreshWebcalService $refreshWebcalService - * @param IConfig $config - * @param ILogger $logger - * @param ITimeFactory $timeFactory - */ - public function __construct(RefreshWebcalService $refreshWebcalService, IConfig $config, ILogger $logger, ITimeFactory $timeFactory) { + public function __construct( + private RefreshWebcalService $refreshWebcalService, + private IConfig $config, + private LoggerInterface $logger, + ITimeFactory $timeFactory, + ) { parent::__construct($timeFactory); - $this->refreshWebcalService = $refreshWebcalService; - $this->config = $config; - $this->logger = $logger; - $this->timeFactory = $timeFactory; } /** @@ -77,7 +33,7 @@ class RefreshWebcalJob extends Job { * * @inheritdoc */ - public function execute(IJobList $jobList, ILogger $logger = null) { + public function start(IJobList $jobList): void { $subscription = $this->refreshWebcalService->getSubscription($this->argument['principaluri'], $this->argument['uri']); if (!$subscription) { return; @@ -85,8 +41,8 @@ class RefreshWebcalJob extends Job { $this->fixSubscriptionRowTyping($subscription); - // if no refresh rate was configured, just refresh once a week - $defaultRefreshRate = $this->config->getAppValue('dav', 'calendarSubscriptionRefreshRate', 'P1W'); + // if no refresh rate was configured, just refresh once a day + $defaultRefreshRate = $this->config->getAppValue('dav', 'calendarSubscriptionRefreshRate', 'P1D'); $refreshRate = $subscription[RefreshWebcalService::REFRESH_RATE] ?? $defaultRefreshRate; $subscriptionId = $subscription['id']; @@ -95,17 +51,19 @@ class RefreshWebcalJob extends Job { /** @var DateInterval $dateInterval */ $dateInterval = DateTimeParser::parseDuration($refreshRate); } catch (InvalidDataException $ex) { - $this->logger->logException($ex); - $this->logger->warning("Subscription $subscriptionId could not be refreshed, refreshrate in database is invalid"); + $this->logger->error( + "Subscription $subscriptionId could not be refreshed, refreshrate in database is invalid", + ['exception' => $ex] + ); return; } $interval = $this->getIntervalFromDateInterval($dateInterval); - if (($this->timeFactory->getTime() - $this->lastRun) <= $interval) { + if (($this->time->getTime() - $this->lastRun) <= $interval) { return; } - parent::execute($jobList, $logger); + parent::start($jobList); } /** @@ -146,7 +104,7 @@ class RefreshWebcalJob extends Job { foreach ($forceInt as $column) { if (isset($row[$column])) { - $row[$column] = (int) $row[$column]; + $row[$column] = (int)$row[$column]; } } } diff --git a/apps/dav/lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php b/apps/dav/lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php index 85da81b3b91..7ec5b7fba79 100644 --- a/apps/dav/lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php +++ b/apps/dav/lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright 2019 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\BackgroundJob; @@ -35,12 +16,6 @@ use OCP\IUserManager; class RegisterRegenerateBirthdayCalendars extends QueuedJob { - /** @var IUserManager */ - private $userManager; - - /** @var IJobList */ - private $jobList; - /** * RegisterRegenerateBirthdayCalendars constructor. * @@ -48,19 +23,19 @@ class RegisterRegenerateBirthdayCalendars extends QueuedJob { * @param IUserManager $userManager * @param IJobList $jobList */ - public function __construct(ITimeFactory $time, - IUserManager $userManager, - IJobList $jobList) { + public function __construct( + ITimeFactory $time, + private IUserManager $userManager, + private IJobList $jobList, + ) { parent::__construct($time); - $this->userManager = $userManager; - $this->jobList = $jobList; } /** * @inheritDoc */ public function run($argument) { - $this->userManager->callForSeenUsers(function (IUser $user) { + $this->userManager->callForSeenUsers(function (IUser $user): void { $this->jobList->add(GenerateBirthdayCalendarBackgroundJob::class, [ 'userId' => $user->getUID(), 'purgeBeforeGenerating' => true diff --git a/apps/dav/lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php b/apps/dav/lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php index f7addd58248..b7688ea32d8 100644 --- a/apps/dav/lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php +++ b/apps/dav/lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php @@ -3,452 +3,32 @@ declare(strict_types=1); /** - * @copyright 2019, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\BackgroundJob; -use OCA\DAV\CalDAV\CalDavBackend; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; -use OCP\Calendar\BackendTemporarilyUnavailableException; -use OCP\Calendar\IMetadataProvider; -use OCP\Calendar\Resource\IBackend as IResourceBackend; use OCP\Calendar\Resource\IManager as IResourceManager; -use OCP\Calendar\Resource\IResource; use OCP\Calendar\Room\IManager as IRoomManager; -use OCP\Calendar\Room\IRoom; -use OCP\IDBConnection; class UpdateCalendarResourcesRoomsBackgroundJob extends TimedJob { - - /** @var IResourceManager */ - private $resourceManager; - - /** @var IRoomManager */ - private $roomManager; - - /** @var IDBConnection */ - private $dbConnection; - - /** @var CalDavBackend */ - private $calDavBackend; - - public function __construct(ITimeFactory $time, - IResourceManager $resourceManager, - IRoomManager $roomManager, - IDBConnection $dbConnection, - CalDavBackend $calDavBackend) { + public function __construct( + ITimeFactory $time, + private IResourceManager $resourceManager, + private IRoomManager $roomManager, + ) { parent::__construct($time); - $this->resourceManager = $resourceManager; - $this->roomManager = $roomManager; - $this->dbConnection = $dbConnection; - $this->calDavBackend = $calDavBackend; // Run once an hour $this->setInterval(60 * 60); $this->setTimeSensitivity(self::TIME_SENSITIVE); } - /** - * @param $argument - */ public function run($argument): void { - $this->runForBackend( - $this->resourceManager, - 'calendar_resources', - 'calendar_resources_md', - 'resource_id', - 'principals/calendar-resources' - ); - $this->runForBackend( - $this->roomManager, - 'calendar_rooms', - 'calendar_rooms_md', - 'room_id', - 'principals/calendar-rooms' - ); - } - - /** - * Run background-job for one specific backendManager - * either ResourceManager or RoomManager - * - * @param IResourceManager|IRoomManager $backendManager - * @param string $dbTable - * @param string $dbTableMetadata - * @param string $foreignKey - * @param string $principalPrefix - */ - private function runForBackend($backendManager, - string $dbTable, - string $dbTableMetadata, - string $foreignKey, - string $principalPrefix): void { - $backends = $backendManager->getBackends(); - - foreach ($backends as $backend) { - $backendId = $backend->getBackendIdentifier(); - - try { - if ($backend instanceof IResourceBackend) { - $list = $backend->listAllResources(); - } else { - $list = $backend->listAllRooms(); - } - } catch (BackendTemporarilyUnavailableException $ex) { - continue; - } - - $cachedList = $this->getAllCachedByBackend($dbTable, $backendId); - $newIds = array_diff($list, $cachedList); - $deletedIds = array_diff($cachedList, $list); - $editedIds = array_intersect($list, $cachedList); - - foreach ($newIds as $newId) { - try { - if ($backend instanceof IResourceBackend) { - $resource = $backend->getResource($newId); - } else { - $resource = $backend->getRoom($newId); - } - - $metadata = []; - if ($resource instanceof IMetadataProvider) { - $metadata = $this->getAllMetadataOfBackend($resource); - } - } catch (BackendTemporarilyUnavailableException $ex) { - continue; - } - - $id = $this->addToCache($dbTable, $backendId, $resource); - $this->addMetadataToCache($dbTableMetadata, $foreignKey, $id, $metadata); - // we don't create the calendar here, it is created lazily - // when an event is actually scheduled with this resource / room - } - - foreach ($deletedIds as $deletedId) { - $id = $this->getIdForBackendAndResource($dbTable, $backendId, $deletedId); - $this->deleteFromCache($dbTable, $id); - $this->deleteMetadataFromCache($dbTableMetadata, $foreignKey, $id); - - $principalName = implode('-', [$backendId, $deletedId]); - $this->deleteCalendarDataForResource($principalPrefix, $principalName); - } - - foreach ($editedIds as $editedId) { - $id = $this->getIdForBackendAndResource($dbTable, $backendId, $editedId); - - try { - if ($backend instanceof IResourceBackend) { - $resource = $backend->getResource($editedId); - } else { - $resource = $backend->getRoom($editedId); - } - - $metadata = []; - if ($resource instanceof IMetadataProvider) { - $metadata = $this->getAllMetadataOfBackend($resource); - } - } catch (BackendTemporarilyUnavailableException $ex) { - continue; - } - - $this->updateCache($dbTable, $id, $resource); - - if ($resource instanceof IMetadataProvider) { - $cachedMetadata = $this->getAllMetadataOfCache($dbTableMetadata, $foreignKey, $id); - $this->updateMetadataCache($dbTableMetadata, $foreignKey, $id, $metadata, $cachedMetadata); - } - } - } - } - - /** - * add entry to cache that exists remotely but not yet in cache - * - * @param string $table - * @param string $backendId - * @param IResource|IRoom $remote - * - * @return int Insert id - */ - private function addToCache(string $table, - string $backendId, - $remote): int { - $query = $this->dbConnection->getQueryBuilder(); - $query->insert($table) - ->values([ - 'backend_id' => $query->createNamedParameter($backendId), - 'resource_id' => $query->createNamedParameter($remote->getId()), - 'email' => $query->createNamedParameter($remote->getEMail()), - 'displayname' => $query->createNamedParameter($remote->getDisplayName()), - 'group_restrictions' => $query->createNamedParameter( - $this->serializeGroupRestrictions( - $remote->getGroupRestrictions() - )) - ]) - ->executeStatement(); - return $query->getLastInsertId(); - } - - /** - * @param string $table - * @param string $foreignKey - * @param int $foreignId - * @param array $metadata - */ - private function addMetadataToCache(string $table, - string $foreignKey, - int $foreignId, - array $metadata): void { - foreach ($metadata as $key => $value) { - $query = $this->dbConnection->getQueryBuilder(); - $query->insert($table) - ->values([ - $foreignKey => $query->createNamedParameter($foreignId), - 'key' => $query->createNamedParameter($key), - 'value' => $query->createNamedParameter($value), - ]) - ->executeStatement(); - } - } - - /** - * delete entry from cache that does not exist anymore remotely - * - * @param string $table - * @param int $id - */ - private function deleteFromCache(string $table, - int $id): void { - $query = $this->dbConnection->getQueryBuilder(); - $query->delete($table) - ->where($query->expr()->eq('id', $query->createNamedParameter($id))) - ->executeStatement(); - } - - /** - * @param string $table - * @param string $foreignKey - * @param int $id - */ - private function deleteMetadataFromCache(string $table, - string $foreignKey, - int $id): void { - $query = $this->dbConnection->getQueryBuilder(); - $query->delete($table) - ->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id))) - ->executeStatement(); - } - - /** - * update an existing entry in cache - * - * @param string $table - * @param int $id - * @param IResource|IRoom $remote - */ - private function updateCache(string $table, - int $id, - $remote): void { - $query = $this->dbConnection->getQueryBuilder(); - $query->update($table) - ->set('email', $query->createNamedParameter($remote->getEMail())) - ->set('displayname', $query->createNamedParameter($remote->getDisplayName())) - ->set('group_restrictions', $query->createNamedParameter( - $this->serializeGroupRestrictions( - $remote->getGroupRestrictions() - ))) - ->where($query->expr()->eq('id', $query->createNamedParameter($id))) - ->executeStatement(); - } - - /** - * @param string $dbTable - * @param string $foreignKey - * @param int $id - * @param array $metadata - * @param array $cachedMetadata - */ - private function updateMetadataCache(string $dbTable, - string $foreignKey, - int $id, - array $metadata, - array $cachedMetadata): void { - $newMetadata = array_diff_key($metadata, $cachedMetadata); - $deletedMetadata = array_diff_key($cachedMetadata, $metadata); - - foreach ($newMetadata as $key => $value) { - $query = $this->dbConnection->getQueryBuilder(); - $query->insert($dbTable) - ->values([ - $foreignKey => $query->createNamedParameter($id), - 'key' => $query->createNamedParameter($key), - 'value' => $query->createNamedParameter($value), - ]) - ->executeStatement(); - } - - foreach ($deletedMetadata as $key => $value) { - $query = $this->dbConnection->getQueryBuilder(); - $query->delete($dbTable) - ->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id))) - ->andWhere($query->expr()->eq('key', $query->createNamedParameter($key))) - ->executeStatement(); - } - - $existingKeys = array_keys(array_intersect_key($metadata, $cachedMetadata)); - foreach ($existingKeys as $existingKey) { - if ($metadata[$existingKey] !== $cachedMetadata[$existingKey]) { - $query = $this->dbConnection->getQueryBuilder(); - $query->update($dbTable) - ->set('value', $query->createNamedParameter($metadata[$existingKey])) - ->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id))) - ->andWhere($query->expr()->eq('key', $query->createNamedParameter($existingKey))) - ->executeStatement(); - } - } - } - - /** - * serialize array of group restrictions to store them in database - * - * @param array $groups - * - * @return string - */ - private function serializeGroupRestrictions(array $groups): string { - return \json_encode($groups); - } - - /** - * Gets all metadata of a backend - * - * @param IResource|IRoom $resource - * - * @return array - */ - private function getAllMetadataOfBackend($resource): array { - if (!($resource instanceof IMetadataProvider)) { - return []; - } - - $keys = $resource->getAllAvailableMetadataKeys(); - $metadata = []; - foreach ($keys as $key) { - $metadata[$key] = $resource->getMetadataForKey($key); - } - - return $metadata; - } - - /** - * @param string $table - * @param string $foreignKey - * @param int $id - * - * @return array - */ - private function getAllMetadataOfCache(string $table, - string $foreignKey, - int $id): array { - $query = $this->dbConnection->getQueryBuilder(); - $query->select(['key', 'value']) - ->from($table) - ->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id))); - $result = $query->executeQuery(); - $rows = $result->fetchAll(); - $result->closeCursor(); - - $metadata = []; - foreach ($rows as $row) { - $metadata[$row['key']] = $row['value']; - } - - return $metadata; - } - - /** - * Gets all cached rooms / resources by backend - * - * @param $tableName - * @param $backendId - * - * @return array - */ - private function getAllCachedByBackend(string $tableName, - string $backendId): array { - $query = $this->dbConnection->getQueryBuilder(); - $query->select('resource_id') - ->from($tableName) - ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId))); - $result = $query->executeQuery(); - $rows = $result->fetchAll(); - $result->closeCursor(); - - return array_map(function ($row): string { - return $row['resource_id']; - }, $rows); - } - - /** - * @param $principalPrefix - * @param $principalUri - */ - private function deleteCalendarDataForResource(string $principalPrefix, - string $principalUri): void { - $calendar = $this->calDavBackend->getCalendarByUri( - implode('/', [$principalPrefix, $principalUri]), - CalDavBackend::RESOURCE_BOOKING_CALENDAR_URI); - - if ($calendar !== null) { - $this->calDavBackend->deleteCalendar( - $calendar['id'], - true // Because this wasn't deleted by a user - ); - } - } - - /** - * @param $table - * @param $backendId - * @param $resourceId - * - * @return int - */ - private function getIdForBackendAndResource(string $table, - string $backendId, - string $resourceId): int { - $query = $this->dbConnection->getQueryBuilder(); - $query->select('id') - ->from($table) - ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId))) - ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId))); - $result = $query->executeQuery(); - - $id = (int) $result->fetchOne(); - $result->closeCursor(); - return $id; + $this->resourceManager->update(); + $this->roomManager->update(); } } diff --git a/apps/dav/lib/BackgroundJob/UploadCleanup.php b/apps/dav/lib/BackgroundJob/UploadCleanup.php index 76906becb54..230cde61578 100644 --- a/apps/dav/lib/BackgroundJob/UploadCleanup.php +++ b/apps/dav/lib/BackgroundJob/UploadCleanup.php @@ -3,56 +3,33 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\BackgroundJob; use OC\User\NoUserException; use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\IJob; use OCP\BackgroundJob\IJobList; use OCP\BackgroundJob\TimedJob; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; +use Psr\Log\LoggerInterface; class UploadCleanup extends TimedJob { - - /** @var IRootFolder */ - private $rootFolder; - - /** @var IJobList */ - private $jobList; - - public function __construct(ITimeFactory $time, IRootFolder $rootFolder, IJobList $jobList) { + public function __construct( + ITimeFactory $time, + private IRootFolder $rootFolder, + private IJobList $jobList, + private LoggerInterface $logger, + ) { parent::__construct($time); - $this->rootFolder = $rootFolder; - $this->jobList = $jobList; // Run once a day $this->setInterval(60 * 60 * 24); - $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); } protected function run($argument) { @@ -64,18 +41,27 @@ class UploadCleanup extends TimedJob { $userRoot = $userFolder->getParent(); /** @var Folder $uploads */ $uploads = $userRoot->get('uploads'); - /** @var Folder $uploadFolder */ $uploadFolder = $uploads->get($folder); - } catch (NotFoundException | NoUserException $e) { + } catch (NotFoundException|NoUserException $e) { $this->jobList->remove(self::class, $argument); return; } - $files = $uploadFolder->getDirectoryListing(); - // Remove if all files have an mtime of more than a day $time = $this->time->getTime() - 60 * 60 * 24; + if (!($uploadFolder instanceof Folder)) { + $this->logger->error('Found a file inside the uploads folder. Uid: ' . $uid . ' folder: ' . $folder); + if ($uploadFolder->getMTime() < $time) { + $uploadFolder->delete(); + } + $this->jobList->remove(self::class, $argument); + return; + } + + /** @var File[] $files */ + $files = $uploadFolder->getDirectoryListing(); + // The folder has to be more than a day old $initial = $uploadFolder->getMTime() < $time; diff --git a/apps/dav/lib/BackgroundJob/UserStatusAutomation.php b/apps/dav/lib/BackgroundJob/UserStatusAutomation.php new file mode 100644 index 00000000000..027b3349802 --- /dev/null +++ b/apps/dav/lib/BackgroundJob/UserStatusAutomation.php @@ -0,0 +1,243 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\BackgroundJob; + +use OCA\DAV\CalDAV\Schedule\Plugin; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\TimedJob; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IUser; +use OCP\IUserManager; +use OCP\User\IAvailabilityCoordinator; +use OCP\User\IOutOfOfficeData; +use OCP\UserStatus\IManager; +use OCP\UserStatus\IUserStatus; +use Psr\Log\LoggerInterface; +use Sabre\VObject\Component\Available; +use Sabre\VObject\Component\VAvailability; +use Sabre\VObject\Reader; +use Sabre\VObject\Recur\RRuleIterator; + +class UserStatusAutomation extends TimedJob { + public function __construct( + private ITimeFactory $timeFactory, + private IDBConnection $connection, + private IJobList $jobList, + private LoggerInterface $logger, + private IManager $manager, + private IConfig $config, + private IAvailabilityCoordinator $coordinator, + private IUserManager $userManager, + ) { + parent::__construct($timeFactory); + + // interval = 0 might look odd, but it's intentional. last_run is set to + // the user's next available time, so the job runs immediately when + // that time comes. + $this->setInterval(0); + } + + /** + * @inheritDoc + */ + protected function run($argument) { + if (!isset($argument['userId'])) { + $this->jobList->remove(self::class, $argument); + $this->logger->info('Removing invalid ' . self::class . ' background job'); + return; + } + + $userId = $argument['userId']; + $user = $this->userManager->get($userId); + if ($user === null) { + return; + } + + $ooo = $this->coordinator->getCurrentOutOfOfficeData($user); + + $continue = $this->processOutOfOfficeData($user, $ooo); + if ($continue === false) { + return; + } + + $property = $this->getAvailabilityFromPropertiesTable($userId); + $hasDndForOfficeHours = $this->config->getUserValue($userId, 'dav', 'user_status_automation', 'no') === 'yes'; + + if (!$property) { + // We found no ooo data and no availability settings, so we need to delete the job because there is no next runtime + $this->logger->info('Removing ' . self::class . ' background job for user "' . $userId . '" because the user has no valid availability rules and no OOO data set'); + $this->jobList->remove(self::class, $argument); + $this->manager->revertUserStatus($user->getUID(), IUserStatus::MESSAGE_AVAILABILITY, IUserStatus::DND); + $this->manager->revertUserStatus($user->getUID(), IUserStatus::MESSAGE_OUT_OF_OFFICE, IUserStatus::DND); + return; + } + + $this->processAvailability($property, $user->getUID(), $hasDndForOfficeHours); + } + + protected function setLastRunToNextToggleTime(string $userId, int $timestamp): void { + $query = $this->connection->getQueryBuilder(); + + $query->update('jobs') + ->set('last_run', $query->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('id', $query->createNamedParameter($this->getId(), IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + + $this->logger->debug('Updated user status automation last_run to ' . $timestamp . ' for user ' . $userId); + } + + /** + * @param string $userId + * @return false|string + */ + protected function getAvailabilityFromPropertiesTable(string $userId) { + $propertyPath = 'calendars/' . $userId . '/inbox'; + $propertyName = '{' . Plugin::NS_CALDAV . '}calendar-availability'; + + $query = $this->connection->getQueryBuilder(); + $query->select('propertyvalue') + ->from('properties') + ->where($query->expr()->eq('userid', $query->createNamedParameter($userId))) + ->andWhere($query->expr()->eq('propertypath', $query->createNamedParameter($propertyPath))) + ->andWhere($query->expr()->eq('propertyname', $query->createNamedParameter($propertyName))) + ->setMaxResults(1); + + $result = $query->executeQuery(); + $property = $result->fetchOne(); + $result->closeCursor(); + + return $property; + } + + /** + * @param string $property + * @param $userId + * @param $argument + * @return void + */ + private function processAvailability(string $property, string $userId, bool $hasDndForOfficeHours): void { + $isCurrentlyAvailable = false; + $nextPotentialToggles = []; + + $now = $this->time->getDateTime(); + $lastMidnight = (clone $now)->setTime(0, 0); + + $vObject = Reader::read($property); + foreach ($vObject->getComponents() as $component) { + if ($component->name !== 'VAVAILABILITY') { + continue; + } + /** @var VAvailability $component */ + $availables = $component->getComponents(); + foreach ($availables as $available) { + /** @var Available $available */ + if ($available->name === 'AVAILABLE') { + /** @var \DateTimeImmutable $originalStart */ + /** @var \DateTimeImmutable $originalEnd */ + [$originalStart, $originalEnd] = $available->getEffectiveStartEnd(); + + // Little shenanigans to fix the automation on the day the rules were adjusted + // Otherwise the $originalStart would match rules for Thursdays on a Friday, etc. + // So we simply wind back a week and then fastForward to the next occurrence + // since today's midnight, which then also accounts for the week days. + $effectiveStart = \DateTime::createFromImmutable($originalStart)->sub(new \DateInterval('P7D')); + $effectiveEnd = \DateTime::createFromImmutable($originalEnd)->sub(new \DateInterval('P7D')); + + try { + $it = new RRuleIterator((string)$available->RRULE, $effectiveStart); + $it->fastForward($lastMidnight); + + $startToday = $it->current(); + if ($startToday && $startToday <= $now) { + $duration = $effectiveStart->diff($effectiveEnd); + $endToday = $startToday->add($duration); + if ($endToday > $now) { + // User is currently available + // Also queuing the end time as next status toggle + $isCurrentlyAvailable = true; + $nextPotentialToggles[] = $endToday->getTimestamp(); + } + + // Availability enabling already done for today, + // so jump to the next recurrence to find the next status toggle + $it->next(); + } + + if ($it->current()) { + $nextPotentialToggles[] = $it->current()->getTimestamp(); + } + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } + } + } + + if (empty($nextPotentialToggles)) { + $this->logger->info('Removing ' . self::class . ' background job for user "' . $userId . '" because the user has no valid availability rules set'); + $this->jobList->remove(self::class, ['userId' => $userId]); + $this->manager->revertUserStatus($userId, IUserStatus::MESSAGE_AVAILABILITY, IUserStatus::DND); + return; + } + + $nextAutomaticToggle = min($nextPotentialToggles); + $this->setLastRunToNextToggleTime($userId, $nextAutomaticToggle - 1); + + if ($isCurrentlyAvailable) { + $this->logger->debug('User is currently available, reverting DND status if applicable'); + $this->manager->revertUserStatus($userId, IUserStatus::MESSAGE_AVAILABILITY, IUserStatus::DND); + $this->logger->debug('User status automation ran'); + return; + } + + if (!$hasDndForOfficeHours) { + // Office hours are not set to DND, so there is nothing to do. + return; + } + + $this->logger->debug('User is currently NOT available, reverting call and meeting status if applicable and then setting DND'); + $this->manager->setUserStatus($userId, IUserStatus::MESSAGE_AVAILABILITY, IUserStatus::DND, true); + $this->logger->debug('User status automation ran'); + } + + private function processOutOfOfficeData(IUser $user, ?IOutOfOfficeData $ooo): bool { + if (empty($ooo)) { + // Reset the user status if the absence doesn't exist + $this->logger->debug('User has no OOO period in effect, reverting DND status if applicable'); + $this->manager->revertUserStatus($user->getUID(), IUserStatus::MESSAGE_OUT_OF_OFFICE, IUserStatus::DND); + // We need to also run the availability automation + return true; + } + + if (!$this->coordinator->isInEffect($ooo)) { + // Reset the user status if the absence is (no longer) in effect + $this->logger->debug('User has no OOO period in effect, reverting DND status if applicable'); + $this->manager->revertUserStatus($user->getUID(), IUserStatus::MESSAGE_OUT_OF_OFFICE, IUserStatus::DND); + + if ($ooo->getStartDate() > $this->time->getTime()) { + // Set the next run to take place at the start of the ooo period if it is in the future + // This might be overwritten if there is an availability setting, but we can't determine + // if this is the case here + $this->setLastRunToNextToggleTime($user->getUID(), $ooo->getStartDate()); + } + return true; + } + + $this->logger->debug('User is currently in an OOO period, reverting other automated status and setting OOO DND status'); + $this->manager->setUserStatus($user->getUID(), IUserStatus::MESSAGE_OUT_OF_OFFICE, IUserStatus::DND, true, $ooo->getShortMessage()); + + // Run at the end of an ooo period to return to availability / regular user status + // If it's overwritten by a custom status in the meantime, there's nothing we can do about it + $this->setLastRunToNextToggleTime($user->getUID(), $ooo->getEndDate()); + return false; + } +} diff --git a/apps/dav/lib/BulkUpload/BulkUploadPlugin.php b/apps/dav/lib/BulkUpload/BulkUploadPlugin.php index bb6baf48b56..d4faf3764e1 100644 --- a/apps/dav/lib/BulkUpload/BulkUploadPlugin.php +++ b/apps/dav/lib/BulkUpload/BulkUploadPlugin.php @@ -1,47 +1,27 @@ <?php + /** - * @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me> - * - * @author Louis Chemineau <louis@chmn.me> - * - * @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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\BulkUpload; +use OCA\DAV\Connector\Sabre\MtimeSanitizer; +use OCP\AppFramework\Http; +use OCP\Files\DavUtil; +use OCP\Files\Folder; use Psr\Log\LoggerInterface; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; -use OCP\Files\Folder; -use OCP\AppFramework\Http; -use OCA\DAV\Connector\Sabre\MtimeSanitizer; class BulkUploadPlugin extends ServerPlugin { - - /** @var Folder */ - private $userFolder; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(Folder $userFolder, LoggerInterface $logger) { - $this->userFolder = $userFolder; - $this->logger = $logger; + public function __construct( + private Folder $userFolder, + private LoggerInterface $logger, + ) { } /** @@ -60,11 +40,11 @@ class BulkUploadPlugin extends ServerPlugin { */ public function httpPost(RequestInterface $request, ResponseInterface $response): bool { // Limit bulk upload to the /dav/bulk endpoint - if ($request->getPath() !== "bulk") { + if ($request->getPath() !== 'bulk') { return true; } - $multiPartParser = new MultipartRequestParser($request); + $multiPartParser = new MultipartRequestParser($request, $this->logger); $writtenFiles = []; while (!$multiPartParser->isAtLastBoundary()) { @@ -74,7 +54,7 @@ class BulkUploadPlugin extends ServerPlugin { // Return early if an error occurs during parsing. $this->logger->error($e->getMessage()); $response->setStatus(Http::STATUS_BAD_REQUEST); - $response->setBody(json_encode($writtenFiles)); + $response->setBody(json_encode($writtenFiles, JSON_THROW_ON_ERROR)); return false; } @@ -90,22 +70,25 @@ class BulkUploadPlugin extends ServerPlugin { $node = $this->userFolder->newFile($headers['x-file-path'], $content); $node->touch($mtime); + $node = $this->userFolder->getFirstNodeById($node->getId()); $writtenFiles[$headers['x-file-path']] = [ - "error" => false, - "etag" => $node->getETag(), + 'error' => false, + 'etag' => $node->getETag(), + 'fileid' => DavUtil::getDavFileId($node->getId()), + 'permissions' => DavUtil::getDavPermissions($node), ]; } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['path' => $headers['x-file-path']]); $writtenFiles[$headers['x-file-path']] = [ - "error" => true, - "message" => $e->getMessage(), + 'error' => true, + 'message' => $e->getMessage(), ]; } } $response->setStatus(Http::STATUS_OK); - $response->setBody(json_encode($writtenFiles)); + $response->setBody(json_encode($writtenFiles, JSON_THROW_ON_ERROR)); return false; } diff --git a/apps/dav/lib/BulkUpload/MultipartRequestParser.php b/apps/dav/lib/BulkUpload/MultipartRequestParser.php index 7554447fc93..50f8cff76ba 100644 --- a/apps/dav/lib/BulkUpload/MultipartRequestParser.php +++ b/apps/dav/lib/BulkUpload/MultipartRequestParser.php @@ -1,32 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me> - * - * @author Louis Chemineau <louis@chmn.me> - * - * @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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\BulkUpload; -use Sabre\HTTP\RequestInterface; +use OCP\AppFramework\Http; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception; use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\LengthRequired; -use OCP\AppFramework\Http; +use Sabre\HTTP\RequestInterface; class MultipartRequestParser { @@ -34,15 +20,18 @@ class MultipartRequestParser { private $stream; /** @var string */ - private $boundary = ""; + private $boundary = ''; /** @var string */ - private $lastBoundary = ""; + private $lastBoundary = ''; /** * @throws BadRequest */ - public function __construct(RequestInterface $request) { + public function __construct( + RequestInterface $request, + protected LoggerInterface $logger, + ) { $stream = $request->getBody(); $contentType = $request->getHeader('Content-Type'); @@ -51,14 +40,14 @@ class MultipartRequestParser { } if ($contentType === null) { - throw new BadRequest("Content-Type can not be null"); + throw new BadRequest('Content-Type can not be null'); } $this->stream = $stream; $boundary = $this->parseBoundaryFromHeaders($contentType); - $this->boundary = '--'.$boundary."\r\n"; - $this->lastBoundary = '--'.$boundary."--\r\n"; + $this->boundary = '--' . $boundary . "\r\n"; + $this->lastBoundary = '--' . $boundary . "--\r\n"; } /** @@ -69,16 +58,22 @@ class MultipartRequestParser { */ private function parseBoundaryFromHeaders(string $contentType): string { try { + if (!str_contains($contentType, ';')) { + throw new \InvalidArgumentException('No semicolon in header'); + } [$mimeType, $boundary] = explode(';', $contentType); + if (!str_contains($boundary, '=')) { + throw new \InvalidArgumentException('No equal in boundary header'); + } [$boundaryKey, $boundaryValue] = explode('=', $boundary); } catch (\Exception $e) { - throw new BadRequest("Error while parsing boundary in Content-Type header.", Http::STATUS_BAD_REQUEST, $e); + throw new BadRequest('Error while parsing boundary in Content-Type header.', Http::STATUS_BAD_REQUEST, $e); } $boundaryValue = trim($boundaryValue); // Remove potential quotes around boundary value. - if (substr($boundaryValue, 0, 1) == '"' && substr($boundaryValue, -1) == '"') { + if (str_starts_with($boundaryValue, '"') && str_ends_with($boundaryValue, '"')) { $boundaryValue = substr($boundaryValue, 1, -1); } @@ -108,7 +103,7 @@ class MultipartRequestParser { $seekBackResult = fseek($this->stream, -$expectedContentLength, SEEK_CUR); if ($seekBackResult === -1) { - throw new Exception("Unknown error while seeking content", Http::STATUS_INTERNAL_SERVER_ERROR); + throw new Exception('Unknown error while seeking content', Http::STATUS_INTERNAL_SERVER_ERROR); } return $expectedContent === $content; @@ -146,7 +141,10 @@ class MultipartRequestParser { $headers = $this->readPartHeaders(); - $content = $this->readPartContent($headers["content-length"], $headers["x-file-md5"]); + $length = (int)$headers['content-length']; + + $this->validateHash($length, $headers['x-file-md5'] ?? '', $headers['oc-checksum'] ?? ''); + $content = $this->readPartContent($length); return [$headers, $content]; } @@ -158,7 +156,7 @@ class MultipartRequestParser { */ private function readBoundary(): string { if (!$this->isAtBoundary()) { - throw new BadRequest("Boundary not found where it should be."); + throw new BadRequest('Boundary not found where it should be.'); } return fread($this->stream, strlen($this->boundary)); @@ -179,6 +177,11 @@ class MultipartRequestParser { throw new Exception('An error occurred while reading headers of a part'); } + if (!str_contains($line, ':')) { + $this->logger->error('Header missing ":" on bulk request: ' . json_encode($line)); + throw new Exception('An error occurred while reading headers of a part', Http::STATUS_BAD_REQUEST); + } + try { [$key, $value] = explode(':', $line, 2); $headers[strtolower(trim($key))] = trim($value); @@ -187,12 +190,13 @@ class MultipartRequestParser { } } - if (!isset($headers["content-length"])) { - throw new LengthRequired("The Content-Length header must not be null."); + if (!isset($headers['content-length'])) { + throw new LengthRequired('The Content-Length header must not be null.'); } - if (!isset($headers["x-file-md5"])) { - throw new BadRequest("The X-File-MD5 header must not be null."); + // TODO: Drop $md5 condition when the latest desktop client that uses it is no longer supported. + if (!isset($headers['x-file-md5']) && !isset($headers['oc-checksum'])) { + throw new BadRequest('The hash headers must not be null.'); } return $headers; @@ -204,21 +208,19 @@ class MultipartRequestParser { * @throws Exception * @throws BadRequest */ - private function readPartContent(int $length, string $md5): string { - $computedMd5 = $this->computeMd5Hash($length); - - if ($md5 !== $computedMd5) { - throw new BadRequest("Computed md5 hash is incorrect."); + private function readPartContent(int $length): string { + if ($length === 0) { + $content = ''; + } else { + $content = stream_get_line($this->stream, $length); } - $content = stream_get_line($this->stream, $length); - if ($content === false) { throw new Exception("Fail to read part's content."); } - if (feof($this->stream)) { - throw new Exception("Unexpected EOF while reading stream."); + if ($length !== 0 && feof($this->stream)) { + throw new Exception('Unexpected EOF while reading stream.'); } // Read '\r\n'. @@ -228,12 +230,25 @@ class MultipartRequestParser { } /** - * Compute the MD5 hash of the next x bytes. + * Compute the MD5 or checksum hash of the next x bytes. + * TODO: Drop $md5 argument when the latest desktop client that uses it is no longer supported. */ - private function computeMd5Hash(int $length): string { - $context = hash_init('md5'); + private function validateHash(int $length, string $fileMd5Header, string $checksumHeader): void { + if ($checksumHeader !== '') { + [$algorithm, $hash] = explode(':', $checksumHeader, 2); + } elseif ($fileMd5Header !== '') { + $algorithm = 'md5'; + $hash = $fileMd5Header; + } else { + throw new BadRequest('No hash provided.'); + } + + $context = hash_init($algorithm); hash_update_stream($context, $this->stream, $length); fseek($this->stream, -$length, SEEK_CUR); - return hash_final($context); + $computedHash = hash_final($context); + if ($hash !== $computedHash) { + throw new BadRequest("Computed $algorithm hash is incorrect ($computedHash)."); + } } } diff --git a/apps/dav/lib/CalDAV/Activity/Backend.php b/apps/dav/lib/CalDAV/Activity/Backend.php index 84ba50b8c37..f0c49e6e28c 100644 --- a/apps/dav/lib/CalDAV/Activity/Backend.php +++ b/apps/dav/lib/CalDAV/Activity/Backend.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity; @@ -34,6 +15,7 @@ use OCP\App\IAppManager; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; +use OCP\IUserManager; use OCP\IUserSession; use Sabre\VObject\Reader; @@ -44,23 +26,13 @@ use Sabre\VObject\Reader; */ class Backend { - /** @var IActivityManager */ - protected $activityManager; - - /** @var IGroupManager */ - protected $groupManager; - - /** @var IUserSession */ - protected $userSession; - - /** @var IAppManager */ - protected $appManager; - - public function __construct(IActivityManager $activityManager, IGroupManager $groupManager, IUserSession $userSession, IAppManager $appManager) { - $this->activityManager = $activityManager; - $this->groupManager = $groupManager; - $this->userSession = $userSession; - $this->appManager = $appManager; + public function __construct( + protected IActivityManager $activityManager, + protected IGroupManager $groupManager, + protected IUserSession $userSession, + protected IAppManager $appManager, + protected IUserManager $userManager, + ) { } /** @@ -119,7 +91,7 @@ class Backend { * @param array $calendarData * @param bool $publishStatus */ - public function onCalendarPublication(array $calendarData, $publishStatus) { + public function onCalendarPublication(array $calendarData, bool $publishStatus): void { $this->triggerCalendarActivity($publishStatus ? Calendar::SUBJECT_PUBLISH : Calendar::SUBJECT_UNPUBLISH, $calendarData); } @@ -148,7 +120,7 @@ class Backend { $event = $this->activityManager->generateEvent(); $event->setApp('dav') - ->setObject('calendar', (int) $calendarData['id']) + ->setObject('calendar', (int)$calendarData['id']) ->setType('calendar') ->setAuthor($currentUser); @@ -165,13 +137,18 @@ class Backend { } foreach ($users as $user) { + if ($action === Calendar::SUBJECT_DELETE && !$this->userManager->userExists($user)) { + // Avoid creating calendar_delete activities for deleted users + continue; + } + $event->setAffectedUser($user) ->setSubject( $user === $currentUser ? $action . '_self' : $action, [ 'actor' => $currentUser, 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -202,7 +179,7 @@ class Backend { $event = $this->activityManager->generateEvent(); $event->setApp('dav') - ->setObject('calendar', (int) $calendarData['id']) + ->setObject('calendar', (int)$calendarData['id']) ->setType('calendar') ->setAuthor($currentUser); @@ -227,7 +204,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -256,7 +233,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -298,7 +275,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -325,7 +302,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -403,7 +380,7 @@ class Backend { [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $properties['id'], + 'id' => (int)$properties['id'], 'uri' => $properties['uri'], 'name' => $properties['{DAV:}displayname'], ], @@ -438,17 +415,22 @@ class Backend { $classification = $objectData['classification'] ?? CalDavBackend::CLASSIFICATION_PUBLIC; $object = $this->getObjectNameAndType($objectData); + + if (!$object) { + return; + } + $action = $action . '_' . $object['type']; - if ($object['type'] === 'todo' && strpos($action, Event::SUBJECT_OBJECT_UPDATE) === 0 && $object['status'] === 'COMPLETED') { + if ($object['type'] === 'todo' && str_starts_with($action, Event::SUBJECT_OBJECT_UPDATE) && $object['status'] === 'COMPLETED') { $action .= '_completed'; - } elseif ($object['type'] === 'todo' && strpos($action, Event::SUBJECT_OBJECT_UPDATE) === 0 && $object['status'] === 'NEEDS-ACTION') { + } elseif ($object['type'] === 'todo' && str_starts_with($action, Event::SUBJECT_OBJECT_UPDATE) && $object['status'] === 'NEEDS-ACTION') { $action .= '_needs_action'; } $event = $this->activityManager->generateEvent(); $event->setApp('dav') - ->setObject('calendar', (int) $calendarData['id']) + ->setObject('calendar', (int)$calendarData['id']) ->setType($object['type'] === 'event' ? 'calendar_event' : 'calendar_todo') ->setAuthor($currentUser); @@ -465,7 +447,7 @@ class Backend { $params = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -476,7 +458,7 @@ class Backend { ], ]; - if ($object['type'] === 'event' && strpos($action, Event::SUBJECT_OBJECT_DELETE) === false && $this->appManager->isEnabledForUser('calendar')) { + if ($object['type'] === 'event' && !str_contains($action, Event::SUBJECT_OBJECT_DELETE) && $this->appManager->isEnabledForUser('calendar')) { $params['object']['link']['object_uri'] = $objectData['uri']; $params['object']['link']['calendar_uri'] = $calendarData['uri']; $params['object']['link']['owner'] = $owner; @@ -494,8 +476,103 @@ class Backend { } /** + * Creates activities when a calendar object was moved + */ + public function onMovedCalendarObject(array $sourceCalendarData, array $targetCalendarData, array $sourceShares, array $targetShares, array $objectData): void { + if (!isset($targetCalendarData['principaluri'])) { + return; + } + + $sourcePrincipal = explode('/', $sourceCalendarData['principaluri']); + $sourceOwner = array_pop($sourcePrincipal); + + $targetPrincipal = explode('/', $targetCalendarData['principaluri']); + $targetOwner = array_pop($targetPrincipal); + + if ($sourceOwner !== $targetOwner) { + $this->onTouchCalendarObject( + Event::SUBJECT_OBJECT_DELETE, + $sourceCalendarData, + $sourceShares, + $objectData + ); + $this->onTouchCalendarObject( + Event::SUBJECT_OBJECT_ADD, + $targetCalendarData, + $targetShares, + $objectData + ); + return; + } + + $currentUser = $this->userSession->getUser(); + if ($currentUser instanceof IUser) { + $currentUser = $currentUser->getUID(); + } else { + $currentUser = $targetOwner; + } + + $classification = $objectData['classification'] ?? CalDavBackend::CLASSIFICATION_PUBLIC; + $object = $this->getObjectNameAndType($objectData); + + if (!$object) { + return; + } + + $event = $this->activityManager->generateEvent(); + $event->setApp('dav') + ->setObject('calendar', (int)$targetCalendarData['id']) + ->setType($object['type'] === 'event' ? 'calendar_event' : 'calendar_todo') + ->setAuthor($currentUser); + + $users = $this->getUsersForShares(array_intersect($sourceShares, $targetShares)); + $users[] = $targetOwner; + + // Users for share can return the owner itself if the calendar is published + foreach (array_unique($users) as $user) { + if ($classification === CalDavBackend::CLASSIFICATION_PRIVATE && $user !== $targetOwner) { + // Private events are only shown to the owner + continue; + } + + $params = [ + 'actor' => $event->getAuthor(), + 'sourceCalendar' => [ + 'id' => (int)$sourceCalendarData['id'], + 'uri' => $sourceCalendarData['uri'], + 'name' => $sourceCalendarData['{DAV:}displayname'], + ], + 'targetCalendar' => [ + 'id' => (int)$targetCalendarData['id'], + 'uri' => $targetCalendarData['uri'], + 'name' => $targetCalendarData['{DAV:}displayname'], + ], + 'object' => [ + 'id' => $object['id'], + 'name' => $classification === CalDavBackend::CLASSIFICATION_CONFIDENTIAL && $user !== $targetOwner ? 'Busy' : $object['name'], + 'classified' => $classification === CalDavBackend::CLASSIFICATION_CONFIDENTIAL && $user !== $targetOwner, + ], + ]; + + if ($object['type'] === 'event' && $this->appManager->isEnabledForUser('calendar')) { + $params['object']['link']['object_uri'] = $objectData['uri']; + $params['object']['link']['calendar_uri'] = $targetCalendarData['uri']; + $params['object']['link']['owner'] = $targetOwner; + } + + $event->setAffectedUser($user) + ->setSubject( + $user === $currentUser ? Event::SUBJECT_OBJECT_MOVE . '_' . $object['type'] . '_self' : Event::SUBJECT_OBJECT_MOVE . '_' . $object['type'], + $params + ); + + $this->activityManager->publish($event); + } + } + + /** * @param array $objectData - * @return string[]|bool + * @return string[]|false */ protected function getObjectNameAndType(array $objectData) { $vObject = Reader::read($objectData['calendardata']); @@ -513,9 +590,9 @@ class Backend { } if ($componentType === 'VEVENT') { - return ['id' => (string) $component->UID, 'name' => (string) $component->SUMMARY, 'type' => 'event']; + return ['id' => (string)$component->UID, 'name' => (string)$component->SUMMARY, 'type' => 'event']; } - return ['id' => (string) $component->UID, 'name' => (string) $component->SUMMARY, 'type' => 'todo', 'status' => (string) $component->STATUS]; + return ['id' => (string)$component->UID, 'name' => (string)$component->SUMMARY, 'type' => 'todo', 'status' => (string)$component->STATUS]; } /** diff --git a/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php b/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php index 06258e3cf74..78579ee84b7 100644 --- a/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Filter; @@ -29,15 +12,10 @@ use OCP\IURLGenerator; class Calendar implements IFilter { - /** @var IL10N */ - protected $l; - - /** @var IURLGenerator */ - protected $url; - - public function __construct(IL10N $l, IURLGenerator $url) { - $this->l = $l; - $this->url = $url; + public function __construct( + protected IL10N $l, + protected IURLGenerator $url, + ) { } /** @@ -58,8 +36,8 @@ class Calendar implements IFilter { /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. * @since 11.0.0 */ public function getPriority() { diff --git a/apps/dav/lib/CalDAV/Activity/Filter/Todo.php b/apps/dav/lib/CalDAV/Activity/Filter/Todo.php index f727c10befe..b001f90c28d 100644 --- a/apps/dav/lib/CalDAV/Activity/Filter/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Filter/Todo.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Filter; @@ -28,15 +12,10 @@ use OCP\IURLGenerator; class Todo implements IFilter { - /** @var IL10N */ - protected $l; - - /** @var IURLGenerator */ - protected $url; - - public function __construct(IL10N $l, IURLGenerator $url) { - $this->l = $l; - $this->url = $url; + public function __construct( + protected IL10N $l, + protected IURLGenerator $url, + ) { } /** @@ -52,13 +31,13 @@ class Todo implements IFilter { * @since 11.0.0 */ public function getName() { - return $this->l->t('Todos'); + return $this->l->t('Tasks'); } /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. * @since 11.0.0 */ public function getPriority() { diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Base.php b/apps/dav/lib/CalDAV/Activity/Provider/Base.php index 7f70980a72b..558abe0ca1a 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Base.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Base.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Provider; @@ -30,51 +13,26 @@ use OCP\IGroup; use OCP\IGroupManager; use OCP\IL10N; use OCP\IURLGenerator; -use OCP\IUser; use OCP\IUserManager; abstract class Base implements IProvider { - - /** @var IUserManager */ - protected $userManager; - - /** @var string[] */ - protected $userDisplayNames = []; - - /** @var IGroupManager */ - protected $groupManager; - /** @var string[] */ protected $groupDisplayNames = []; - /** @var IURLGenerator */ - protected $url; - /** * @param IUserManager $userManager * @param IGroupManager $groupManager - * @param IURLGenerator $urlGenerator + * @param IURLGenerator $url */ - public function __construct(IUserManager $userManager, IGroupManager $groupManager, IURLGenerator $urlGenerator) { - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->url = $urlGenerator; + public function __construct( + protected IUserManager $userManager, + protected IGroupManager $groupManager, + protected IURLGenerator $url, + ) { } - /** - * @param IEvent $event - * @param string $subject - * @param array $parameters - */ - protected function setSubjects(IEvent $event, $subject, array $parameters) { - $placeholders = $replacements = []; - foreach ($parameters as $placeholder => $parameter) { - $placeholders[] = '{' . $placeholder . '}'; - $replacements[] = $parameter['name']; - } - - $event->setParsedSubject(str_replace($placeholders, $replacements, $subject)) - ->setRichSubject($subject, $parameters); + protected function setSubjects(IEvent $event, string $subject, array $parameters): void { + $event->setRichSubject($subject, $parameters); } /** @@ -83,18 +41,18 @@ abstract class Base implements IProvider { * @return array */ protected function generateCalendarParameter($data, IL10N $l) { - if ($data['uri'] === CalDavBackend::PERSONAL_CALENDAR_URI && - $data['name'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { + if ($data['uri'] === CalDavBackend::PERSONAL_CALENDAR_URI + && $data['name'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { return [ 'type' => 'calendar', - 'id' => $data['id'], + 'id' => (string)$data['id'], 'name' => $l->t('Personal'), ]; } return [ 'type' => 'calendar', - 'id' => $data['id'], + 'id' => (string)$data['id'], 'name' => $data['name'], ]; } @@ -107,40 +65,20 @@ abstract class Base implements IProvider { protected function generateLegacyCalendarParameter($id, $name) { return [ 'type' => 'calendar', - 'id' => $id, + 'id' => (string)$id, 'name' => $name, ]; } - /** - * @param string $uid - * @return array - */ - protected function generateUserParameter($uid) { - if (!isset($this->userDisplayNames[$uid])) { - $this->userDisplayNames[$uid] = $this->getUserDisplayName($uid); - } - + protected function generateUserParameter(string $uid): array { return [ 'type' => 'user', 'id' => $uid, - 'name' => $this->userDisplayNames[$uid], + 'name' => $this->userManager->getDisplayName($uid) ?? $uid, ]; } /** - * @param string $uid - * @return string - */ - protected function getUserDisplayName($uid) { - $user = $this->userManager->get($uid); - if ($user instanceof IUser) { - return $user->getDisplayName(); - } - return $uid; - } - - /** * @param string $gid * @return array */ diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php index daab7806e46..8c93ddae431 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php @@ -1,31 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Provider; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; use OCP\Activity\IEventMerger; use OCP\Activity\IManager; @@ -48,18 +29,9 @@ class Calendar extends Base { public const SUBJECT_UNSHARE_USER = 'calendar_user_unshare'; public const SUBJECT_UNSHARE_GROUP = 'calendar_group_unshare'; - /** @var IFactory */ - protected $languageFactory; - /** @var IL10N */ protected $l; - /** @var IManager */ - protected $activityManager; - - /** @var IEventMerger */ - protected $eventMerger; - /** * @param IFactory $languageFactory * @param IURLGenerator $url @@ -68,11 +40,15 @@ class Calendar extends Base { * @param IGroupManager $groupManager * @param IEventMerger $eventMerger */ - public function __construct(IFactory $languageFactory, IURLGenerator $url, IManager $activityManager, IUserManager $userManager, IGroupManager $groupManager, IEventMerger $eventMerger) { + public function __construct( + protected IFactory $languageFactory, + IURLGenerator $url, + protected IManager $activityManager, + IUserManager $userManager, + IGroupManager $groupManager, + protected IEventMerger $eventMerger, + ) { parent::__construct($userManager, $groupManager, $url); - $this->languageFactory = $languageFactory; - $this->activityManager = $activityManager; - $this->eventMerger = $eventMerger; } /** @@ -80,12 +56,12 @@ class Calendar extends Base { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parse($language, IEvent $event, IEvent $previousEvent = null) { + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { if ($event->getApp() !== 'dav' || $event->getType() !== 'calendar') { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $this->l = $this->languageFactory->get('dav', $language); @@ -143,7 +119,7 @@ class Calendar extends Base { } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_by') { $subject = $this->l->t('{actor} unshared calendar {calendar} from group {group}'); } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $parsedParameters = $this->getParameters($event); diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Event.php b/apps/dav/lib/CalDAV/Activity/Provider/Event.php index 96366f54942..87551d7840b 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Event.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Event.php @@ -1,32 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Provider; -use OC_App; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; use OCP\Activity\IEventMerger; use OCP\Activity\IManager; @@ -40,25 +20,14 @@ use OCP\L10N\IFactory; class Event extends Base { public const SUBJECT_OBJECT_ADD = 'object_add'; public const SUBJECT_OBJECT_UPDATE = 'object_update'; + public const SUBJECT_OBJECT_MOVE = 'object_move'; public const SUBJECT_OBJECT_MOVE_TO_TRASH = 'object_move_to_trash'; public const SUBJECT_OBJECT_RESTORE = 'object_restore'; public const SUBJECT_OBJECT_DELETE = 'object_delete'; - /** @var IFactory */ - protected $languageFactory; - /** @var IL10N */ protected $l; - /** @var IManager */ - protected $activityManager; - - /** @var IEventMerger */ - protected $eventMerger; - - /** @var IAppManager */ - protected $appManager; - /** * @param IFactory $languageFactory * @param IURLGenerator $url @@ -68,19 +37,23 @@ class Event extends Base { * @param IEventMerger $eventMerger * @param IAppManager $appManager */ - public function __construct(IFactory $languageFactory, IURLGenerator $url, IManager $activityManager, IUserManager $userManager, IGroupManager $groupManager, IEventMerger $eventMerger, IAppManager $appManager) { + public function __construct( + protected IFactory $languageFactory, + IURLGenerator $url, + protected IManager $activityManager, + IUserManager $userManager, + IGroupManager $groupManager, + protected IEventMerger $eventMerger, + protected IAppManager $appManager, + ) { parent::__construct($userManager, $groupManager, $url); - $this->languageFactory = $languageFactory; - $this->activityManager = $activityManager; - $this->eventMerger = $eventMerger; - $this->appManager = $appManager; } /** * @param array $eventData * @return array */ - protected function generateObjectParameter(array $eventData) { + protected function generateObjectParameter(array $eventData, string $affectedUser): array { if (!isset($eventData['id']) || !isset($eventData['name'])) { throw new \InvalidArgumentException(); } @@ -88,23 +61,27 @@ class Event extends Base { $params = [ 'type' => 'calendar-event', 'id' => $eventData['id'], - 'name' => $eventData['name'], - + 'name' => trim($eventData['name']) !== '' ? $eventData['name'] : $this->l->t('Untitled event'), ]; + if (isset($eventData['link']) && is_array($eventData['link']) && $this->appManager->isEnabledForUser('calendar')) { try { // The calendar app needs to be manually loaded for the routes to be loaded - OC_App::loadApp('calendar'); + $this->appManager->loadApp('calendar'); $linkData = $eventData['link']; - $objectId = base64_encode('/remote.php/dav/calendars/' . $linkData['owner'] . '/' . $linkData['calendar_uri'] . '/' . $linkData['object_uri']); - $link = [ - 'view' => 'dayGridMonth', - 'timeRange' => 'now', - 'mode' => 'sidebar', + $calendarUri = $this->urlencodeLowerHex($linkData['calendar_uri']); + if ($affectedUser === $linkData['owner']) { + $objectId = base64_encode($this->url->getWebroot() . '/remote.php/dav/calendars/' . $linkData['owner'] . '/' . $calendarUri . '/' . $linkData['object_uri']); + } else { + // Can't use the "real" owner and calendar names here because we create a custom + // calendar for incoming shares with the name "<calendar>_shared_by_<sharer>". + // Hack: Fix the link by generating it for the incoming shared calendar instead, + // as seen from the affected user. + $objectId = base64_encode($this->url->getWebroot() . '/remote.php/dav/calendars/' . $affectedUser . '/' . $calendarUri . '_shared_by_' . $linkData['owner'] . '/' . $linkData['object_uri']); + } + $params['link'] = $this->url->linkToRouteAbsolute('calendar.view.indexdirect.edit', [ 'objectId' => $objectId, - 'recurrenceId' => 'next' - ]; - $params['link'] = $this->url->linkToRouteAbsolute('calendar.view.indexview.timerange.edit', $link); + ]); } catch (\Exception $error) { // Do nothing } @@ -117,12 +94,12 @@ class Event extends Base { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parse($language, IEvent $event, IEvent $previousEvent = null) { + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { if ($event->getApp() !== 'dav' || $event->getType() !== 'calendar_event') { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $this->l = $this->languageFactory->get('dav', $language); @@ -145,6 +122,10 @@ class Event extends Base { $subject = $this->l->t('{actor} updated event {event} in calendar {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_event_self') { $subject = $this->l->t('You updated event {event} in calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_event') { + $subject = $this->l->t('{actor} moved event {event} from calendar {sourceCalendar} to calendar {targetCalendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_event_self') { + $subject = $this->l->t('You moved event {event} from calendar {sourceCalendar} to calendar {targetCalendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event') { $subject = $this->l->t('{actor} deleted event {event} from calendar {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event_self') { @@ -154,7 +135,7 @@ class Event extends Base { } elseif ($event->getSubject() === self::SUBJECT_OBJECT_RESTORE . '_event_self') { $subject = $this->l->t('You restored event {event} of calendar {calendar}'); } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $parsedParameters = $this->getParameters($event); @@ -184,7 +165,7 @@ class Event extends Base { return [ 'actor' => $this->generateUserParameter($parameters['actor']), 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), - 'event' => $this->generateClassifiedObjectParameter($parameters['object']), + 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()), ]; case self::SUBJECT_OBJECT_ADD . '_event_self': case self::SUBJECT_OBJECT_DELETE . '_event_self': @@ -193,7 +174,25 @@ class Event extends Base { case self::SUBJECT_OBJECT_RESTORE . '_event_self': return [ 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), - 'event' => $this->generateClassifiedObjectParameter($parameters['object']), + 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()), + ]; + } + } + + if (isset($parameters['sourceCalendar']) && isset($parameters['targetCalendar'])) { + switch ($subject) { + case self::SUBJECT_OBJECT_MOVE . '_event': + return [ + 'actor' => $this->generateUserParameter($parameters['actor']), + 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l), + 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l), + 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()), + ]; + case self::SUBJECT_OBJECT_MOVE . '_event_self': + return [ + 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l), + 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l), + 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()), ]; } } @@ -210,25 +209,37 @@ class Event extends Base { return [ 'actor' => $this->generateUserParameter($parameters[0]), 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), - 'event' => $this->generateObjectParameter($parameters[2]), + 'event' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()), ]; case self::SUBJECT_OBJECT_ADD . '_event_self': case self::SUBJECT_OBJECT_DELETE . '_event_self': case self::SUBJECT_OBJECT_UPDATE . '_event_self': return [ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), - 'event' => $this->generateObjectParameter($parameters[2]), + 'event' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()), ]; } throw new \InvalidArgumentException(); } - private function generateClassifiedObjectParameter(array $eventData) { - $parameter = $this->generateObjectParameter($eventData); + private function generateClassifiedObjectParameter(array $eventData, string $affectedUser): array { + $parameter = $this->generateObjectParameter($eventData, $affectedUser); if (!empty($eventData['classified'])) { $parameter['name'] = $this->l->t('Busy'); } return $parameter; } + + /** + * Return urlencoded string but with lower cased hex sequences. + * The remaining casing will be untouched. + */ + private function urlencodeLowerHex(string $raw): string { + return preg_replace_callback( + '/%[0-9A-F]{2}/', + static fn (array $matches) => strtolower($matches[0]), + urlencode($raw), + ); + } } diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Todo.php b/apps/dav/lib/CalDAV/Activity/Provider/Todo.php index a3ab81e38ae..fc0625ec970 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Todo.php @@ -1,29 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Provider; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; class Todo extends Event { @@ -33,12 +16,12 @@ class Todo extends Event { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parse($language, IEvent $event, IEvent $previousEvent = null) { + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { if ($event->getApp() !== 'dav' || $event->getType() !== 'calendar_todo') { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $this->l = $this->languageFactory->get('dav', $language); @@ -50,27 +33,31 @@ class Todo extends Event { } if ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_todo') { - $subject = $this->l->t('{actor} created todo {todo} in list {calendar}'); + $subject = $this->l->t('{actor} created to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_todo_self') { - $subject = $this->l->t('You created todo {todo} in list {calendar}'); + $subject = $this->l->t('You created to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo') { - $subject = $this->l->t('{actor} deleted todo {todo} from list {calendar}'); + $subject = $this->l->t('{actor} deleted to-do {todo} from list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo_self') { - $subject = $this->l->t('You deleted todo {todo} from list {calendar}'); + $subject = $this->l->t('You deleted to-do {todo} from list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo') { - $subject = $this->l->t('{actor} updated todo {todo} in list {calendar}'); + $subject = $this->l->t('{actor} updated to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_self') { - $subject = $this->l->t('You updated todo {todo} in list {calendar}'); + $subject = $this->l->t('You updated to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed') { - $subject = $this->l->t('{actor} solved todo {todo} in list {calendar}'); + $subject = $this->l->t('{actor} solved to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed_self') { - $subject = $this->l->t('You solved todo {todo} in list {calendar}'); + $subject = $this->l->t('You solved to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action') { - $subject = $this->l->t('{actor} reopened todo {todo} in list {calendar}'); + $subject = $this->l->t('{actor} reopened to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self') { - $subject = $this->l->t('You reopened todo {todo} in list {calendar}'); + $subject = $this->l->t('You reopened to-do {todo} in list {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_todo') { + $subject = $this->l->t('{actor} moved to-do {todo} from list {sourceCalendar} to list {targetCalendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_todo_self') { + $subject = $this->l->t('You moved to-do {todo} from list {sourceCalendar} to list {targetCalendar}'); } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $parsedParameters = $this->getParameters($event); @@ -100,7 +87,7 @@ class Todo extends Event { return [ 'actor' => $this->generateUserParameter($parameters['actor']), 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), - 'todo' => $this->generateObjectParameter($parameters['object']), + 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()), ]; case self::SUBJECT_OBJECT_ADD . '_todo_self': case self::SUBJECT_OBJECT_DELETE . '_todo_self': @@ -109,7 +96,25 @@ class Todo extends Event { case self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self': return [ 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), - 'todo' => $this->generateObjectParameter($parameters['object']), + 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()), + ]; + } + } + + if (isset($parameters['sourceCalendar']) && isset($parameters['targetCalendar'])) { + switch ($subject) { + case self::SUBJECT_OBJECT_MOVE . '_todo': + return [ + 'actor' => $this->generateUserParameter($parameters['actor']), + 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l), + 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l), + 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()), + ]; + case self::SUBJECT_OBJECT_MOVE . '_todo_self': + return [ + 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l), + 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l), + 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()), ]; } } @@ -128,7 +133,7 @@ class Todo extends Event { return [ 'actor' => $this->generateUserParameter($parameters[0]), 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), - 'todo' => $this->generateObjectParameter($parameters[2]), + 'todo' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()), ]; case self::SUBJECT_OBJECT_ADD . '_todo_self': case self::SUBJECT_OBJECT_DELETE . '_todo_self': @@ -137,7 +142,7 @@ class Todo extends Event { case self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self': return [ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), - 'todo' => $this->generateObjectParameter($parameters[2]), + 'todo' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()), ]; } diff --git a/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php b/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php index 20325a253f4..7ab7f16dbbb 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 Robin Appelman <robin@icewind.nl> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Setting; @@ -30,14 +12,12 @@ use OCP\Activity\ActivitySettings; use OCP\IL10N; abstract class CalDAVSetting extends ActivitySettings { - /** @var IL10N */ - protected $l; - /** * @param IL10N $l */ - public function __construct(IL10N $l) { - $this->l = $l; + public function __construct( + protected IL10N $l, + ) { } public function getGroupIdentifier() { diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php b/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php index 4a226fca439..0ad86a919bc 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Setting; @@ -42,8 +25,8 @@ class Calendar extends CalDAVSetting { /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. * @since 11.0.0 */ public function getPriority() { diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Event.php b/apps/dav/lib/CalDAV/Activity/Setting/Event.php index 0239296a403..ea9476d6f08 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Event.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Event.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Setting; @@ -42,8 +25,8 @@ class Event extends CalDAVSetting { /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. * @since 11.0.0 */ public function getPriority() { diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Todo.php b/apps/dav/lib/CalDAV/Activity/Setting/Todo.php index 7d27b30c4af..ed8377b0ffa 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Todo.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Setting; @@ -38,13 +21,13 @@ class Todo extends CalDAVSetting { * @since 11.0.0 */ public function getName() { - return $this->l->t('A calendar <strong>todo</strong> was modified'); + return $this->l->t('A calendar <strong>to-do</strong> was modified'); } /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. * @since 11.0.0 */ public function getPriority() { diff --git a/apps/dav/lib/CalDAV/AppCalendar/AppCalendar.php b/apps/dav/lib/CalDAV/AppCalendar/AppCalendar.php new file mode 100644 index 00000000000..87d26324c32 --- /dev/null +++ b/apps/dav/lib/CalDAV/AppCalendar/AppCalendar.php @@ -0,0 +1,194 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\AppCalendar; + +use OCA\DAV\CalDAV\Integration\ExternalCalendar; +use OCA\DAV\CalDAV\Plugin; +use OCP\Calendar\ICalendar; +use OCP\Calendar\ICreateFromString; +use OCP\Constants; +use Sabre\CalDAV\CalendarQueryValidator; +use Sabre\CalDAV\ICalendarObject; +use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\PropPatch; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Reader; + +class AppCalendar extends ExternalCalendar { + protected ICalendar $calendar; + + public function __construct( + string $appId, + ICalendar $calendar, + protected string $principal, + ) { + parent::__construct($appId, $calendar->getUri()); + $this->calendar = $calendar; + } + + /** + * Return permissions supported by the backend calendar + * @return int Permissions based on \OCP\Constants + */ + public function getPermissions(): int { + // Make sure to only promote write support if the backend implement the correct interface + if ($this->calendar instanceof ICreateFromString) { + return $this->calendar->getPermissions(); + } + return Constants::PERMISSION_READ; + } + + public function getOwner(): ?string { + return $this->principal; + } + + public function getGroup(): ?string { + return null; + } + + public function getACL(): array { + $acl = [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner(), + 'protected' => true, + ] + ]; + if ($this->getPermissions() & Constants::PERMISSION_CREATE) { + $acl[] = [ + 'privilege' => '{DAV:}write', + 'principal' => $this->getOwner(), + 'protected' => true, + ]; + } + return $acl; + } + + public function setACL(array $acl): void { + throw new Forbidden('Setting ACL is not supported on this node'); + } + + public function getSupportedPrivilegeSet(): ?array { + // Use the default one + return null; + } + + public function getLastModified(): ?int { + // unknown + return null; + } + + public function delete(): void { + // No method for deleting a calendar in OCP\Calendar\ICalendar + throw new Forbidden('Deleting an entry is not implemented'); + } + + public function createFile($name, $data = null) { + if ($this->calendar instanceof ICreateFromString) { + if (is_resource($data)) { + $data = stream_get_contents($data) ?: null; + } + $this->calendar->createFromString($name, is_null($data) ? '' : $data); + return null; + } else { + throw new Forbidden('Creating a new entry is not allowed'); + } + } + + public function getProperties($properties) { + return [ + '{DAV:}displayname' => $this->calendar->getDisplayName() ?: $this->calendar->getKey(), + '{http://apple.com/ns/ical/}calendar-color' => $this->calendar->getDisplayColor() ?: '#0082c9', + '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VEVENT', 'VJOURNAL', 'VTODO']), + ]; + } + + public function calendarQuery(array $filters) { + $result = []; + $objects = $this->getChildren(); + + foreach ($objects as $object) { + if ($this->validateFilterForObject($object, $filters)) { + $result[] = $object->getName(); + } + } + + return $result; + } + + protected function validateFilterForObject(ICalendarObject $object, array $filters): bool { + /** @var \Sabre\VObject\Component\VCalendar */ + $vObject = Reader::read($object->get()); + + $validator = new CalendarQueryValidator(); + $result = $validator->validate($vObject, $filters); + + // Destroy circular references so PHP will GC the object. + $vObject->destroy(); + + return $result; + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $error) { + return false; + } + } + + public function getChild($name) { + // Try to get calendar by filename + $children = $this->calendar->search($name, ['X-FILENAME']); + if (count($children) === 0) { + // If nothing found try to get by UID from filename + $pos = strrpos($name, '.ics'); + $children = $this->calendar->search(substr($name, 0, $pos ?: null), ['UID']); + } + + if (count($children) > 0) { + return new CalendarObject($this, $this->calendar, new VCalendar($children)); + } + + throw new NotFound('Node not found'); + } + + /** + * @return ICalendarObject[] + */ + public function getChildren(): array { + $objects = $this->calendar->search(''); + // We need to group by UID (actually by filename but we do not have that information) + $result = []; + foreach ($objects as $object) { + $uid = (string)$object['UID'] ?: uniqid(); + if (!isset($result[$uid])) { + $result[$uid] = []; + } + $result[$uid][] = $object; + } + + return array_map(function (array $children) { + return new CalendarObject($this, $this->calendar, new VCalendar($children)); + }, $result); + } + + public function propPatch(PropPatch $propPatch): void { + // no setDisplayColor or setDisplayName in \OCP\Calendar\ICalendar + } +} diff --git a/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php b/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php new file mode 100644 index 00000000000..72f2ed2c163 --- /dev/null +++ b/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\AppCalendar; + +use OCA\DAV\CalDAV\CachedSubscriptionImpl; +use OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\Integration\ExternalCalendar; +use OCA\DAV\CalDAV\Integration\ICalendarProvider; +use OCP\Calendar\IManager; +use Psr\Log\LoggerInterface; + +/* Plugin for wrapping application generated calendars registered in nextcloud core (OCP\Calendar\ICalendarProvider) */ +class AppCalendarPlugin implements ICalendarProvider { + public function __construct( + protected IManager $manager, + protected LoggerInterface $logger, + ) { + } + + public function getAppID(): string { + return 'dav-wrapper'; + } + + public function fetchAllForCalendarHome(string $principalUri): array { + return array_map(function ($calendar) use (&$principalUri) { + return new AppCalendar($this->getAppID(), $calendar, $principalUri); + }, $this->getWrappedCalendars($principalUri)); + } + + public function hasCalendarInCalendarHome(string $principalUri, string $calendarUri): bool { + return count($this->getWrappedCalendars($principalUri, [ $calendarUri ])) > 0; + } + + public function getCalendarInCalendarHome(string $principalUri, string $calendarUri): ?ExternalCalendar { + $calendars = $this->getWrappedCalendars($principalUri, [ $calendarUri ]); + if (count($calendars) > 0) { + return new AppCalendar($this->getAppID(), $calendars[0], $principalUri); + } + + return null; + } + + protected function getWrappedCalendars(string $principalUri, array $calendarUris = []): array { + return array_values( + array_filter($this->manager->getCalendarsForPrincipal($principalUri, $calendarUris), function ($c) { + // We must not provide a wrapper for DAV calendars + return ! (($c instanceof CalendarImpl) || ($c instanceof CachedSubscriptionImpl)); + }) + ); + } +} diff --git a/apps/dav/lib/CalDAV/AppCalendar/CalendarObject.php b/apps/dav/lib/CalDAV/AppCalendar/CalendarObject.php new file mode 100644 index 00000000000..3c62a26df54 --- /dev/null +++ b/apps/dav/lib/CalDAV/AppCalendar/CalendarObject.php @@ -0,0 +1,134 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\AppCalendar; + +use OCP\Calendar\ICalendar; +use OCP\Calendar\ICreateFromString; +use OCP\Constants; +use Sabre\CalDAV\ICalendarObject; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAVACL\IACL; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Property\ICalendar\DateTime; + +class CalendarObject implements ICalendarObject, IACL { + public function __construct( + private AppCalendar $calendar, + private ICalendar|ICreateFromString $backend, + private VCalendar $vobject, + ) { + } + + public function getOwner() { + return $this->calendar->getOwner(); + } + + public function getGroup() { + return $this->calendar->getGroup(); + } + + public function getACL(): array { + $acl = [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ] + ]; + if ($this->calendar->getPermissions() & Constants::PERMISSION_UPDATE) { + $acl[] = [ + 'privilege' => '{DAV:}write-content', + 'principal' => $this->getOwner(), + 'protected' => true, + ]; + } + return $acl; + } + + public function setACL(array $acl): void { + throw new Forbidden('Setting ACL is not supported on this node'); + } + + public function getSupportedPrivilegeSet(): ?array { + return null; + } + + public function put($data): void { + if ($this->backend instanceof ICreateFromString && $this->calendar->getPermissions() & Constants::PERMISSION_UPDATE) { + if (is_resource($data)) { + $data = stream_get_contents($data) ?: ''; + } + $this->backend->createFromString($this->getName(), $data); + } else { + throw new Forbidden('This calendar-object is read-only'); + } + } + + public function get(): string { + return $this->vobject->serialize(); + } + + public function getContentType(): string { + return 'text/calendar; charset=utf-8'; + } + + public function getETag(): ?string { + return null; + } + + public function getSize() { + return mb_strlen($this->vobject->serialize()); + } + + public function delete(): void { + if ($this->backend instanceof ICreateFromString && $this->calendar->getPermissions() & Constants::PERMISSION_DELETE) { + /** @var \Sabre\VObject\Component[] */ + $components = $this->vobject->getBaseComponents(); + foreach ($components as $key => $component) { + $components[$key]->STATUS = 'CANCELLED'; + $components[$key]->SEQUENCE = isset($component->SEQUENCE) ? ((int)$component->SEQUENCE->getValue()) + 1 : 1; + if ($component->name === 'VEVENT') { + $components[$key]->METHOD = 'CANCEL'; + } + } + $this->backend->createFromString($this->getName(), (new VCalendar($components))->serialize()); + } else { + throw new Forbidden('This calendar-object is read-only'); + } + } + + public function getName(): string { + // Every object is required to have an UID + $base = $this->vobject->getBaseComponent(); + // This should never happen except the app provides invalid calendars (VEvent, VTodo... all require UID to be present) + if ($base === null) { + throw new NotFound('Invalid node'); + } + if (isset($base->{'X-FILENAME'})) { + return (string)$base->{'X-FILENAME'}; + } + return (string)$base->UID . '.ics'; + } + + public function setName($name): void { + throw new Forbidden('This calendar-object is read-only'); + } + + public function getLastModified(): ?int { + $base = $this->vobject->getBaseComponent(); + if ($base !== null && $this->vobject->getBaseComponent()->{'LAST-MODIFIED'}) { + /** @var DateTime */ + $lastModified = $this->vobject->getBaseComponent()->{'LAST-MODIFIED'}; + return $lastModified->getDateTime()->getTimestamp(); + } + return null; + } +} diff --git a/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php b/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php index 89e50c7da6b..71b9acb939b 100644 --- a/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php +++ b/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * CalDAV App - * - * @copyright 2021 Anna Larch <anna.larch@gmx.net> - * - * @author Anna Larch <anna.larch@gmx.net> - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Auth; diff --git a/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php b/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php index 96669558818..ed89638451e 100644 --- a/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php +++ b/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * CalDAV App - * - * @copyright 2021 Anna Larch <anna.larch@gmx.net> - * - * @author Anna Larch <anna.larch@gmx.net> - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Auth; diff --git a/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php b/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php index b736d9432bd..681709cdb6f 100644 --- a/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php +++ b/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php @@ -1,32 +1,16 @@ <?php + /** - * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\BirthdayCalendar; use OCA\DAV\CalDAV\BirthdayService; use OCA\DAV\CalDAV\CalendarHome; +use OCP\AppFramework\Http; use OCP\IConfig; +use OCP\IUser; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\HTTP\RequestInterface; @@ -42,16 +26,6 @@ class EnablePlugin extends ServerPlugin { public const NS_Nextcloud = 'http://nextcloud.com/ns'; /** - * @var IConfig - */ - protected $config; - - /** - * @var BirthdayService - */ - protected $birthdayService; - - /** * @var Server */ protected $server; @@ -61,10 +35,13 @@ class EnablePlugin extends ServerPlugin { * * @param IConfig $config * @param BirthdayService $birthdayService + * @param IUser $user */ - public function __construct(IConfig $config, BirthdayService $birthdayService) { - $this->config = $config; - $this->birthdayService = $birthdayService; + public function __construct( + protected IConfig $config, + protected BirthdayService $birthdayService, + private IUser $user, + ) { } /** @@ -117,23 +94,26 @@ class EnablePlugin extends ServerPlugin { */ public function httpPost(RequestInterface $request, ResponseInterface $response) { $node = $this->server->tree->getNodeForPath($this->server->getRequestUri()); - if (!($node instanceof CalendarHome)) { + if (!$node instanceof CalendarHome) { return; } $requestBody = $request->getBodyAsString(); $this->server->xml->parse($requestBody, $request->getUrl(), $documentType); - if ($documentType !== '{'.self::NS_Nextcloud.'}enable-birthday-calendar') { + if ($documentType !== '{' . self::NS_Nextcloud . '}enable-birthday-calendar') { return; } - $principalUri = $node->getOwner(); - $userId = substr($principalUri, 17); + $owner = substr($node->getOwner(), 17); + if ($owner !== $this->user->getUID()) { + $this->server->httpResponse->setStatus(Http::STATUS_FORBIDDEN); + return false; + } - $this->config->setUserValue($userId, 'dav', 'generateBirthdayCalendar', 'yes'); - $this->birthdayService->syncUser($userId); + $this->config->setUserValue($this->user->getUID(), 'dav', 'generateBirthdayCalendar', 'yes'); + $this->birthdayService->syncUser($this->user->getUID()); - $this->server->httpResponse->setStatus(204); + $this->server->httpResponse->setStatus(Http::STATUS_NO_CONTENT); return false; } diff --git a/apps/dav/lib/CalDAV/BirthdayService.php b/apps/dav/lib/CalDAV/BirthdayService.php index bdcf0796283..680b228766f 100644 --- a/apps/dav/lib/CalDAV/BirthdayService.php +++ b/apps/dav/lib/CalDAV/BirthdayService.php @@ -3,32 +3,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Achim Königs <garfonso@tratschtante.de> - * @author Christian Weiske <cweiske@cweiske.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Sven Strickroth <email@cs-ware.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Valdnet <47037905+Valdnet@users.noreply.github.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -53,63 +30,33 @@ use Sabre\VObject\Reader; */ class BirthdayService { public const BIRTHDAY_CALENDAR_URI = 'contact_birthdays'; - - /** @var GroupPrincipalBackend */ - private $principalBackend; - - /** @var CalDavBackend */ - private $calDavBackEnd; - - /** @var CardDavBackend */ - private $cardDavBackEnd; - - /** @var IConfig */ - private $config; - - /** @var IDBConnection */ - private $dbConnection; - - /** @var IL10N */ - private $l10n; + public const EXCLUDE_FROM_BIRTHDAY_CALENDAR_PROPERTY_NAME = 'X-NC-EXCLUDE-FROM-BIRTHDAY-CALENDAR'; /** * BirthdayService constructor. - * - * @param CalDavBackend $calDavBackEnd - * @param CardDavBackend $cardDavBackEnd - * @param GroupPrincipalBackend $principalBackend - * @param IConfig $config - * @param IDBConnection $dbConnection - * @param IL10N $l10n */ - public function __construct(CalDavBackend $calDavBackEnd, - CardDavBackend $cardDavBackEnd, - GroupPrincipalBackend $principalBackend, - IConfig $config, - IDBConnection $dbConnection, - IL10N $l10n) { - $this->calDavBackEnd = $calDavBackEnd; - $this->cardDavBackEnd = $cardDavBackEnd; - $this->principalBackend = $principalBackend; - $this->config = $config; - $this->dbConnection = $dbConnection; - $this->l10n = $l10n; + public function __construct( + private CalDavBackend $calDavBackEnd, + private CardDavBackend $cardDavBackEnd, + private GroupPrincipalBackend $principalBackend, + private IConfig $config, + private IDBConnection $dbConnection, + private IL10N $l10n, + ) { } - /** - * @param int $addressBookId - * @param string $cardUri - * @param string $cardData - */ public function onCardChanged(int $addressBookId, - string $cardUri, - string $cardData) { + string $cardUri, + string $cardData): void { if (!$this->isGloballyEnabled()) { return; } $targetPrincipals = $this->getAllAffectedPrincipals($addressBookId); $book = $this->cardDavBackEnd->getAddressBookById($addressBookId); + if ($book === null) { + return; + } $targetPrincipals[] = $book['principaluri']; $datesToSync = [ ['postfix' => '', 'field' => 'BDAY'], @@ -122,19 +69,20 @@ class BirthdayService { continue; } + $reminderOffset = $this->getReminderOffsetForUser($principalUri); + $calendar = $this->ensureCalendarExists($principalUri); + if ($calendar === null) { + return; + } foreach ($datesToSync as $type) { - $this->updateCalendar($cardUri, $cardData, $book, (int) $calendar['id'], $type); + $this->updateCalendar($cardUri, $cardData, $book, (int)$calendar['id'], $type, $reminderOffset); } } } - /** - * @param int $addressBookId - * @param string $cardUri - */ public function onCardDeleted(int $addressBookId, - string $cardUri) { + string $cardUri): void { if (!$this->isGloballyEnabled()) { return; } @@ -149,18 +97,16 @@ class BirthdayService { $calendar = $this->ensureCalendarExists($principalUri); foreach (['', '-death', '-anniversary'] as $tag) { - $objectUri = $book['uri'] . '-' . $cardUri . $tag .'.ics'; + $objectUri = $book['uri'] . '-' . $cardUri . $tag . '.ics'; $this->calDavBackEnd->deleteCalendarObject($calendar['id'], $objectUri, CalDavBackend::CALENDAR_TYPE_CALENDAR, true); } } } /** - * @param string $principal - * @return array|null * @throws \Sabre\DAV\Exception\BadRequest */ - public function ensureCalendarExists(string $principal):?array { + public function ensureCalendarExists(string $principal): ?array { $calendar = $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI); if (!is_null($calendar)) { return $calendar; @@ -178,12 +124,14 @@ class BirthdayService { * @param $cardData * @param $dateField * @param $postfix + * @param $reminderOffset * @return VCalendar|null * @throws InvalidDataException */ public function buildDateFromContact(string $cardData, - string $dateField, - string $postfix):?VCalendar { + string $dateField, + string $postfix, + ?string $reminderOffset):?VCalendar { if (empty($cardData)) { return null; } @@ -199,6 +147,10 @@ class BirthdayService { return null; } + if (isset($doc->{self::EXCLUDE_FROM_BIRTHDAY_CALENDAR_PROPERTY_NAME})) { + return null; + } + if (!isset($doc->{$dateField})) { return null; } @@ -220,33 +172,26 @@ class BirthdayService { } catch (InvalidDataException $e) { return null; } + if ($dateParts['year'] !== null) { + $parameters = $birthday->parameters(); + $omitYear = (isset($parameters['X-APPLE-OMIT-YEAR']) + && $parameters['X-APPLE-OMIT-YEAR'] === $dateParts['year']); + // 'X-APPLE-OMIT-YEAR' is not always present, at least iOS 12.4 uses the hard coded date of 1604 (the start of the gregorian calendar) when the year is unknown + if ($omitYear || (int)$dateParts['year'] === 1604) { + $dateParts['year'] = null; + } + } - $unknownYear = false; $originalYear = null; - if (!$dateParts['year']) { - $birthday = '1970-' . $dateParts['month'] . '-' . $dateParts['date']; + if ($dateParts['year'] !== null) { + $originalYear = (int)$dateParts['year']; + } - $unknownYear = true; - } else { - $parameters = $birthday->parameters(); - if (isset($parameters['X-APPLE-OMIT-YEAR'])) { - $omitYear = $parameters['X-APPLE-OMIT-YEAR']; - if ($dateParts['year'] === $omitYear) { - $birthday = '1970-' . $dateParts['month'] . '-' . $dateParts['date']; - $unknownYear = true; - } - } else { - $originalYear = (int)$dateParts['year']; - // 'X-APPLE-OMIT-YEAR' is not always present, at least iOS 12.4 uses the hard coded date of 1604 (the start of the gregorian calendar) when the year is unknown - if ($originalYear == 1604) { - $originalYear = null; - $unknownYear = true; - $birthday = '1970-' . $dateParts['month'] . '-' . $dateParts['date']; - } - if ($originalYear < 1970) { - $birthday = '1970-' . $dateParts['month'] . '-' . $dateParts['date']; - } - } + $leapDay = ((int)$dateParts['month'] === 2 + && (int)$dateParts['date'] === 29); + if ($dateParts['year'] === null || $originalYear < 1970) { + $birthday = ($leapDay ? '1972-' : '1970-') + . $dateParts['month'] . '-' . $dateParts['date']; } try { @@ -281,18 +226,25 @@ class BirthdayService { $vEvent->DTEND['VALUE'] = 'DATE'; $vEvent->{'UID'} = $doc->UID . $postfix; $vEvent->{'RRULE'} = 'FREQ=YEARLY'; + if ($leapDay) { + /* Sabre\VObject supports BYMONTHDAY only if BYMONTH + * is also set */ + $vEvent->{'RRULE'} = 'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1'; + } $vEvent->{'SUMMARY'} = $summary; $vEvent->{'TRANSP'} = 'TRANSPARENT'; $vEvent->{'X-NEXTCLOUD-BC-FIELD-TYPE'} = $dateField; - $vEvent->{'X-NEXTCLOUD-BC-UNKNOWN-YEAR'} = $unknownYear ? '1' : '0'; + $vEvent->{'X-NEXTCLOUD-BC-UNKNOWN-YEAR'} = $dateParts['year'] === null ? '1' : '0'; if ($originalYear !== null) { - $vEvent->{'X-NEXTCLOUD-BC-YEAR'} = (string) $originalYear; + $vEvent->{'X-NEXTCLOUD-BC-YEAR'} = (string)$originalYear; + } + if ($reminderOffset) { + $alarm = $vCal->createComponent('VALARM'); + $alarm->add($vCal->createProperty('TRIGGER', $reminderOffset, ['VALUE' => 'DURATION'])); + $alarm->add($vCal->createProperty('ACTION', 'DISPLAY')); + $alarm->add($vCal->createProperty('DESCRIPTION', $vEvent->{'SUMMARY'})); + $vEvent->add($alarm); } - $alarm = $vCal->createComponent('VALARM'); - $alarm->add($vCal->createProperty('TRIGGER', '-PT0M', ['VALUE' => 'DURATION'])); - $alarm->add($vCal->createProperty('ACTION', 'DISPLAY')); - $alarm->add($vCal->createProperty('DESCRIPTION', $vEvent->{'SUMMARY'})); - $vEvent->add($alarm); $vCal->add($vEvent); return $vCal; } @@ -301,8 +253,11 @@ class BirthdayService { * @param string $user */ public function resetForUser(string $user):void { - $principal = 'principals/users/'.$user; + $principal = 'principals/users/' . $user; $calendar = $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI); + if (!$calendar) { + return; // The user's birthday calendar doesn't exist, no need to purge it + } $calendarObjects = $this->calDavBackEnd->getCalendarObjects($calendar['id'], CalDavBackend::CALENDAR_TYPE_CALENDAR); foreach ($calendarObjects as $calendarObject) { @@ -315,13 +270,13 @@ class BirthdayService { * @throws \Sabre\DAV\Exception\BadRequest */ public function syncUser(string $user):void { - $principal = 'principals/users/'.$user; + $principal = 'principals/users/' . $user; $this->ensureCalendarExists($principal); $books = $this->cardDavBackEnd->getAddressBooksForUser($principal); foreach ($books as $book) { $cards = $this->cardDavBackEnd->getCards($book['id']); foreach ($cards as $card) { - $this->onCardChanged((int) $book['id'], $card['uri'], $card['carddata']); + $this->onCardChanged((int)$book['id'], $card['uri'], $card['carddata']); } } } @@ -332,7 +287,7 @@ class BirthdayService { * @return bool */ public function birthdayEvenChanged(string $existingCalendarData, - VCalendar $newCalendarData):bool { + VCalendar $newCalendarData):bool { try { $existingBirthday = Reader::read($existingCalendarData); } catch (Exception $ex) { @@ -340,8 +295,8 @@ class BirthdayService { } return ( - $newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() || - $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue() + $newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() + || $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue() ); } @@ -371,16 +326,18 @@ class BirthdayService { * @param array $book * @param int $calendarId * @param array $type + * @param string $reminderOffset * @throws InvalidDataException * @throws \Sabre\DAV\Exception\BadRequest */ private function updateCalendar(string $cardUri, - string $cardData, - array $book, - int $calendarId, - array $type):void { + string $cardData, + array $book, + int $calendarId, + array $type, + ?string $reminderOffset):void { $objectUri = $book['uri'] . '-' . $cardUri . $type['postfix'] . '.ics'; - $calendarData = $this->buildDateFromContact($cardData, $type['field'], $type['postfix']); + $calendarData = $this->buildDateFromContact($cardData, $type['field'], $type['postfix'], $reminderOffset); $existing = $this->calDavBackEnd->getCalendarObject($calendarId, $objectUri); if ($calendarData === null) { if ($existing !== null) { @@ -421,14 +378,27 @@ class BirthdayService { } /** + * Extracts the userId part of a principal + * + * @param string $userPrincipal + * @return string|null + */ + private function principalToUserId(string $userPrincipal):?string { + if (str_starts_with($userPrincipal, 'principals/users/')) { + return substr($userPrincipal, 17); + } + return null; + } + + /** * Checks if the user opted-out of birthday calendars * * @param string $userPrincipal The user principal to check for * @return bool */ private function isUserEnabled(string $userPrincipal):bool { - if (strpos($userPrincipal, 'principals/users/') === 0) { - $userId = substr($userPrincipal, 17); + $userId = $this->principalToUserId($userPrincipal); + if ($userId !== null) { $isEnabled = $this->config->getUserValue($userId, 'dav', 'generateBirthdayCalendar', 'yes'); return $isEnabled === 'yes'; } @@ -438,6 +408,23 @@ class BirthdayService { } /** + * Get the reminder offset value for a user. This is a duration string (e.g. + * PT9H) or null if no reminder is wanted. + * + * @param string $userPrincipal + * @return string|null + */ + private function getReminderOffsetForUser(string $userPrincipal):?string { + $userId = $this->principalToUserId($userPrincipal); + if ($userId !== null) { + return $this->config->getUserValue($userId, 'dav', 'birthdayCalendarReminderOffset', 'PT9H') ?: null; + } + + // not sure how we got here, just be on the safe side and return the default value + return 'PT9H'; + } + + /** * Formats title of Birthday event * * @param string $field Field name like BDAY, ANNIVERSARY, ... @@ -447,9 +434,9 @@ class BirthdayService { * @return string The formatted title */ private function formatTitle(string $field, - string $name, - int $year = null, - bool $supports4Byte = true):string { + string $name, + ?int $year = null, + bool $supports4Byte = true):string { if ($supports4Byte) { switch ($field) { case 'BDAY': diff --git a/apps/dav/lib/CalDAV/CachedSubscription.php b/apps/dav/lib/CalDAV/CachedSubscription.php index 18e61450ee9..75ee5cb440f 100644 --- a/apps/dav/lib/CalDAV/CachedSubscription.php +++ b/apps/dav/lib/CalDAV/CachedSubscription.php @@ -3,41 +3,22 @@ declare(strict_types=1); /** - * @copyright 2018 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException; -use Sabre\CalDAV\Backend\BackendInterface; use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\INode; use Sabre\DAV\PropPatch; /** * Class CachedSubscription * * @package OCA\DAV\CalDAV - * @property BackendInterface|CalDavBackend $caldavBackend + * @property CalDavBackend $caldavBackend */ class CachedSubscription extends \Sabre\CalDAV\Calendar { @@ -51,7 +32,7 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { /** * @return array */ - public function getACL():array { + public function getACL() { return [ [ 'privilege' => '{DAV:}read', @@ -73,13 +54,18 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { 'principal' => '{DAV:}authenticated', 'protected' => true, ], + [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner(), + 'protected' => true, + ] ]; } /** * @return array */ - public function getChildACL():array { + public function getChildACL() { return [ [ 'privilege' => '{DAV:}read', @@ -97,7 +83,6 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { 'principal' => $this->getOwner() . '/calendar-proxy-read', 'protected' => true, ], - ]; } @@ -111,7 +96,7 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { return parent::getOwner(); } - + public function delete() { $this->caldavBackend->deleteSubscription($this->calendarInfo['id']); } @@ -139,9 +124,9 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { } /** - * @return array + * @return INode[] */ - public function getChildren():array { + public function getChildren(): array { $objs = $this->caldavBackend->getCalendarObjects($this->calendarInfo['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); $children = []; @@ -169,11 +154,11 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { /** * @param string $name - * @param null $calendarData - * @return null|string|void + * @param null|resource|string $data + * @return null|string * @throws MethodNotAllowed */ - public function createFile($name, $calendarData = null) { + public function createFile($name, $data = null) { throw new MethodNotAllowed('Creating objects in cached subscription is not allowed'); } diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php new file mode 100644 index 00000000000..cc1bab6d4fc --- /dev/null +++ b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV; + +use OCP\Calendar\ICalendar; +use OCP\Calendar\ICalendarIsEnabled; +use OCP\Calendar\ICalendarIsShared; +use OCP\Calendar\ICalendarIsWritable; +use OCP\Constants; + +class CachedSubscriptionImpl implements ICalendar, ICalendarIsEnabled, ICalendarIsShared, ICalendarIsWritable { + + public function __construct( + private CachedSubscription $calendar, + /** @var array<string, mixed> */ + private array $calendarInfo, + private CalDavBackend $backend, + ) { + } + + /** + * @return string defining the technical unique key + * @since 13.0.0 + */ + public function getKey(): string { + return (string)$this->calendarInfo['id']; + } + + /** + * {@inheritDoc} + */ + public function getUri(): string { + return $this->calendarInfo['uri']; + } + + /** + * In comparison to getKey() this function returns a human readable (maybe translated) name + * @since 13.0.0 + */ + public function getDisplayName(): ?string { + return $this->calendarInfo['{DAV:}displayname']; + } + + /** + * Calendar color + * @since 13.0.0 + */ + public function getDisplayColor(): ?string { + return $this->calendarInfo['{http://apple.com/ns/ical/}calendar-color']; + } + + public function search(string $pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null): array { + return $this->backend->search($this->calendarInfo, $pattern, $searchProperties, $options, $limit, $offset); + } + + /** + * @return int build up using \OCP\Constants + * @since 13.0.0 + */ + public function getPermissions(): int { + $permissions = $this->calendar->getACL(); + $result = 0; + foreach ($permissions as $permission) { + switch ($permission['privilege']) { + case '{DAV:}read': + $result |= Constants::PERMISSION_READ; + break; + } + } + + return $result; + } + + /** + * @since 32.0.0 + */ + public function isEnabled(): bool { + return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true; + } + + public function isWritable(): bool { + return false; + } + + public function isDeleted(): bool { + return false; + } + + public function isShared(): bool { + return true; + } + + public function getSource(): string { + return $this->calendarInfo['source']; + } +} diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionObject.php b/apps/dav/lib/CalDAV/CachedSubscriptionObject.php index db8c9fa8e80..dc9141a61b8 100644 --- a/apps/dav/lib/CalDAV/CachedSubscriptionObject.php +++ b/apps/dav/lib/CalDAV/CachedSubscriptionObject.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; @@ -50,7 +33,7 @@ class CachedSubscriptionObject extends \Sabre\CalDAV\CalendarObject { /** * @param resource|string $calendarData - * @return string|void + * @return string * @throws MethodNotAllowed */ public function put($calendarData) { diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionProvider.php b/apps/dav/lib/CalDAV/CachedSubscriptionProvider.php new file mode 100644 index 00000000000..d64f039d05b --- /dev/null +++ b/apps/dav/lib/CalDAV/CachedSubscriptionProvider.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV; + +use OCP\Calendar\ICalendarProvider; + +class CachedSubscriptionProvider implements ICalendarProvider { + + public function __construct( + private CalDavBackend $calDavBackend, + ) { + } + + public function getCalendars(string $principalUri, array $calendarUris = []): array { + $calendarInfos = $this->calDavBackend->getSubscriptionsForUser($principalUri); + + if (count($calendarUris) > 0) { + $calendarInfos = array_filter($calendarInfos, fn (array $subscription) => in_array($subscription['uri'], $calendarUris)); + } + + $calendarInfos = array_values(array_filter($calendarInfos)); + + $iCalendars = []; + foreach ($calendarInfos as $calendarInfo) { + $calendar = new CachedSubscription($this->calDavBackend, $calendarInfo); + $iCalendars[] = new CachedSubscriptionImpl( + $calendar, + $calendarInfo, + $this->calDavBackend, + ); + } + return $iCalendars; + } +} diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index f0d332adab5..d5b0d875ede 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -1,48 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2018 Georg Ehrke - * @copyright Copyright (c) 2020, leith abdulla (<online-nextcloud@eleith.com>) - * - * @author Chih-Hsuan Yen <yan12125@gmail.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author dartcafe <github@dartcafe.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author leith abdulla <online-nextcloud@eleith.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Simon Spannagel <simonspa@kth.se> - * @author Stefan Weil <sw@weilnetz.de> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; use DateTime; +use DateTimeImmutable; use DateTimeInterface; +use Generator; use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\Sharing\Backend; use OCA\DAV\Connector\Sabre\Principal; -use OCA\DAV\DAV\Sharing\Backend; use OCA\DAV\DAV\Sharing\IShareable; use OCA\DAV\Events\CachedCalendarObjectCreatedEvent; use OCA\DAV\Events\CachedCalendarObjectDeletedEvent; @@ -50,11 +21,6 @@ use OCA\DAV\Events\CachedCalendarObjectUpdatedEvent; use OCA\DAV\Events\CalendarCreatedEvent; use OCA\DAV\Events\CalendarDeletedEvent; use OCA\DAV\Events\CalendarMovedToTrashEvent; -use OCA\DAV\Events\CalendarObjectCreatedEvent; -use OCA\DAV\Events\CalendarObjectDeletedEvent; -use OCA\DAV\Events\CalendarObjectMovedToTrashEvent; -use OCA\DAV\Events\CalendarObjectRestoredEvent; -use OCA\DAV\Events\CalendarObjectUpdatedEvent; use OCA\DAV\Events\CalendarPublishedEvent; use OCA\DAV\Events\CalendarRestoredEvent; use OCA\DAV\Events\CalendarShareUpdatedEvent; @@ -63,16 +29,23 @@ use OCA\DAV\Events\CalendarUpdatedEvent; use OCA\DAV\Events\SubscriptionCreatedEvent; use OCA\DAV\Events\SubscriptionDeletedEvent; use OCA\DAV\Events\SubscriptionUpdatedEvent; +use OCP\AppFramework\Db\TTransactional; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\Events\CalendarObjectCreatedEvent; +use OCP\Calendar\Events\CalendarObjectDeletedEvent; +use OCP\Calendar\Events\CalendarObjectMovedEvent; +use OCP\Calendar\Events\CalendarObjectMovedToTrashEvent; +use OCP\Calendar\Events\CalendarObjectRestoredEvent; +use OCP\Calendar\Events\CalendarObjectUpdatedEvent; +use OCP\Calendar\Exceptions\CalendarException; use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IDBConnection; -use OCP\IGroupManager; -use OCP\ILogger; -use OCP\IUser; use OCP\IUserManager; use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; use RuntimeException; use Sabre\CalDAV\Backend\AbstractBackend; use Sabre\CalDAV\Backend\SchedulingSupport; @@ -95,9 +68,10 @@ use Sabre\VObject\ParseException; use Sabre\VObject\Property; use Sabre\VObject\Reader; use Sabre\VObject\Recur\EventIterator; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\EventDispatcher\GenericEvent; +use Sabre\VObject\Recur\MaxInstancesExceededException; +use Sabre\VObject\Recur\NoInstancesException; use function array_column; +use function array_map; use function array_merge; use function array_values; use function explode; @@ -117,8 +91,23 @@ use function time; * Code is heavily inspired by https://github.com/fruux/sabre-dav/blob/master/lib/CalDAV/Backend/PDO.php * * @package OCA\DAV\CalDAV + * + * @psalm-type CalendarInfo = array{ + * id: int, + * uri: string, + * principaluri: string, + * '{http://calendarserver.org/ns/}getctag': string, + * '{http://sabredav.org/ns}sync-token': int, + * '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': \Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet, + * '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp': \Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp, + * '{DAV:}displayname': string, + * '{urn:ietf:params:xml:ns:caldav}calendar-timezone': ?string, + * '{http://nextcloud.com/ns}owner-displayname': string, + * } */ class CalDavBackend extends AbstractBackend implements SyncSupport, SubscriptionSupport, SchedulingSupport { + use TTransactional; + public const CALENDAR_TYPE_CALENDAR = 0; public const CALENDAR_TYPE_SUBSCRIPTION = 1; @@ -150,7 +139,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @var array * @psalm-var array<string, string[]> */ - public $propertyMap = [ + public array $propertyMap = [ '{DAV:}displayname' => ['displayname', 'string'], '{urn:ietf:params:xml:ns:caldav}calendar-description' => ['description', 'string'], '{urn:ietf:params:xml:ns:caldav}calendar-timezone' => ['timezone', 'string'], @@ -164,7 +153,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * * @var array */ - public $subscriptionPropertyMap = [ + public array $subscriptionPropertyMap = [ '{DAV:}displayname' => ['displayname', 'string'], '{http://apple.com/ns/ical/}refreshrate' => ['refreshrate', 'string'], '{http://apple.com/ns/ical/}calendar-order' => ['calendarorder', 'int'], @@ -195,7 +184,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ]; /** @var array parameters to index */ - public static $indexParameters = [ + public static array $indexParameters = [ 'ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN'], ]; @@ -203,86 +192,34 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** * @var string[] Map of uid => display name */ - protected $userDisplayNames; - - /** @var IDBConnection */ - private $db; - - /** @var Backend */ - private $calendarSharingBackend; - - /** @var Principal */ - private $principalBackend; - - /** @var IUserManager */ - private $userManager; - - /** @var ISecureRandom */ - private $random; + protected array $userDisplayNames; - /** @var ILogger */ - private $logger; + private string $dbObjectsTable = 'calendarobjects'; + private string $dbObjectPropertiesTable = 'calendarobjects_props'; + private string $dbObjectInvitationsTable = 'calendar_invitations'; + private array $cachedObjects = []; - /** @var IEventDispatcher */ - private $dispatcher; - - /** @var EventDispatcherInterface */ - private $legacyDispatcher; - - /** @var IConfig */ - private $config; - - /** @var bool */ - private $legacyEndpoint; - - /** @var string */ - private $dbObjectPropertiesTable = 'calendarobjects_props'; - - /** - * CalDavBackend constructor. - * - * @param IDBConnection $db - * @param Principal $principalBackend - * @param IUserManager $userManager - * @param IGroupManager $groupManager - * @param ISecureRandom $random - * @param ILogger $logger - * @param IEventDispatcher $dispatcher - * @param EventDispatcherInterface $legacyDispatcher - * @param bool $legacyEndpoint - */ - public function __construct(IDBConnection $db, - Principal $principalBackend, - IUserManager $userManager, - IGroupManager $groupManager, - ISecureRandom $random, - ILogger $logger, - IEventDispatcher $dispatcher, - EventDispatcherInterface $legacyDispatcher, - IConfig $config, - bool $legacyEndpoint = false) { - $this->db = $db; - $this->principalBackend = $principalBackend; - $this->userManager = $userManager; - $this->calendarSharingBackend = new Backend($this->db, $this->userManager, $groupManager, $principalBackend, 'calendar'); - $this->random = $random; - $this->logger = $logger; - $this->dispatcher = $dispatcher; - $this->legacyDispatcher = $legacyDispatcher; - $this->config = $config; - $this->legacyEndpoint = $legacyEndpoint; + public function __construct( + private IDBConnection $db, + private Principal $principalBackend, + private IUserManager $userManager, + private ISecureRandom $random, + private LoggerInterface $logger, + private IEventDispatcher $dispatcher, + private IConfig $config, + private Sharing\Backend $calendarSharingBackend, + private bool $legacyEndpoint = false, + ) { } /** - * Return the number of calendars for a principal + * Return the number of calendars owned by the given principal. * - * By default this excludes the automatically generated birthday calendar + * Calendars shared with the given principal are not counted! * - * @param $principalUri - * @param bool $excludeBirthday - * @return int + * By default, this excludes the automatically generated birthday calendar. */ - public function getCalendarsForUserCount($principalUri, $excludeBirthday = true) { + public function getCalendarsForUserCount(string $principalUri, bool $excludeBirthday = true): int { $principalUri = $this->convertPrincipal($principalUri, true); $query = $this->db->getQueryBuilder(); $query->select($query->func()->count('*')) @@ -305,6 +242,27 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** + * Return the number of subscriptions for a principal + */ + public function getSubscriptionsForUserCount(string $principalUri): int { + $principalUri = $this->convertPrincipal($principalUri, true); + $query = $this->db->getQueryBuilder(); + $query->select($query->func()->count('*')) + ->from('calendarsubscriptions'); + + if ($principalUri === '') { + $query->where($query->expr()->emptyString('principaluri')); + } else { + $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); + } + + $result = $query->executeQuery(); + $column = (int)$result->fetchOne(); + $result->closeCursor(); + return $column; + } + + /** * @return array{id: int, deleted_at: int}[] */ public function getDeletedCalendars(int $deletedBefore): array { @@ -314,14 +272,15 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->where($qb->expr()->isNotNull('deleted_at')) ->andWhere($qb->expr()->lt('deleted_at', $qb->createNamedParameter($deletedBefore))); $result = $qb->executeQuery(); - $raw = $result->fetchAll(); - $result->closeCursor(); - return array_map(function ($row) { - return [ - 'id' => (int) $row['id'], - 'deleted_at' => (int) $row['deleted_at'], + $calendars = []; + while (($row = $result->fetch()) !== false) { + $calendars[] = [ + 'id' => (int)$row['id'], + 'deleted_at' => (int)$row['deleted_at'], ]; - }, $raw); + } + $result->closeCursor(); + return $calendars; } /** @@ -350,132 +309,143 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return array */ public function getCalendarsForUser($principalUri) { - $principalUriOriginal = $principalUri; - $principalUri = $this->convertPrincipal($principalUri, true); - $fields = array_column($this->propertyMap, 0); - $fields[] = 'id'; - $fields[] = 'uri'; - $fields[] = 'synctoken'; - $fields[] = 'components'; - $fields[] = 'principaluri'; - $fields[] = 'transparent'; - - // Making fields a comma-delimited list - $query = $this->db->getQueryBuilder(); - $query->select($fields) - ->from('calendars') - ->orderBy('calendarorder', 'ASC'); - - if ($principalUri === '') { - $query->where($query->expr()->emptyString('principaluri')); - } else { - $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); - } - - $result = $query->executeQuery(); - - $calendars = []; - while ($row = $result->fetch()) { - $row['principaluri'] = (string) $row['principaluri']; - $components = []; - if ($row['components']) { - $components = explode(',',$row['components']); - } - - $calendar = [ - 'id' => $row['id'], - 'uri' => $row['uri'], - 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', - '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), - '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint), - ]; - - $calendar = $this->rowToCalendar($row, $calendar); - $calendar = $this->addOwnerPrincipalToCalendar($calendar); - $calendar = $this->addResourceTypeToCalendar($row, $calendar); + return $this->atomic(function () use ($principalUri) { + $principalUriOriginal = $principalUri; + $principalUri = $this->convertPrincipal($principalUri, true); + $fields = array_column($this->propertyMap, 0); + $fields[] = 'id'; + $fields[] = 'uri'; + $fields[] = 'synctoken'; + $fields[] = 'components'; + $fields[] = 'principaluri'; + $fields[] = 'transparent'; + + // Making fields a comma-delimited list + $query = $this->db->getQueryBuilder(); + $query->select($fields) + ->from('calendars') + ->orderBy('calendarorder', 'ASC'); - if (!isset($calendars[$calendar['id']])) { - $calendars[$calendar['id']] = $calendar; + if ($principalUri === '') { + $query->where($query->expr()->emptyString('principaluri')); + } else { + $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); } - } - $result->closeCursor(); - // query for shared calendars - $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); - $principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUriOriginal)); + $result = $query->executeQuery(); - $principals[] = $principalUri; + $calendars = []; + while ($row = $result->fetch()) { + $row['principaluri'] = (string)$row['principaluri']; + $components = []; + if ($row['components']) { + $components = explode(',', $row['components']); + } - $fields = array_column($this->propertyMap, 0); - $fields[] = 'a.id'; - $fields[] = 'a.uri'; - $fields[] = 'a.synctoken'; - $fields[] = 'a.components'; - $fields[] = 'a.principaluri'; - $fields[] = 'a.transparent'; - $fields[] = 's.access'; - $query = $this->db->getQueryBuilder(); - $query->select($fields) - ->from('dav_shares', 's') - ->join('s', 'calendars', 'a', $query->expr()->eq('s.resourceid', 'a.id')) - ->where($query->expr()->in('s.principaluri', $query->createParameter('principaluri'))) - ->andWhere($query->expr()->eq('s.type', $query->createParameter('type'))) - ->setParameter('type', 'calendar') - ->setParameter('principaluri', $principals, \Doctrine\DBAL\Connection::PARAM_STR_ARRAY); + $calendar = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), + '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint), + ]; - $result = $query->executeQuery(); + $calendar = $this->rowToCalendar($row, $calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); - $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'; - while ($row = $result->fetch()) { - $row['principaluri'] = (string) $row['principaluri']; - if ($row['principaluri'] === $principalUri) { - continue; + if (!isset($calendars[$calendar['id']])) { + $calendars[$calendar['id']] = $calendar; + } } + $result->closeCursor(); - $readOnly = (int) $row['access'] === Backend::ACCESS_READ; - if (isset($calendars[$row['id']])) { - if ($readOnly) { - // New share can not have more permissions then the old one. + // query for shared calendars + $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); + $principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUriOriginal)); + $principals[] = $principalUri; + + $fields = array_column($this->propertyMap, 0); + $fields = array_map(function (string $field) { + return 'a.' . $field; + }, $fields); + $fields[] = 'a.id'; + $fields[] = 'a.uri'; + $fields[] = 'a.synctoken'; + $fields[] = 'a.components'; + $fields[] = 'a.principaluri'; + $fields[] = 'a.transparent'; + $fields[] = 's.access'; + + $select = $this->db->getQueryBuilder(); + $subSelect = $this->db->getQueryBuilder(); + + $subSelect->select('resourceid') + ->from('dav_shares', 'd') + ->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)); + + $select->select($fields) + ->from('dav_shares', 's') + ->join('s', 'calendars', 'a', $select->expr()->eq('s.resourceid', 'a.id', IQueryBuilder::PARAM_INT)) + ->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)) + ->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('calendar', IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)) + ->andWhere($select->expr()->notIn('a.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY)); + + $results = $select->executeQuery(); + + $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'; + while ($row = $results->fetch()) { + $row['principaluri'] = (string)$row['principaluri']; + if ($row['principaluri'] === $principalUri) { continue; } - if (isset($calendars[$row['id']][$readOnlyPropertyName]) && - $calendars[$row['id']][$readOnlyPropertyName] === 0) { - // Old share is already read-write, no more permissions can be gained - continue; + + $readOnly = (int)$row['access'] === Backend::ACCESS_READ; + if (isset($calendars[$row['id']])) { + if ($readOnly) { + // New share can not have more permissions than the old one. + continue; + } + if (isset($calendars[$row['id']][$readOnlyPropertyName]) + && $calendars[$row['id']][$readOnlyPropertyName] === 0) { + // Old share is already read-write, no more permissions can be gained + continue; + } } - } - [, $name] = Uri\split($row['principaluri']); - $uri = $row['uri'] . '_shared_by_' . $name; - $row['displayname'] = $row['displayname'] . ' (' . $this->getUserDisplayName($name) . ')'; - $components = []; - if ($row['components']) { - $components = explode(',',$row['components']); - } - $calendar = [ - 'id' => $row['id'], - 'uri' => $uri, - 'principaluri' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', - '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), - '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp('transparent'), - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - $readOnlyPropertyName => $readOnly, - ]; + [, $name] = Uri\split($row['principaluri']); + $uri = $row['uri'] . '_shared_by_' . $name; + $row['displayname'] = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? ($name ?? '')) . ')'; + $components = []; + if ($row['components']) { + $components = explode(',', $row['components']); + } + $calendar = [ + 'id' => $row['id'], + 'uri' => $uri, + 'principaluri' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint), + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), + '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp('transparent'), + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), + $readOnlyPropertyName => $readOnly, + ]; - $calendar = $this->rowToCalendar($row, $calendar); - $calendar = $this->addOwnerPrincipalToCalendar($calendar); - $calendar = $this->addResourceTypeToCalendar($row, $calendar); + $calendar = $this->rowToCalendar($row, $calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); - $calendars[$calendar['id']] = $calendar; - } - $result->closeCursor(); + $calendars[$calendar['id']] = $calendar; + } + $result->closeCursor(); - return array_values($calendars); + return array_values($calendars); + }, $this->db); } /** @@ -499,17 +469,17 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $stmt = $query->executeQuery(); $calendars = []; while ($row = $stmt->fetch()) { - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; $components = []; if ($row['components']) { - $components = explode(',',$row['components']); + $components = explode(',', $row['components']); } $calendar = [ 'id' => $row['id'], 'uri' => $row['uri'], 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), ]; @@ -526,25 +496,6 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return array_values($calendars); } - - /** - * @param $uid - * @return string - */ - private function getUserDisplayName($uid) { - if (!isset($this->userDisplayNames[$uid])) { - $user = $this->userManager->get($uid); - - if ($user instanceof IUser) { - $this->userDisplayNames[$uid] = $user->getDisplayName(); - } else { - $this->userDisplayNames[$uid] = $uid; - } - } - - return $this->userDisplayNames[$uid]; - } - /** * @return array */ @@ -568,19 +519,19 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->executeQuery(); while ($row = $result->fetch()) { - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; [, $name] = Uri\split($row['principaluri']); $row['displayname'] = $row['displayname'] . "($name)"; $components = []; if ($row['components']) { - $components = explode(',',$row['components']); + $components = explode(',', $row['components']); } $calendar = [ 'id' => $row['id'], 'uri' => $row['publicuri'], 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], $this->legacyEndpoint), @@ -633,19 +584,19 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription throw new NotFound('Node with name \'' . $uri . '\' could not be found'); } - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; [, $name] = Uri\split($row['principaluri']); $row['displayname'] = $row['displayname'] . ' ' . "($name)"; $components = []; if ($row['components']) { - $components = explode(',',$row['components']); + $components = explode(',', $row['components']); } $calendar = [ 'id' => $row['id'], 'uri' => $row['publicuri'], 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), @@ -688,18 +639,18 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return null; } - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; $components = []; if ($row['components']) { - $components = explode(',',$row['components']); + $components = explode(',', $row['components']); } $calendar = [ 'id' => $row['id'], 'uri' => $row['uri'], 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), ]; @@ -712,10 +663,10 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** - * @param $calendarId + * @psalm-return CalendarInfo|null * @return array|null */ - public function getCalendarById($calendarId) { + public function getCalendarById(int $calendarId): ?array { $fields = array_column($this->propertyMap, 0); $fields[] = 'id'; $fields[] = 'uri'; @@ -737,18 +688,18 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return null; } - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; $components = []; if ($row['components']) { - $components = explode(',',$row['components']); + $components = explode(',', $row['components']); } $calendar = [ 'id' => $row['id'], 'uri' => $row['uri'], 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?? 0, '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), ]; @@ -785,7 +736,44 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return null; } - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; + $subscription = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'principaluri' => $row['principaluri'], + 'source' => $row['source'], + 'lastmodified' => $row['lastmodified'], + '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + ]; + + return $this->rowToSubscription($row, $subscription); + } + + public function getSubscriptionByUri(string $principal, string $uri): ?array { + $fields = array_column($this->subscriptionPropertyMap, 0); + $fields[] = 'id'; + $fields[] = 'uri'; + $fields[] = 'source'; + $fields[] = 'synctoken'; + $fields[] = 'principaluri'; + $fields[] = 'lastmodified'; + + $query = $this->db->getQueryBuilder(); + $query->select($fields) + ->from('calendarsubscriptions') + ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) + ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal))) + ->setMaxResults(1); + $stmt = $query->executeQuery(); + + $row = $stmt->fetch(); + $stmt->closeCursor(); + if ($row === false) { + return null; + } + + $row['principaluri'] = (string)$row['principaluri']; $subscription = [ 'id' => $row['id'], 'uri' => $row['uri'], @@ -793,7 +781,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'source' => $row['source'], 'lastmodified' => $row['lastmodified'], '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', ]; return $this->rowToSubscription($row, $subscription); @@ -809,14 +797,20 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $calendarUri * @param array $properties * @return int + * + * @throws CalendarException */ public function createCalendar($principalUri, $calendarUri, array $properties) { + if (strlen($calendarUri) > 255) { + throw new CalendarException('URI too long. Calendar not created'); + } + $values = [ 'principaluri' => $this->convertPrincipal($principalUri, true), 'uri' => $calendarUri, 'synctoken' => 1, 'transparent' => 0, - 'components' => 'VEVENT,VTODO', + 'components' => 'VEVENT,VTODO,VJOURNAL', 'displayname' => $calendarUri ]; @@ -826,7 +820,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription if (!($properties[$sccs] instanceof SupportedCalendarComponentSet)) { throw new DAV\Exception('The ' . $sccs . ' property must be of type: \Sabre\CalDAV\Property\SupportedCalendarComponentSet'); } - $values['components'] = implode(',',$properties[$sccs]->getValue()); + $values['components'] = implode(',', $properties[$sccs]->getValue()); } elseif (isset($properties['components'])) { // Allow to provide components internally without having // to create a SupportedCalendarComponentSet object @@ -835,7 +829,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $transp = '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp'; if (isset($properties[$transp])) { - $values['transparent'] = (int) ($properties[$transp]->getValue() === 'transparent'); + $values['transparent'] = (int)($properties[$transp]->getValue() === 'transparent'); } foreach ($this->propertyMap as $xmlName => [$dbName, $type]) { @@ -844,15 +838,19 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } - $query = $this->db->getQueryBuilder(); - $query->insert('calendars'); - foreach ($values as $column => $value) { - $query->setValue($column, $query->createNamedParameter($value)); - } - $query->executeStatement(); - $calendarId = $query->getLastInsertId(); + [$calendarId, $calendarData] = $this->atomic(function () use ($values) { + $query = $this->db->getQueryBuilder(); + $query->insert('calendars'); + foreach ($values as $column => $value) { + $query->setValue($column, $query->createNamedParameter($value)); + } + $query->executeStatement(); + $calendarId = $query->getLastInsertId(); + + $calendarData = $this->getCalendarById($calendarId); + return [$calendarId, $calendarData]; + }, $this->db); - $calendarData = $this->getCalendarById($calendarId); $this->dispatcher->dispatchTyped(new CalendarCreatedEvent((int)$calendarId, $calendarData)); return $calendarId; @@ -884,7 +882,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription switch ($propertyName) { case '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp': $fieldName = 'transparent'; - $newValues[$fieldName] = (int) ($propertyValue->getValue() === 'transparent'); + $newValues[$fieldName] = (int)($propertyValue->getValue() === 'transparent'); break; default: $fieldName = $this->propertyMap[$propertyName][0]; @@ -892,19 +890,23 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription break; } } - $query = $this->db->getQueryBuilder(); - $query->update('calendars'); - foreach ($newValues as $fieldName => $value) { - $query->set($fieldName, $query->createNamedParameter($value)); - } - $query->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))); - $query->executeStatement(); + [$calendarData, $shares] = $this->atomic(function () use ($calendarId, $newValues) { + $query = $this->db->getQueryBuilder(); + $query->update('calendars'); + foreach ($newValues as $fieldName => $value) { + $query->set($fieldName, $query->createNamedParameter($value)); + } + $query->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))); + $query->executeStatement(); - $this->addChange($calendarId, "", 2); + $this->addChanges($calendarId, [''], 2); - $calendarData = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); - $this->dispatcher->dispatchTyped(new CalendarUpdatedEvent((int)$calendarId, $calendarData, $shares, $mutations)); + $calendarData = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); + return [$calendarData, $shares]; + }, $this->db); + + $this->dispatcher->dispatchTyped(new CalendarUpdatedEvent($calendarId, $calendarData, $shares, $mutations)); return true; }); @@ -917,81 +919,162 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return void */ public function deleteCalendar($calendarId, bool $forceDeletePermanently = false) { - // The calendar is deleted right away if this is either enforced by the caller - // or the special contacts birthday calendar or when the preference of an empty - // retention (0 seconds) is set, which signals a disabled trashbin. - $calendarData = $this->getCalendarById($calendarId); - $isBirthdayCalendar = isset($calendarData['uri']) && $calendarData['uri'] === BirthdayService::BIRTHDAY_CALENDAR_URI; - $trashbinDisabled = $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0'; - if ($forceDeletePermanently || $isBirthdayCalendar || $trashbinDisabled) { + $this->atomic(function () use ($calendarId, $forceDeletePermanently): void { + // The calendar is deleted right away if this is either enforced by the caller + // or the special contacts birthday calendar or when the preference of an empty + // retention (0 seconds) is set, which signals a disabled trashbin. $calendarData = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); + $isBirthdayCalendar = isset($calendarData['uri']) && $calendarData['uri'] === BirthdayService::BIRTHDAY_CALENDAR_URI; + $trashbinDisabled = $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0'; + if ($forceDeletePermanently || $isBirthdayCalendar || $trashbinDisabled) { + $calendarData = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); - $qbDeleteCalendarObjectProps = $this->db->getQueryBuilder(); - $qbDeleteCalendarObjectProps->delete($this->dbObjectPropertiesTable) - ->where($qbDeleteCalendarObjectProps->expr()->eq('calendarid', $qbDeleteCalendarObjectProps->createNamedParameter($calendarId))) - ->andWhere($qbDeleteCalendarObjectProps->expr()->eq('calendartype', $qbDeleteCalendarObjectProps->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) - ->executeStatement(); + $this->purgeCalendarInvitations($calendarId); - $qbDeleteCalendarObjects = $this->db->getQueryBuilder(); - $qbDeleteCalendarObjects->delete('calendarobjects') - ->where($qbDeleteCalendarObjects->expr()->eq('calendarid', $qbDeleteCalendarObjects->createNamedParameter($calendarId))) - ->andWhere($qbDeleteCalendarObjects->expr()->eq('calendartype', $qbDeleteCalendarObjects->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) - ->executeStatement(); + $qbDeleteCalendarObjectProps = $this->db->getQueryBuilder(); + $qbDeleteCalendarObjectProps->delete($this->dbObjectPropertiesTable) + ->where($qbDeleteCalendarObjectProps->expr()->eq('calendarid', $qbDeleteCalendarObjectProps->createNamedParameter($calendarId))) + ->andWhere($qbDeleteCalendarObjectProps->expr()->eq('calendartype', $qbDeleteCalendarObjectProps->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) + ->executeStatement(); - $qbDeleteCalendarChanges = $this->db->getQueryBuilder(); - $qbDeleteCalendarObjects->delete('calendarchanges') - ->where($qbDeleteCalendarChanges->expr()->eq('calendarid', $qbDeleteCalendarChanges->createNamedParameter($calendarId))) - ->andWhere($qbDeleteCalendarChanges->expr()->eq('calendartype', $qbDeleteCalendarChanges->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) - ->executeStatement(); + $qbDeleteCalendarObjects = $this->db->getQueryBuilder(); + $qbDeleteCalendarObjects->delete('calendarobjects') + ->where($qbDeleteCalendarObjects->expr()->eq('calendarid', $qbDeleteCalendarObjects->createNamedParameter($calendarId))) + ->andWhere($qbDeleteCalendarObjects->expr()->eq('calendartype', $qbDeleteCalendarObjects->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) + ->executeStatement(); - $this->calendarSharingBackend->deleteAllShares($calendarId); + $qbDeleteCalendarChanges = $this->db->getQueryBuilder(); + $qbDeleteCalendarChanges->delete('calendarchanges') + ->where($qbDeleteCalendarChanges->expr()->eq('calendarid', $qbDeleteCalendarChanges->createNamedParameter($calendarId))) + ->andWhere($qbDeleteCalendarChanges->expr()->eq('calendartype', $qbDeleteCalendarChanges->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) + ->executeStatement(); - $qbDeleteCalendar = $this->db->getQueryBuilder(); - $qbDeleteCalendarObjects->delete('calendars') - ->where($qbDeleteCalendar->expr()->eq('id', $qbDeleteCalendar->createNamedParameter($calendarId))) - ->executeStatement(); + $this->calendarSharingBackend->deleteAllShares($calendarId); - // Only dispatch if we actually deleted anything - if ($calendarData) { - $this->dispatcher->dispatchTyped(new CalendarDeletedEvent((int)$calendarId, $calendarData, $shares)); - } - } else { - $qbMarkCalendarDeleted = $this->db->getQueryBuilder(); - $qbMarkCalendarDeleted->update('calendars') - ->set('deleted_at', $qbMarkCalendarDeleted->createNamedParameter(time())) - ->where($qbMarkCalendarDeleted->expr()->eq('id', $qbMarkCalendarDeleted->createNamedParameter($calendarId))) - ->executeStatement(); + $qbDeleteCalendar = $this->db->getQueryBuilder(); + $qbDeleteCalendar->delete('calendars') + ->where($qbDeleteCalendar->expr()->eq('id', $qbDeleteCalendar->createNamedParameter($calendarId))) + ->executeStatement(); - $calendarData = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); - if ($calendarData) { - $this->dispatcher->dispatchTyped(new CalendarMovedToTrashEvent( - (int)$calendarId, - $calendarData, - $shares - )); + // Only dispatch if we actually deleted anything + if ($calendarData) { + $this->dispatcher->dispatchTyped(new CalendarDeletedEvent($calendarId, $calendarData, $shares)); + } + } else { + $qbMarkCalendarDeleted = $this->db->getQueryBuilder(); + $qbMarkCalendarDeleted->update('calendars') + ->set('deleted_at', $qbMarkCalendarDeleted->createNamedParameter(time())) + ->where($qbMarkCalendarDeleted->expr()->eq('id', $qbMarkCalendarDeleted->createNamedParameter($calendarId))) + ->executeStatement(); + + $calendarData = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); + if ($calendarData) { + $this->dispatcher->dispatchTyped(new CalendarMovedToTrashEvent( + $calendarId, + $calendarData, + $shares + )); + } } - } + }, $this->db); } public function restoreCalendar(int $id): void { + $this->atomic(function () use ($id): void { + $qb = $this->db->getQueryBuilder(); + $update = $qb->update('calendars') + ->set('deleted_at', $qb->createNamedParameter(null)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $update->executeStatement(); + + $calendarData = $this->getCalendarById($id); + $shares = $this->getShares($id); + if ($calendarData === null) { + throw new RuntimeException('Calendar data that was just written can\'t be read back. Check your database configuration.'); + } + $this->dispatcher->dispatchTyped(new CalendarRestoredEvent( + $id, + $calendarData, + $shares + )); + }, $this->db); + } + + /** + * Returns all calendar entries as a stream of data + * + * @since 32.0.0 + * + * @return Generator<array> + */ + public function exportCalendar(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, ?CalendarExportOptions $options = null): Generator { + // extract options + $rangeStart = $options?->getRangeStart(); + $rangeCount = $options?->getRangeCount(); + // construct query $qb = $this->db->getQueryBuilder(); - $update = $qb->update('calendars') - ->set('deleted_at', $qb->createNamedParameter(null)) - ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); - $update->executeStatement(); + $qb->select('*') + ->from('calendarobjects') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->isNull('deleted_at')); + if ($rangeStart !== null) { + $qb->andWhere($qb->expr()->gt('uid', $qb->createNamedParameter($rangeStart))); + } + if ($rangeCount !== null) { + $qb->setMaxResults($rangeCount); + } + if ($rangeStart !== null || $rangeCount !== null) { + $qb->orderBy('uid', 'ASC'); + } + $rs = $qb->executeQuery(); + // iterate through results + try { + while (($row = $rs->fetch()) !== false) { + yield $row; + } + } finally { + $rs->closeCursor(); + } + } + + /** + * Returns all calendar objects with limited metadata for a calendar + * + * Every item contains an array with the following keys: + * * id - the table row id + * * etag - An arbitrary string + * * uri - a unique key which will be used to construct the uri. This can + * be any arbitrary string. + * * calendardata - The iCalendar-compatible calendar data + * + * @param mixed $calendarId + * @param int $calendarType + * @return array + */ + public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR):array { + $query = $this->db->getQueryBuilder(); + $query->select(['id','uid', 'etag', 'uri', 'calendardata']) + ->from('calendarobjects') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) + ->andWhere($query->expr()->isNull('deleted_at')); + $stmt = $query->executeQuery(); - $calendarData = $this->getCalendarById($id); - $shares = $this->getShares($id); - if ($calendarData === null) { - throw new RuntimeException('Calendar data that was just written can\'t be read back. Check your database configuration.'); + $result = []; + while (($row = $stmt->fetch()) !== false) { + $result[$row['uid']] = [ + 'id' => $row['id'], + 'etag' => $row['etag'], + 'uri' => $row['uri'], + 'calendardata' => $row['calendardata'], + ]; } - $this->dispatcher->dispatchTyped(new CalendarRestoredEvent( - $id, - $calendarData, - $shares - )); + $stmt->closeCursor(); + + return $result; } /** @@ -1046,7 +1129,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $stmt = $query->executeQuery(); $result = []; - foreach ($stmt->fetchAll() as $row) { + while (($row = $stmt->fetch()) !== false) { $result[] = [ 'id' => $row['id'], 'uri' => $row['uri'], @@ -1073,18 +1156,18 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $stmt = $query->executeQuery(); $result = []; - foreach ($stmt->fetchAll() as $row) { + while (($row = $stmt->fetch()) !== false) { $result[] = [ 'id' => $row['id'], 'uri' => $row['uri'], 'lastmodified' => $row['lastmodified'], 'etag' => '"' . $row['etag'] . '"', - 'calendarid' => (int) $row['calendarid'], - 'calendartype' => (int) $row['calendartype'], - 'size' => (int) $row['size'], + 'calendarid' => (int)$row['calendarid'], + 'calendartype' => (int)$row['calendartype'], + 'size' => (int)$row['size'], 'component' => strtolower($row['componenttype']), - 'classification' => (int) $row['classification'], - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int) $row['deleted_at'], + 'classification' => (int)$row['classification'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'], ]; } $stmt->closeCursor(); @@ -1123,7 +1206,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'size' => (int)$row['size'], 'component' => strtolower($row['componenttype']), 'classification' => (int)$row['classification'], - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int) $row['deleted_at'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'], ]; } $stmt->closeCursor(); @@ -1149,8 +1232,12 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return array|null */ public function getCalendarObject($calendarId, $objectUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR) { + $key = $calendarId . '::' . $objectUri . '::' . $calendarType; + if (isset($this->cachedObjects[$key])) { + return $this->cachedObjects[$key]; + } $query = $this->db->getQueryBuilder(); - $query->select(['id', 'uri', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification']) + $query->select(['id', 'uri', 'uid', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification', 'deleted_at']) ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) @@ -1163,16 +1250,24 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return null; } + $object = $this->rowToCalendarObject($row); + $this->cachedObjects[$key] = $object; + return $object; + } + + private function rowToCalendarObject(array $row): array { return [ 'id' => $row['id'], 'uri' => $row['uri'], + 'uid' => $row['uid'], 'lastmodified' => $row['lastmodified'], 'etag' => '"' . $row['etag'] . '"', 'calendarid' => $row['calendarid'], 'size' => (int)$row['size'], 'calendardata' => $this->readBlob($row['calendardata']), 'component' => strtolower($row['componenttype']), - 'classification' => (int)$row['classification'] + 'classification' => (int)$row['classification'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'], ]; } @@ -1248,81 +1343,79 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return string */ public function createCalendarObject($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + $this->cachedObjects = []; $extraData = $this->getDenormalizedData($calendarData); - // Try to detect duplicates - $qb = $this->db->getQueryBuilder(); - $qb->select($qb->func()->count('*')) - ->from('calendarobjects') - ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) - ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($extraData['uid']))) - ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) - ->andWhere($qb->expr()->isNull('deleted_at')); - $result = $qb->executeQuery(); - $count = (int) $result->fetchOne(); - $result->closeCursor(); + return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) { + // Try to detect duplicates + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('calendarobjects') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($extraData['uid']))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->isNull('deleted_at')); + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); - if ($count !== 0) { - throw new BadRequest('Calendar object with uid already exists in this calendar collection.'); - } - // For a more specific error message we also try to explicitly look up the UID but as a deleted entry - $qbDel = $this->db->getQueryBuilder(); - $qbDel->select($qb->func()->count('*')) - ->from('calendarobjects') - ->where($qbDel->expr()->eq('calendarid', $qbDel->createNamedParameter($calendarId))) - ->andWhere($qbDel->expr()->eq('uid', $qbDel->createNamedParameter($extraData['uid']))) - ->andWhere($qbDel->expr()->eq('calendartype', $qbDel->createNamedParameter($calendarType))) - ->andWhere($qbDel->expr()->isNotNull('deleted_at')); - $result = $qbDel->executeQuery(); - $count = (int) $result->fetchOne(); - $result->closeCursor(); - if ($count !== 0) { - throw new BadRequest('Deleted calendar object with uid already exists in this calendar collection.'); - } + if ($count !== 0) { + throw new BadRequest('Calendar object with uid already exists in this calendar collection.'); + } + // For a more specific error message we also try to explicitly look up the UID but as a deleted entry + $qbDel = $this->db->getQueryBuilder(); + $qbDel->select('*') + ->from('calendarobjects') + ->where($qbDel->expr()->eq('calendarid', $qbDel->createNamedParameter($calendarId))) + ->andWhere($qbDel->expr()->eq('uid', $qbDel->createNamedParameter($extraData['uid']))) + ->andWhere($qbDel->expr()->eq('calendartype', $qbDel->createNamedParameter($calendarType))) + ->andWhere($qbDel->expr()->isNotNull('deleted_at')); + $result = $qbDel->executeQuery(); + $found = $result->fetch(); + $result->closeCursor(); + if ($found !== false) { + // the object existed previously but has been deleted + // remove the trashbin entry and continue as if it was a new object + $this->deleteCalendarObject($calendarId, $found['uri']); + } - $query = $this->db->getQueryBuilder(); - $query->insert('calendarobjects') - ->values([ - 'calendarid' => $query->createNamedParameter($calendarId), - 'uri' => $query->createNamedParameter($objectUri), - 'calendardata' => $query->createNamedParameter($calendarData, IQueryBuilder::PARAM_LOB), - 'lastmodified' => $query->createNamedParameter(time()), - 'etag' => $query->createNamedParameter($extraData['etag']), - 'size' => $query->createNamedParameter($extraData['size']), - 'componenttype' => $query->createNamedParameter($extraData['componentType']), - 'firstoccurence' => $query->createNamedParameter($extraData['firstOccurence']), - 'lastoccurence' => $query->createNamedParameter($extraData['lastOccurence']), - 'classification' => $query->createNamedParameter($extraData['classification']), - 'uid' => $query->createNamedParameter($extraData['uid']), - 'calendartype' => $query->createNamedParameter($calendarType), - ]) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->insert('calendarobjects') + ->values([ + 'calendarid' => $query->createNamedParameter($calendarId), + 'uri' => $query->createNamedParameter($objectUri), + 'calendardata' => $query->createNamedParameter($calendarData, IQueryBuilder::PARAM_LOB), + 'lastmodified' => $query->createNamedParameter(time()), + 'etag' => $query->createNamedParameter($extraData['etag']), + 'size' => $query->createNamedParameter($extraData['size']), + 'componenttype' => $query->createNamedParameter($extraData['componentType']), + 'firstoccurence' => $query->createNamedParameter($extraData['firstOccurence']), + 'lastoccurence' => $query->createNamedParameter($extraData['lastOccurence']), + 'classification' => $query->createNamedParameter($extraData['classification']), + 'uid' => $query->createNamedParameter($extraData['uid']), + 'calendartype' => $query->createNamedParameter($calendarType), + ]) + ->executeStatement(); - $this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType); - $this->addChange($calendarId, $objectUri, 1, $calendarType); + $this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType); + $this->addChanges($calendarId, [$objectUri], 1, $calendarType); - $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType); - if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { - $calendarRow = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); + $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType); + assert($objectRow !== null); - $this->dispatcher->dispatchTyped(new CalendarObjectCreatedEvent((int)$calendarId, $calendarRow, $shares, $objectRow)); - } else { - $subscriptionRow = $this->getSubscriptionById($calendarId); - - $this->dispatcher->dispatchTyped(new CachedCalendarObjectCreatedEvent((int)$calendarId, $subscriptionRow, [], $objectRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::createCachedCalendarObject', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::createCachedCalendarObject', - [ - 'subscriptionId' => $calendarId, - 'calendarData' => $subscriptionRow, - 'shares' => [], - 'objectData' => $objectRow, - ] - )); - } + if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { + $calendarRow = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); + + $this->dispatcher->dispatchTyped(new CalendarObjectCreatedEvent($calendarId, $calendarRow, $shares, $objectRow)); + } else { + $subscriptionRow = $this->getSubscriptionById($calendarId); - return '"' . $extraData['etag'] . '"'; + $this->dispatcher->dispatchTyped(new CachedCalendarObjectCreatedEvent($calendarId, $subscriptionRow, [], $objectRow)); + } + + return '"' . $extraData['etag'] . '"'; + }, $this->db); } /** @@ -1345,9 +1438,12 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return string */ public function updateCalendarObject($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + $this->cachedObjects = []; $extraData = $this->getDenormalizedData($calendarData); - $query = $this->db->getQueryBuilder(); - $query->update('calendarobjects') + + return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) { + $query = $this->db->getQueryBuilder(); + $query->update('calendarobjects') ->set('calendardata', $query->createNamedParameter($calendarData, IQueryBuilder::PARAM_LOB)) ->set('lastmodified', $query->createNamedParameter(time())) ->set('etag', $query->createNamedParameter($extraData['etag'])) @@ -1357,105 +1453,88 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->set('lastoccurence', $query->createNamedParameter($extraData['lastOccurence'])) ->set('classification', $query->createNamedParameter($extraData['classification'])) ->set('uid', $query->createNamedParameter($extraData['uid'])) - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) - ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) - ->executeStatement(); + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) + ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) + ->executeStatement(); - $this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType); - $this->addChange($calendarId, $objectUri, 2, $calendarType); + $this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType); + $this->addChanges($calendarId, [$objectUri], 2, $calendarType); - $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType); - if (is_array($objectRow)) { - if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { - $calendarRow = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); + $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType); + if (is_array($objectRow)) { + if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { + $calendarRow = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); - $this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent((int)$calendarId, $calendarRow, $shares, $objectRow)); - } else { - $subscriptionRow = $this->getSubscriptionById($calendarId); + $this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent($calendarId, $calendarRow, $shares, $objectRow)); + } else { + $subscriptionRow = $this->getSubscriptionById($calendarId); - $this->dispatcher->dispatchTyped(new CachedCalendarObjectUpdatedEvent((int)$calendarId, $subscriptionRow, [], $objectRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateCachedCalendarObject', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateCachedCalendarObject', - [ - 'subscriptionId' => $calendarId, - 'calendarData' => $subscriptionRow, - 'shares' => [], - 'objectData' => $objectRow, - ] - )); + $this->dispatcher->dispatchTyped(new CachedCalendarObjectUpdatedEvent($calendarId, $subscriptionRow, [], $objectRow)); + } } - } - return '"' . $extraData['etag'] . '"'; + return '"' . $extraData['etag'] . '"'; + }, $this->db); } /** * Moves a calendar object from calendar to calendar. * - * @param int $sourceCalendarId + * @param string $sourcePrincipalUri + * @param int $sourceObjectId + * @param string $targetPrincipalUri * @param int $targetCalendarId - * @param int $objectId - * @param string $principalUri + * @param string $tragetObjectUri * @param int $calendarType * @return bool * @throws Exception */ - public function moveCalendarObject(int $sourceCalendarId, int $targetCalendarId, int $objectId, string $principalUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR): bool { - $object = $this->getCalendarObjectById($principalUri, $objectId); - if (empty($object)) { - return false; - } - - $query = $this->db->getQueryBuilder(); - $query->update('calendarobjects') - ->set('calendarid', $query->createNamedParameter($targetCalendarId, IQueryBuilder::PARAM_INT)) - ->where($query->expr()->eq('id', $query->createNamedParameter($objectId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) - ->executeStatement(); + public function moveCalendarObject(string $sourcePrincipalUri, int $sourceObjectId, string $targetPrincipalUri, int $targetCalendarId, string $tragetObjectUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR): bool { + $this->cachedObjects = []; + return $this->atomic(function () use ($sourcePrincipalUri, $sourceObjectId, $targetPrincipalUri, $targetCalendarId, $tragetObjectUri, $calendarType) { + $object = $this->getCalendarObjectById($sourcePrincipalUri, $sourceObjectId); + if (empty($object)) { + return false; + } - $this->purgeProperties($sourceCalendarId, $objectId); - $this->updateProperties($targetCalendarId, $object['uri'], $object['calendardata'], $calendarType); + $sourceCalendarId = $object['calendarid']; + $sourceObjectUri = $object['uri']; - $this->addChange($sourceCalendarId, $object['uri'], 1, $calendarType); - $this->addChange($targetCalendarId, $object['uri'], 3, $calendarType); + $query = $this->db->getQueryBuilder(); + $query->update('calendarobjects') + ->set('calendarid', $query->createNamedParameter($targetCalendarId, IQueryBuilder::PARAM_INT)) + ->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR)) + ->where($query->expr()->eq('id', $query->createNamedParameter($sourceObjectId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->executeStatement(); - $object = $this->getCalendarObjectById($principalUri, $objectId); - // Calendar Object wasn't found - possibly because it was deleted in the meantime by a different client - if (empty($object)) { - return false; - } + $this->purgeProperties($sourceCalendarId, $sourceObjectId); + $this->updateProperties($targetCalendarId, $tragetObjectUri, $object['calendardata'], $calendarType); - $calendarRow = $this->getCalendarById($targetCalendarId); - // the calendar this event is being moved to does not exist any longer - if (empty($calendarRow)) { - return false; - } + $this->addChanges($sourceCalendarId, [$sourceObjectUri], 3, $calendarType); + $this->addChanges($targetCalendarId, [$tragetObjectUri], 1, $calendarType); - if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { - $shares = $this->getShares($targetCalendarId); - $this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent($targetCalendarId, $calendarRow, $shares, $object)); - } - return true; - } + $object = $this->getCalendarObjectById($targetPrincipalUri, $sourceObjectId); + // Calendar Object wasn't found - possibly because it was deleted in the meantime by a different client + if (empty($object)) { + return false; + } + $targetCalendarRow = $this->getCalendarById($targetCalendarId); + // the calendar this event is being moved to does not exist any longer + if (empty($targetCalendarRow)) { + return false; + } - /** - * @param int $calendarObjectId - * @param int $classification - */ - public function setClassification($calendarObjectId, $classification) { - if (!in_array($classification, [ - self::CLASSIFICATION_PUBLIC, self::CLASSIFICATION_PRIVATE, self::CLASSIFICATION_CONFIDENTIAL - ])) { - throw new \InvalidArgumentException(); - } - $query = $this->db->getQueryBuilder(); - $query->update('calendarobjects') - ->set('classification', $query->createNamedParameter($classification)) - ->where($query->expr()->eq('id', $query->createNamedParameter($calendarObjectId))) - ->executeStatement(); + if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { + $sourceShares = $this->getShares($sourceCalendarId); + $targetShares = $this->getShares($targetCalendarId); + $sourceCalendarRow = $this->getCalendarById($sourceCalendarId); + $this->dispatcher->dispatchTyped(new CalendarObjectMovedEvent($sourceCalendarId, $sourceCalendarRow, $targetCalendarId, $targetCalendarRow, $sourceShares, $targetShares, $object)); + } + return true; + }, $this->db); } /** @@ -1470,86 +1549,82 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return void */ public function deleteCalendarObject($calendarId, $objectUri, $calendarType = self::CALENDAR_TYPE_CALENDAR, bool $forceDeletePermanently = false) { - $data = $this->getCalendarObject($calendarId, $objectUri, $calendarType); + $this->cachedObjects = []; + $this->atomic(function () use ($calendarId, $objectUri, $calendarType, $forceDeletePermanently): void { + $data = $this->getCalendarObject($calendarId, $objectUri, $calendarType); - if ($data === null) { - // Nothing to delete - return; - } + if ($data === null) { + // Nothing to delete + return; + } - if ($forceDeletePermanently || $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0') { - $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `uri` = ? AND `calendartype` = ?'); - $stmt->execute([$calendarId, $objectUri, $calendarType]); + if ($forceDeletePermanently || $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0') { + $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `uri` = ? AND `calendartype` = ?'); + $stmt->execute([$calendarId, $objectUri, $calendarType]); - $this->purgeProperties($calendarId, $data['id']); + $this->purgeProperties($calendarId, $data['id']); - if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { - $calendarRow = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); + $this->purgeObjectInvitations($data['uid']); - $this->dispatcher->dispatchTyped(new CalendarObjectDeletedEvent((int)$calendarId, $calendarRow, $shares, $data)); - } else { - $subscriptionRow = $this->getSubscriptionById($calendarId); + if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { + $calendarRow = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); - $this->dispatcher->dispatchTyped(new CachedCalendarObjectDeletedEvent((int)$calendarId, $subscriptionRow, [], $data)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::deleteCachedCalendarObject', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::deleteCachedCalendarObject', - [ - 'subscriptionId' => $calendarId, - 'calendarData' => $subscriptionRow, - 'shares' => [], - 'objectData' => $data, - ] - )); - } - } else { - $pathInfo = pathinfo($data['uri']); - if (!empty($pathInfo['extension'])) { - // Append a suffix to "free" the old URI for recreation - $newUri = sprintf( - "%s-deleted.%s", - $pathInfo['filename'], - $pathInfo['extension'] - ); - } else { - $newUri = sprintf( - "%s-deleted", - $pathInfo['filename'] - ); - } + $this->dispatcher->dispatchTyped(new CalendarObjectDeletedEvent($calendarId, $calendarRow, $shares, $data)); + } else { + $subscriptionRow = $this->getSubscriptionById($calendarId); - // Try to detect conflicts before the DB does - // As unlikely as it seems, this can happen when the user imports, then deletes, imports and deletes again - $newObject = $this->getCalendarObject($calendarId, $newUri, $calendarType); - if ($newObject !== null) { - throw new Forbidden("A calendar object with URI $newUri already exists in calendar $calendarId, therefore this object can't be moved into the trashbin"); - } + $this->dispatcher->dispatchTyped(new CachedCalendarObjectDeletedEvent($calendarId, $subscriptionRow, [], $data)); + } + } else { + $pathInfo = pathinfo($data['uri']); + if (!empty($pathInfo['extension'])) { + // Append a suffix to "free" the old URI for recreation + $newUri = sprintf( + '%s-deleted.%s', + $pathInfo['filename'], + $pathInfo['extension'] + ); + } else { + $newUri = sprintf( + '%s-deleted', + $pathInfo['filename'] + ); + } - $qb = $this->db->getQueryBuilder(); - $markObjectDeletedQuery = $qb->update('calendarobjects') - ->set('deleted_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)) - ->set('uri', $qb->createNamedParameter($newUri)) - ->where( - $qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)), - $qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), - $qb->expr()->eq('uri', $qb->createNamedParameter($objectUri)) - ); - $markObjectDeletedQuery->executeStatement(); + // Try to detect conflicts before the DB does + // As unlikely as it seems, this can happen when the user imports, then deletes, imports and deletes again + $newObject = $this->getCalendarObject($calendarId, $newUri, $calendarType); + if ($newObject !== null) { + throw new Forbidden("A calendar object with URI $newUri already exists in calendar $calendarId, therefore this object can't be moved into the trashbin"); + } - $calendarData = $this->getCalendarById($calendarId); - if ($calendarData !== null) { - $this->dispatcher->dispatchTyped( - new CalendarObjectMovedToTrashEvent( - (int)$calendarId, - $calendarData, - $this->getShares($calendarId), - $data - ) - ); + $qb = $this->db->getQueryBuilder(); + $markObjectDeletedQuery = $qb->update('calendarobjects') + ->set('deleted_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)) + ->set('uri', $qb->createNamedParameter($newUri)) + ->where( + $qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)), + $qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $qb->expr()->eq('uri', $qb->createNamedParameter($objectUri)) + ); + $markObjectDeletedQuery->executeStatement(); + + $calendarData = $this->getCalendarById($calendarId); + if ($calendarData !== null) { + $this->dispatcher->dispatchTyped( + new CalendarObjectMovedToTrashEvent( + $calendarId, + $calendarData, + $this->getShares($calendarId), + $data + ) + ); + } } - } - $this->addChange($calendarId, $objectUri, 3, $calendarType); + $this->addChanges($calendarId, [$objectUri], 3, $calendarType); + }, $this->db); } /** @@ -1558,50 +1633,53 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @throws Forbidden */ public function restoreCalendarObject(array $objectData): void { - $id = (int) $objectData['id']; - $restoreUri = str_replace("-deleted.ics", ".ics", $objectData['uri']); - $targetObject = $this->getCalendarObject( - $objectData['calendarid'], - $restoreUri - ); - if ($targetObject !== null) { - throw new Forbidden("Can not restore calendar $id because a calendar object with the URI $restoreUri already exists"); - } + $this->cachedObjects = []; + $this->atomic(function () use ($objectData): void { + $id = (int)$objectData['id']; + $restoreUri = str_replace('-deleted.ics', '.ics', $objectData['uri']); + $targetObject = $this->getCalendarObject( + $objectData['calendarid'], + $restoreUri + ); + if ($targetObject !== null) { + throw new Forbidden("Can not restore calendar $id because a calendar object with the URI $restoreUri already exists"); + } - $qb = $this->db->getQueryBuilder(); - $update = $qb->update('calendarobjects') - ->set('uri', $qb->createNamedParameter($restoreUri)) - ->set('deleted_at', $qb->createNamedParameter(null)) - ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); - $update->executeStatement(); - - // Make sure this change is tracked in the changes table - $qb2 = $this->db->getQueryBuilder(); - $selectObject = $qb2->select('calendardata', 'uri', 'calendarid', 'calendartype') - ->selectAlias('componenttype', 'component') - ->from('calendarobjects') - ->where($qb2->expr()->eq('id', $qb2->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); - $result = $selectObject->executeQuery(); - $row = $result->fetch(); - $result->closeCursor(); - if ($row === false) { - // Welp, this should possibly not have happened, but let's ignore - return; - } - $this->addChange($row['calendarid'], $row['uri'], 1, (int) $row['calendartype']); + $qb = $this->db->getQueryBuilder(); + $update = $qb->update('calendarobjects') + ->set('uri', $qb->createNamedParameter($restoreUri)) + ->set('deleted_at', $qb->createNamedParameter(null)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $update->executeStatement(); + + // Make sure this change is tracked in the changes table + $qb2 = $this->db->getQueryBuilder(); + $selectObject = $qb2->select('calendardata', 'uri', 'calendarid', 'calendartype') + ->selectAlias('componenttype', 'component') + ->from('calendarobjects') + ->where($qb2->expr()->eq('id', $qb2->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $result = $selectObject->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + if ($row === false) { + // Welp, this should possibly not have happened, but let's ignore + return; + } + $this->addChanges($row['calendarid'], [$row['uri']], 1, (int)$row['calendartype']); - $calendarRow = $this->getCalendarById((int) $row['calendarid']); - if ($calendarRow === null) { - throw new RuntimeException('Calendar object data that was just written can\'t be read back. Check your database configuration.'); - } - $this->dispatcher->dispatchTyped( - new CalendarObjectRestoredEvent( - (int) $objectData['calendarid'], - $calendarRow, - $this->getShares((int) $row['calendarid']), - $row - ) - ); + $calendarRow = $this->getCalendarById((int)$row['calendarid']); + if ($calendarRow === null) { + throw new RuntimeException('Calendar object data that was just written can\'t be read back. Check your database configuration.'); + } + $this->dispatcher->dispatchTyped( + new CalendarObjectRestoredEvent( + (int)$objectData['calendarid'], + $calendarRow, + $this->getShares((int)$row['calendarid']), + $row + ) + ); + }, $this->db); } /** @@ -1644,7 +1722,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * Note that especially time-range-filters may be difficult to parse. A * time-range filter specified on a VEVENT must for instance also handle * recurrence rules correctly. - * A good example of how to interprete all these filters can also simply + * A good example of how to interpret all these filters can also simply * be found in Sabre\CalDAV\CalendarQueryFilter. This class is as correct * as possible, so it gives you a good idea on what type of stuff you need * to think of. @@ -1683,12 +1761,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } } - $columns = ['uri']; - if ($requirePostFilter) { - $columns = ['uri', 'calendardata']; - } $query = $this->db->getQueryBuilder(); - $query->select($columns) + $query->select(['id', 'uri', 'uid', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification', 'deleted_at']) ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) @@ -1709,21 +1783,32 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $result = []; while ($row = $stmt->fetch()) { + // if we leave it as a blob we can't read it both from the post filter and the rowToCalendarObject + if (isset($row['calendardata'])) { + $row['calendardata'] = $this->readBlob($row['calendardata']); + } + if ($requirePostFilter) { // validateFilterForObject will parse the calendar data // catch parsing errors try { $matches = $this->validateFilterForObject($row, $filters); } catch (ParseException $ex) { - $this->logger->logException($ex, [ + $this->logger->error('Caught parsing exception for calendar data. This usually indicates invalid calendar data. calendar-id:' . $calendarId . ' uri:' . $row['uri'], [ 'app' => 'dav', - 'message' => 'Caught parsing exception for calendar data. This usually indicates invalid calendar data. calendar-id:'.$calendarId.' uri:'.$row['uri'] + 'exception' => $ex, ]); continue; } catch (InvalidDataException $ex) { - $this->logger->logException($ex, [ + $this->logger->error('Caught invalid data exception for calendar data. This usually indicates invalid calendar data. calendar-id:' . $calendarId . ' uri:' . $row['uri'], [ + 'app' => 'dav', + 'exception' => $ex, + ]); + continue; + } catch (MaxInstancesExceededException $ex) { + $this->logger->warning('Caught max instances exceeded exception for calendar data. This usually indicates too much recurring (more than 3500) event in calendar data. Object uri: ' . $row['uri'], [ 'app' => 'dav', - 'message' => 'Caught invalid data exception for calendar data. This usually indicates invalid calendar data. calendar-id:'.$calendarId.' uri:'.$row['uri'] + 'exception' => $ex, ]); continue; } @@ -1733,6 +1818,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } $result[] = $row['uri']; + $key = $calendarId . '::' . $row['uri'] . '::' . $calendarType; + $this->cachedObjects[$key] = $this->rowToCalendarObject($row); } return $result; @@ -1750,118 +1837,120 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return array */ public function calendarSearch($principalUri, array $filters, $limit = null, $offset = null) { - $calendars = $this->getCalendarsForUser($principalUri); - $ownCalendars = []; - $sharedCalendars = []; + return $this->atomic(function () use ($principalUri, $filters, $limit, $offset) { + $calendars = $this->getCalendarsForUser($principalUri); + $ownCalendars = []; + $sharedCalendars = []; - $uriMapper = []; + $uriMapper = []; - foreach ($calendars as $calendar) { - if ($calendar['{http://owncloud.org/ns}owner-principal'] === $principalUri) { - $ownCalendars[] = $calendar['id']; - } else { - $sharedCalendars[] = $calendar['id']; + foreach ($calendars as $calendar) { + if ($calendar['{http://owncloud.org/ns}owner-principal'] === $principalUri) { + $ownCalendars[] = $calendar['id']; + } else { + $sharedCalendars[] = $calendar['id']; + } + $uriMapper[$calendar['id']] = $calendar['uri']; + } + if (count($ownCalendars) === 0 && count($sharedCalendars) === 0) { + return []; } - $uriMapper[$calendar['id']] = $calendar['uri']; - } - if (count($ownCalendars) === 0 && count($sharedCalendars) === 0) { - return []; - } - $query = $this->db->getQueryBuilder(); - // Calendar id expressions - $calendarExpressions = []; - foreach ($ownCalendars as $id) { - $calendarExpressions[] = $query->expr()->andX( - $query->expr()->eq('c.calendarid', - $query->createNamedParameter($id)), - $query->expr()->eq('c.calendartype', + $query = $this->db->getQueryBuilder(); + // Calendar id expressions + $calendarExpressions = []; + foreach ($ownCalendars as $id) { + $calendarExpressions[] = $query->expr()->andX( + $query->expr()->eq('c.calendarid', + $query->createNamedParameter($id)), + $query->expr()->eq('c.calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))); - } - foreach ($sharedCalendars as $id) { - $calendarExpressions[] = $query->expr()->andX( - $query->expr()->eq('c.calendarid', - $query->createNamedParameter($id)), - $query->expr()->eq('c.classification', - $query->createNamedParameter(self::CLASSIFICATION_PUBLIC)), - $query->expr()->eq('c.calendartype', - $query->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))); - } - - if (count($calendarExpressions) === 1) { - $calExpr = $calendarExpressions[0]; - } else { - $calExpr = call_user_func_array([$query->expr(), 'orX'], $calendarExpressions); - } + } + foreach ($sharedCalendars as $id) { + $calendarExpressions[] = $query->expr()->andX( + $query->expr()->eq('c.calendarid', + $query->createNamedParameter($id)), + $query->expr()->eq('c.classification', + $query->createNamedParameter(self::CLASSIFICATION_PUBLIC)), + $query->expr()->eq('c.calendartype', + $query->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))); + } - // Component expressions - $compExpressions = []; - foreach ($filters['comps'] as $comp) { - $compExpressions[] = $query->expr() - ->eq('c.componenttype', $query->createNamedParameter($comp)); - } + if (count($calendarExpressions) === 1) { + $calExpr = $calendarExpressions[0]; + } else { + $calExpr = call_user_func_array([$query->expr(), 'orX'], $calendarExpressions); + } - if (count($compExpressions) === 1) { - $compExpr = $compExpressions[0]; - } else { - $compExpr = call_user_func_array([$query->expr(), 'orX'], $compExpressions); - } + // Component expressions + $compExpressions = []; + foreach ($filters['comps'] as $comp) { + $compExpressions[] = $query->expr() + ->eq('c.componenttype', $query->createNamedParameter($comp)); + } - if (!isset($filters['props'])) { - $filters['props'] = []; - } - if (!isset($filters['params'])) { - $filters['params'] = []; - } + if (count($compExpressions) === 1) { + $compExpr = $compExpressions[0]; + } else { + $compExpr = call_user_func_array([$query->expr(), 'orX'], $compExpressions); + } - $propParamExpressions = []; - foreach ($filters['props'] as $prop) { - $propParamExpressions[] = $query->expr()->andX( - $query->expr()->eq('i.name', $query->createNamedParameter($prop)), - $query->expr()->isNull('i.parameter') - ); - } - foreach ($filters['params'] as $param) { - $propParamExpressions[] = $query->expr()->andX( - $query->expr()->eq('i.name', $query->createNamedParameter($param['property'])), - $query->expr()->eq('i.parameter', $query->createNamedParameter($param['parameter'])) - ); - } + if (!isset($filters['props'])) { + $filters['props'] = []; + } + if (!isset($filters['params'])) { + $filters['params'] = []; + } - if (count($propParamExpressions) === 1) { - $propParamExpr = $propParamExpressions[0]; - } else { - $propParamExpr = call_user_func_array([$query->expr(), 'orX'], $propParamExpressions); - } + $propParamExpressions = []; + foreach ($filters['props'] as $prop) { + $propParamExpressions[] = $query->expr()->andX( + $query->expr()->eq('i.name', $query->createNamedParameter($prop)), + $query->expr()->isNull('i.parameter') + ); + } + foreach ($filters['params'] as $param) { + $propParamExpressions[] = $query->expr()->andX( + $query->expr()->eq('i.name', $query->createNamedParameter($param['property'])), + $query->expr()->eq('i.parameter', $query->createNamedParameter($param['parameter'])) + ); + } - $query->select(['c.calendarid', 'c.uri']) - ->from($this->dbObjectPropertiesTable, 'i') - ->join('i', 'calendarobjects', 'c', $query->expr()->eq('i.objectid', 'c.id')) - ->where($calExpr) - ->andWhere($compExpr) - ->andWhere($propParamExpr) - ->andWhere($query->expr()->iLike('i.value', - $query->createNamedParameter('%'.$this->db->escapeLikeParameter($filters['search-term']).'%'))) - ->andWhere($query->expr()->isNull('deleted_at')); + if (count($propParamExpressions) === 1) { + $propParamExpr = $propParamExpressions[0]; + } else { + $propParamExpr = call_user_func_array([$query->expr(), 'orX'], $propParamExpressions); + } - if ($offset) { - $query->setFirstResult($offset); - } - if ($limit) { - $query->setMaxResults($limit); - } + $query->select(['c.calendarid', 'c.uri']) + ->from($this->dbObjectPropertiesTable, 'i') + ->join('i', 'calendarobjects', 'c', $query->expr()->eq('i.objectid', 'c.id')) + ->where($calExpr) + ->andWhere($compExpr) + ->andWhere($propParamExpr) + ->andWhere($query->expr()->iLike('i.value', + $query->createNamedParameter('%' . $this->db->escapeLikeParameter($filters['search-term']) . '%'))) + ->andWhere($query->expr()->isNull('deleted_at')); + + if ($offset) { + $query->setFirstResult($offset); + } + if ($limit) { + $query->setMaxResults($limit); + } - $stmt = $query->executeQuery(); + $stmt = $query->executeQuery(); - $result = []; - while ($row = $stmt->fetch()) { - $path = $uriMapper[$row['calendarid']] . '/' . $row['uri']; - if (!in_array($path, $result)) { - $result[] = $path; + $result = []; + while ($row = $stmt->fetch()) { + $path = $uriMapper[$row['calendarid']] . '/' . $row['uri']; + if (!in_array($path, $result)) { + $result[] = $path; + } } - } - return $result; + return $result; + }, $this->db); } /** @@ -1876,110 +1965,151 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * * @return array */ - public function search(array $calendarInfo, $pattern, array $searchProperties, - array $options, $limit, $offset) { + public function search( + array $calendarInfo, + $pattern, + array $searchProperties, + array $options, + $limit, + $offset, + ) { $outerQuery = $this->db->getQueryBuilder(); $innerQuery = $this->db->getQueryBuilder(); + if (isset($calendarInfo['source'])) { + $calendarType = self::CALENDAR_TYPE_SUBSCRIPTION; + } else { + $calendarType = self::CALENDAR_TYPE_CALENDAR; + } + $innerQuery->selectDistinct('op.objectid') ->from($this->dbObjectPropertiesTable, 'op') ->andWhere($innerQuery->expr()->eq('op.calendarid', $outerQuery->createNamedParameter($calendarInfo['id']))) ->andWhere($innerQuery->expr()->eq('op.calendartype', - $outerQuery->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))); + $outerQuery->createNamedParameter($calendarType))); + + $outerQuery->select('c.id', 'c.calendardata', 'c.componenttype', 'c.uid', 'c.uri') + ->from('calendarobjects', 'c') + ->where($outerQuery->expr()->isNull('deleted_at')); // only return public items for shared calendars for now if (isset($calendarInfo['{http://owncloud.org/ns}owner-principal']) === false || $calendarInfo['principaluri'] !== $calendarInfo['{http://owncloud.org/ns}owner-principal']) { - $innerQuery->andWhere($innerQuery->expr()->eq('c.classification', + $outerQuery->andWhere($outerQuery->expr()->eq('c.classification', $outerQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); } if (!empty($searchProperties)) { - $or = $innerQuery->expr()->orX(); + $or = []; foreach ($searchProperties as $searchProperty) { - $or->add($innerQuery->expr()->eq('op.name', - $outerQuery->createNamedParameter($searchProperty))); + $or[] = $innerQuery->expr()->eq('op.name', + $outerQuery->createNamedParameter($searchProperty)); } - $innerQuery->andWhere($or); + $innerQuery->andWhere($innerQuery->expr()->orX(...$or)); } if ($pattern !== '') { $innerQuery->andWhere($innerQuery->expr()->iLike('op.value', - $outerQuery->createNamedParameter('%' . - $this->db->escapeLikeParameter($pattern) . '%'))); + $outerQuery->createNamedParameter('%' + . $this->db->escapeLikeParameter($pattern) . '%'))); } - $outerQuery->select('c.id', 'c.calendardata', 'c.componenttype', 'c.uid', 'c.uri') - ->from('calendarobjects', 'c') - ->where($outerQuery->expr()->isNull('deleted_at')); + $start = null; + $end = null; - if (isset($options['timerange'])) { - if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { - $outerQuery->andWhere($outerQuery->expr()->gt('lastoccurence', - $outerQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()))); - } - if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { - $outerQuery->andWhere($outerQuery->expr()->lt('firstoccurence', - $outerQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()))); - } + $hasLimit = is_int($limit); + $hasTimeRange = false; + + if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { + /** @var DateTimeInterface $start */ + $start = $options['timerange']['start']; + $outerQuery->andWhere( + $outerQuery->expr()->gt( + 'lastoccurence', + $outerQuery->createNamedParameter($start->getTimestamp()) + ) + ); + $hasTimeRange = true; + } + + if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { + /** @var DateTimeInterface $end */ + $end = $options['timerange']['end']; + $outerQuery->andWhere( + $outerQuery->expr()->lt( + 'firstoccurence', + $outerQuery->createNamedParameter($end->getTimestamp()) + ) + ); + $hasTimeRange = true; + } + + if (isset($options['uid'])) { + $outerQuery->andWhere($outerQuery->expr()->eq('uid', $outerQuery->createNamedParameter($options['uid']))); } if (!empty($options['types'])) { - $or = $outerQuery->expr()->orX(); + $or = []; foreach ($options['types'] as $type) { - $or->add($outerQuery->expr()->eq('componenttype', - $outerQuery->createNamedParameter($type))); + $or[] = $outerQuery->expr()->eq('componenttype', + $outerQuery->createNamedParameter($type)); } - $outerQuery->andWhere($or); + $outerQuery->andWhere($outerQuery->expr()->orX(...$or)); } $outerQuery->andWhere($outerQuery->expr()->in('c.id', $outerQuery->createFunction($innerQuery->getSQL()))); - if ($offset) { - $outerQuery->setFirstResult($offset); - } - if ($limit) { + // Without explicit order by its undefined in which order the SQL server returns the events. + // For the pagination with hasLimit and hasTimeRange, a stable ordering is helpful. + $outerQuery->addOrderBy('id'); + + $offset = (int)$offset; + $outerQuery->setFirstResult($offset); + + $calendarObjects = []; + + if ($hasLimit && $hasTimeRange) { + /** + * Event recurrences are evaluated at runtime because the database only knows the first and last occurrence. + * + * Given, a user created 8 events with a yearly reoccurrence and two for events tomorrow. + * The upcoming event widget asks the CalDAV backend for 7 events within the next 14 days. + * + * If limit 7 is applied to the SQL query, we find the 7 events with a yearly reoccurrence + * and discard the events after evaluating the reoccurrence rules because they are not due within + * the next 14 days and end up with an empty result even if there are two events to show. + * + * The workaround for search requests with a limit and time range is asking for more row than requested + * and retrying if we have not reached the limit. + * + * 25 rows and 3 retries is entirely arbitrary. + */ + $maxResults = (int)max($limit, 25); + $outerQuery->setMaxResults($maxResults); + + for ($attempt = $objectsCount = 0; $attempt < 3 && $objectsCount < $limit; $attempt++) { + $objectsCount = array_push($calendarObjects, ...$this->searchCalendarObjects($outerQuery, $start, $end)); + $outerQuery->setFirstResult($offset += $maxResults); + } + + $calendarObjects = array_slice($calendarObjects, 0, $limit, false); + } else { $outerQuery->setMaxResults($limit); + $calendarObjects = $this->searchCalendarObjects($outerQuery, $start, $end); } - $result = $outerQuery->executeQuery(); - $calendarObjects = array_filter($result->fetchAll(), function (array $row) use ($options) { - $start = $options['timerange']['start'] ?? null; - $end = $options['timerange']['end'] ?? null; + $calendarObjects = array_map(function ($o) use ($options) { + $calendarData = Reader::read($o['calendardata']); - if ($start === null || !($start instanceof DateTimeInterface) || $end === null || !($end instanceof DateTimeInterface)) { - // No filter required - return true; + // Expand recurrences if an explicit time range is requested + if ($calendarData instanceof VCalendar + && isset($options['timerange']['start'], $options['timerange']['end'])) { + $calendarData = $calendarData->expand( + $options['timerange']['start'], + $options['timerange']['end'], + ); } - $isValid = $this->validateFilterForObject($row, [ - 'name' => 'VCALENDAR', - 'comp-filters' => [ - [ - 'name' => 'VEVENT', - 'comp-filters' => [], - 'prop-filters' => [], - 'is-not-defined' => false, - 'time-range' => [ - 'start' => $start, - 'end' => $end, - ], - ], - ], - 'prop-filters' => [], - 'is-not-defined' => false, - 'time-range' => null, - ]); - if (is_resource($row['calendardata'])) { - // Put the stream back to the beginning so it can be read another time - rewind($row['calendardata']); - } - return $isValid; - }); - $result->closeCursor(); - - return array_map(function ($o) { - $calendarData = Reader::read($o['calendardata']); $comps = $calendarData->getComponents(); $objects = []; $timezones = []; @@ -2004,6 +2134,72 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription }, $timezones), ]; }, $calendarObjects); + + usort($calendarObjects, function (array $a, array $b) { + /** @var DateTimeImmutable $startA */ + $startA = $a['objects'][0]['DTSTART'][0] ?? new DateTimeImmutable(self::MAX_DATE); + /** @var DateTimeImmutable $startB */ + $startB = $b['objects'][0]['DTSTART'][0] ?? new DateTimeImmutable(self::MAX_DATE); + + return $startA->getTimestamp() <=> $startB->getTimestamp(); + }); + + return $calendarObjects; + } + + private function searchCalendarObjects(IQueryBuilder $query, ?DateTimeInterface $start, ?DateTimeInterface $end): array { + $calendarObjects = []; + $filterByTimeRange = ($start instanceof DateTimeInterface) || ($end instanceof DateTimeInterface); + + $result = $query->executeQuery(); + + while (($row = $result->fetch()) !== false) { + if ($filterByTimeRange === false) { + // No filter required + $calendarObjects[] = $row; + continue; + } + + try { + $isValid = $this->validateFilterForObject($row, [ + 'name' => 'VCALENDAR', + 'comp-filters' => [ + [ + 'name' => 'VEVENT', + 'comp-filters' => [], + 'prop-filters' => [], + 'is-not-defined' => false, + 'time-range' => [ + 'start' => $start, + 'end' => $end, + ], + ], + ], + 'prop-filters' => [], + 'is-not-defined' => false, + 'time-range' => null, + ]); + } catch (MaxInstancesExceededException $ex) { + $this->logger->warning('Caught max instances exceeded exception for calendar data. This usually indicates too much recurring (more than 3500) event in calendar data. Object uri: ' . $row['uri'], [ + 'app' => 'dav', + 'exception' => $ex, + ]); + continue; + } + + if (is_resource($row['calendardata'])) { + // Put the stream back to the beginning so it can be read another time + rewind($row['calendardata']); + } + + if ($isValid) { + $calendarObjects[] = $row; + } + } + + $result->closeCursor(); + + return $calendarObjects; } /** @@ -2077,115 +2273,136 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return array */ public function searchPrincipalUri(string $principalUri, - string $pattern, - array $componentTypes, - array $searchProperties, - array $searchParameters, - array $options = []): array { - $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false; - - $calendarObjectIdQuery = $this->db->getQueryBuilder(); - $calendarOr = $calendarObjectIdQuery->expr()->orX(); - $searchOr = $calendarObjectIdQuery->expr()->orX(); + string $pattern, + array $componentTypes, + array $searchProperties, + array $searchParameters, + array $options = [], + ): array { + return $this->atomic(function () use ($principalUri, $pattern, $componentTypes, $searchProperties, $searchParameters, $options) { + $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false; + + $calendarObjectIdQuery = $this->db->getQueryBuilder(); + $calendarOr = []; + $searchOr = []; + + // Fetch calendars and subscription + $calendars = $this->getCalendarsForUser($principalUri); + $subscriptions = $this->getSubscriptionsForUser($principalUri); + foreach ($calendars as $calendar) { + $calendarAnd = $calendarObjectIdQuery->expr()->andX( + $calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$calendar['id'])), + $calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_CALENDAR)), + ); - // Fetch calendars and subscription - $calendars = $this->getCalendarsForUser($principalUri); - $subscriptions = $this->getSubscriptionsForUser($principalUri); - foreach ($calendars as $calendar) { - $calendarAnd = $calendarObjectIdQuery->expr()->andX(); - $calendarAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$calendar['id']))); - $calendarAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))); + // If it's shared, limit search to public events + if (isset($calendar['{http://owncloud.org/ns}owner-principal']) + && $calendar['principaluri'] !== $calendar['{http://owncloud.org/ns}owner-principal']) { + $calendarAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); + } - // If it's shared, limit search to public events - if (isset($calendar['{http://owncloud.org/ns}owner-principal']) - && $calendar['principaluri'] !== $calendar['{http://owncloud.org/ns}owner-principal']) { - $calendarAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); + $calendarOr[] = $calendarAnd; } + foreach ($subscriptions as $subscription) { + $subscriptionAnd = $calendarObjectIdQuery->expr()->andX( + $calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$subscription['id'])), + $calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)), + ); - $calendarOr->add($calendarAnd); - } - foreach ($subscriptions as $subscription) { - $subscriptionAnd = $calendarObjectIdQuery->expr()->andX(); - $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$subscription['id']))); - $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))); + // If it's shared, limit search to public events + if (isset($subscription['{http://owncloud.org/ns}owner-principal']) + && $subscription['principaluri'] !== $subscription['{http://owncloud.org/ns}owner-principal']) { + $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); + } - // If it's shared, limit search to public events - if (isset($subscription['{http://owncloud.org/ns}owner-principal']) - && $subscription['principaluri'] !== $subscription['{http://owncloud.org/ns}owner-principal']) { - $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); + $calendarOr[] = $subscriptionAnd; } - $calendarOr->add($subscriptionAnd); - } - - foreach ($searchProperties as $property) { - $propertyAnd = $calendarObjectIdQuery->expr()->andX(); - $propertyAnd->add($calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR))); - $propertyAnd->add($calendarObjectIdQuery->expr()->isNull('cob.parameter')); - - $searchOr->add($propertyAnd); - } - foreach ($searchParameters as $property => $parameter) { - $parameterAnd = $calendarObjectIdQuery->expr()->andX(); - $parameterAnd->add($calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR))); - $parameterAnd->add($calendarObjectIdQuery->expr()->eq('cob.parameter', $calendarObjectIdQuery->createNamedParameter($parameter, IQueryBuilder::PARAM_STR_ARRAY))); - - $searchOr->add($parameterAnd); - } + foreach ($searchProperties as $property) { + $propertyAnd = $calendarObjectIdQuery->expr()->andX( + $calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR)), + $calendarObjectIdQuery->expr()->isNull('cob.parameter'), + ); - if ($calendarOr->count() === 0) { - return []; - } - if ($searchOr->count() === 0) { - return []; - } + $searchOr[] = $propertyAnd; + } + foreach ($searchParameters as $property => $parameter) { + $parameterAnd = $calendarObjectIdQuery->expr()->andX( + $calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR)), + $calendarObjectIdQuery->expr()->eq('cob.parameter', $calendarObjectIdQuery->createNamedParameter($parameter, IQueryBuilder::PARAM_STR_ARRAY)), + ); - $calendarObjectIdQuery->selectDistinct('cob.objectid') - ->from($this->dbObjectPropertiesTable, 'cob') - ->leftJoin('cob', 'calendarobjects', 'co', $calendarObjectIdQuery->expr()->eq('co.id', 'cob.objectid')) - ->andWhere($calendarObjectIdQuery->expr()->in('co.componenttype', $calendarObjectIdQuery->createNamedParameter($componentTypes, IQueryBuilder::PARAM_STR_ARRAY))) - ->andWhere($calendarOr) - ->andWhere($searchOr) - ->andWhere($calendarObjectIdQuery->expr()->isNull('deleted_at')); + $searchOr[] = $parameterAnd; + } - if ('' !== $pattern) { - if (!$escapePattern) { - $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter($pattern))); - } else { - $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%'))); + if (empty($calendarOr)) { + return []; + } + if (empty($searchOr)) { + return []; } - } - if (isset($options['limit'])) { - $calendarObjectIdQuery->setMaxResults($options['limit']); - } - if (isset($options['offset'])) { - $calendarObjectIdQuery->setFirstResult($options['offset']); - } + $calendarObjectIdQuery->selectDistinct('cob.objectid') + ->from($this->dbObjectPropertiesTable, 'cob') + ->leftJoin('cob', 'calendarobjects', 'co', $calendarObjectIdQuery->expr()->eq('co.id', 'cob.objectid')) + ->andWhere($calendarObjectIdQuery->expr()->in('co.componenttype', $calendarObjectIdQuery->createNamedParameter($componentTypes, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($calendarObjectIdQuery->expr()->orX(...$calendarOr)) + ->andWhere($calendarObjectIdQuery->expr()->orX(...$searchOr)) + ->andWhere($calendarObjectIdQuery->expr()->isNull('deleted_at')); + + if ($pattern !== '') { + if (!$escapePattern) { + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter($pattern))); + } else { + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%'))); + } + } - $result = $calendarObjectIdQuery->executeQuery(); - $matches = $result->fetchAll(); - $result->closeCursor(); - $matches = array_map(static function (array $match):int { - return (int) $match['objectid']; - }, $matches); + if (isset($options['limit'])) { + $calendarObjectIdQuery->setMaxResults($options['limit']); + } + if (isset($options['offset'])) { + $calendarObjectIdQuery->setFirstResult($options['offset']); + } + if (isset($options['timerange'])) { + if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt( + 'lastoccurence', + $calendarObjectIdQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()), + )); + } + if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt( + 'firstoccurence', + $calendarObjectIdQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()), + )); + } + } - $query = $this->db->getQueryBuilder(); - $query->select('calendardata', 'uri', 'calendarid', 'calendartype') - ->from('calendarobjects') - ->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY))); + $result = $calendarObjectIdQuery->executeQuery(); + $matches = []; + while (($row = $result->fetch()) !== false) { + $matches[] = (int)$row['objectid']; + } + $result->closeCursor(); - $result = $query->executeQuery(); - $calendarObjects = $result->fetchAll(); - $result->closeCursor(); + $query = $this->db->getQueryBuilder(); + $query->select('calendardata', 'uri', 'calendarid', 'calendartype') + ->from('calendarobjects') + ->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY))); - return array_map(function (array $array): array { - $array['calendarid'] = (int)$array['calendarid']; - $array['calendartype'] = (int)$array['calendartype']; - $array['calendardata'] = $this->readBlob($array['calendardata']); + $result = $query->executeQuery(); + $calendarObjects = []; + while (($array = $result->fetch()) !== false) { + $array['calendarid'] = (int)$array['calendarid']; + $array['calendartype'] = (int)$array['calendartype']; + $array['calendardata'] = $this->readBlob($array['calendardata']); - return $array; - }, $calendarObjects); + $calendarObjects[] = $array; + } + $result->closeCursor(); + return $calendarObjects; + }, $this->db); } /** @@ -2252,7 +2469,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'calendardata' => $this->readBlob($row['calendardata']), 'component' => strtolower($row['componenttype']), 'classification' => (int)$row['classification'], - 'deleted_at' => isset($row['deleted_at']) ? ((int) $row['deleted_at']) : null, + 'deleted_at' => isset($row['deleted_at']) ? ((int)$row['deleted_at']) : null, ]; } @@ -2311,87 +2528,74 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param int $syncLevel * @param int|null $limit * @param int $calendarType - * @return array + * @return ?array */ public function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limit = null, $calendarType = self::CALENDAR_TYPE_CALENDAR) { - // Current synctoken - $qb = $this->db->getQueryBuilder(); - $qb->select('synctoken') - ->from('calendars') - ->where( - $qb->expr()->eq('id', $qb->createNamedParameter($calendarId)) - ); - $stmt = $qb->executeQuery(); - $currentToken = $stmt->fetchOne(); - - if ($currentToken === false) { - return null; - } - - $result = [ - 'syncToken' => $currentToken, - 'added' => [], - 'modified' => [], - 'deleted' => [], - ]; + $table = $calendarType === self::CALENDAR_TYPE_CALENDAR ? 'calendars': 'calendarsubscriptions'; - if ($syncToken) { + return $this->atomic(function () use ($calendarId, $syncToken, $syncLevel, $limit, $calendarType, $table) { + // Current synctoken $qb = $this->db->getQueryBuilder(); - - $qb->select('uri', 'operation') - ->from('calendarchanges') + $qb->select('synctoken') + ->from($table) ->where( - $qb->expr()->andX( - $qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)), - $qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)), - $qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)), - $qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)) - ) - )->orderBy('synctoken'); - if (is_int($limit) && $limit > 0) { - $qb->setMaxResults($limit); - } - - // Fetching all changes + $qb->expr()->eq('id', $qb->createNamedParameter($calendarId)) + ); $stmt = $qb->executeQuery(); - $changes = []; + $currentToken = $stmt->fetchOne(); + $initialSync = !is_numeric($syncToken); - // This loop ensures that any duplicates are overwritten, only the - // last change on a node is relevant. - while ($row = $stmt->fetch()) { - $changes[$row['uri']] = $row['operation']; + if ($currentToken === false) { + return null; } - $stmt->closeCursor(); - foreach ($changes as $uri => $operation) { - switch ($operation) { - case 1: - $result['added'][] = $uri; - break; - case 2: - $result['modified'][] = $uri; - break; - case 3: - $result['deleted'][] = $uri; - break; - } + // evaluate if this is a initial sync and construct appropriate command + if ($initialSync) { + $qb = $this->db->getQueryBuilder(); + $qb->select('uri') + ->from('calendarobjects') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->isNull('deleted_at')); + } else { + $qb = $this->db->getQueryBuilder(); + $qb->select('uri', $qb->func()->max('operation')) + ->from('calendarchanges') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken))) + ->andWhere($qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken))) + ->groupBy('uri'); } - } else { - // No synctoken supplied, this is the initial sync. - $qb = $this->db->getQueryBuilder(); - $qb->select('uri') - ->from('calendarobjects') - ->where( - $qb->expr()->andX( - $qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)), - $qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)) - ) - ); + // evaluate if limit exists + if (is_numeric($limit)) { + $qb->setMaxResults($limit); + } + // execute command $stmt = $qb->executeQuery(); - $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); + // build results + $result = ['syncToken' => $currentToken, 'added' => [], 'modified' => [], 'deleted' => []]; + // retrieve results + if ($initialSync) { + $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); + } else { + // \PDO::FETCH_NUM is needed due to the inconsistent field names + // produced by doctrine for MAX() with different databases + while ($entry = $stmt->fetch(\PDO::FETCH_NUM)) { + // assign uri (column 0) to appropriate mutation based on operation (column 1) + // forced (int) is needed as doctrine with OCI returns the operation field as string not integer + match ((int)$entry[1]) { + 1 => $result['added'][] = $entry[0], + 2 => $result['modified'][] = $entry[0], + 3 => $result['deleted'][] = $entry[0], + default => $this->logger->debug('Unknown calendar change operation detected') + }; + } + } $stmt->closeCursor(); - } - return $result; + + return $result; + }, $this->db); } /** @@ -2452,7 +2656,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'lastmodified' => $row['lastmodified'], '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', ]; $subscriptions[] = $this->rowToSubscription($row, $subscription); @@ -2495,28 +2699,23 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } - $valuesToInsert = []; - - $query = $this->db->getQueryBuilder(); - - foreach (array_keys($values) as $name) { - $valuesToInsert[$name] = $query->createNamedParameter($values[$name]); - } + [$subscriptionId, $subscriptionRow] = $this->atomic(function () use ($values) { + $valuesToInsert = []; + $query = $this->db->getQueryBuilder(); + foreach (array_keys($values) as $name) { + $valuesToInsert[$name] = $query->createNamedParameter($values[$name]); + } + $query->insert('calendarsubscriptions') + ->values($valuesToInsert) + ->executeStatement(); - $query->insert('calendarsubscriptions') - ->values($valuesToInsert) - ->executeStatement(); + $subscriptionId = $query->getLastInsertId(); - $subscriptionId = $query->getLastInsertId(); + $subscriptionRow = $this->getSubscriptionById($subscriptionId); + return [$subscriptionId, $subscriptionRow]; + }, $this->db); - $subscriptionRow = $this->getSubscriptionById($subscriptionId); $this->dispatcher->dispatchTyped(new SubscriptionCreatedEvent($subscriptionId, $subscriptionRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::createSubscription', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::createSubscription', - [ - 'subscriptionId' => $subscriptionId, - 'subscriptionData' => $subscriptionRow, - ])); return $subscriptionId; } @@ -2553,24 +2752,20 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } - $query = $this->db->getQueryBuilder(); - $query->update('calendarsubscriptions') - ->set('lastmodified', $query->createNamedParameter(time())); - foreach ($newValues as $fieldName => $value) { - $query->set($fieldName, $query->createNamedParameter($value)); - } - $query->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId))) - ->executeStatement(); + $subscriptionRow = $this->atomic(function () use ($subscriptionId, $newValues) { + $query = $this->db->getQueryBuilder(); + $query->update('calendarsubscriptions') + ->set('lastmodified', $query->createNamedParameter(time())); + foreach ($newValues as $fieldName => $value) { + $query->set($fieldName, $query->createNamedParameter($value)); + } + $query->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId))) + ->executeStatement(); + + return $this->getSubscriptionById($subscriptionId); + }, $this->db); - $subscriptionRow = $this->getSubscriptionById($subscriptionId); $this->dispatcher->dispatchTyped(new SubscriptionUpdatedEvent((int)$subscriptionId, $subscriptionRow, [], $mutations)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateSubscription', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateSubscription', - [ - 'subscriptionId' => $subscriptionId, - 'subscriptionData' => $subscriptionRow, - 'propertyMutations' => $mutations, - ])); return true; }); @@ -2583,39 +2778,34 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return void */ public function deleteSubscription($subscriptionId) { - $subscriptionRow = $this->getSubscriptionById($subscriptionId); - - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::deleteSubscription', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::deleteSubscription', - [ - 'subscriptionId' => $subscriptionId, - 'subscriptionData' => $this->getSubscriptionById($subscriptionId), - ])); + $this->atomic(function () use ($subscriptionId): void { + $subscriptionRow = $this->getSubscriptionById($subscriptionId); - $query = $this->db->getQueryBuilder(); - $query->delete('calendarsubscriptions') - ->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId))) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->delete('calendarsubscriptions') + ->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId))) + ->executeStatement(); - $query = $this->db->getQueryBuilder(); - $query->delete('calendarobjects') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->delete('calendarobjects') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); - $query->delete('calendarchanges') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query->delete('calendarchanges') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); - $query->delete($this->dbObjectPropertiesTable) - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query->delete($this->dbObjectPropertiesTable) + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); - if ($subscriptionRow) { - $this->dispatcher->dispatchTyped(new SubscriptionDeletedEvent((int)$subscriptionId, $subscriptionRow, [])); - } + if ($subscriptionRow) { + $this->dispatcher->dispatchTyped(new SubscriptionDeletedEvent((int)$subscriptionId, $subscriptionRow, [])); + } + }, $this->db); } /** @@ -2671,13 +2861,13 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription public function getSchedulingObjects($principalUri) { $query = $this->db->getQueryBuilder(); $stmt = $query->select(['uri', 'calendardata', 'lastmodified', 'etag', 'size']) - ->from('schedulingobjects') - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) - ->executeQuery(); + ->from('schedulingobjects') + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) + ->executeQuery(); - $result = []; - foreach ($stmt->fetchAll() as $row) { - $result[] = [ + $results = []; + while (($row = $stmt->fetch()) !== false) { + $results[] = [ 'calendardata' => $row['calendardata'], 'uri' => $row['uri'], 'lastmodified' => $row['lastmodified'], @@ -2687,7 +2877,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } $stmt->closeCursor(); - return $result; + return $results; } /** @@ -2698,11 +2888,50 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return void */ public function deleteSchedulingObject($principalUri, $objectUri) { + $this->cachedObjects = []; $query = $this->db->getQueryBuilder(); $query->delete('schedulingobjects') - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) - ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) - ->executeStatement(); + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) + ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) + ->executeStatement(); + } + + /** + * Deletes all scheduling objects last modified before $modifiedBefore from the inbox collection. + * + * @param int $modifiedBefore + * @param int $limit + * @return void + */ + public function deleteOutdatedSchedulingObjects(int $modifiedBefore, int $limit): void { + $query = $this->db->getQueryBuilder(); + $query->select('id') + ->from('schedulingobjects') + ->where($query->expr()->lt('lastmodified', $query->createNamedParameter($modifiedBefore))) + ->setMaxResults($limit); + $result = $query->executeQuery(); + $count = $result->rowCount(); + if ($count === 0) { + return; + } + $ids = array_map(static function (array $id) { + return (int)$id[0]; + }, $result->fetchAll(\PDO::FETCH_NUM)); + $result->closeCursor(); + + $numDeleted = 0; + $deleteQuery = $this->db->getQueryBuilder(); + $deleteQuery->delete('schedulingobjects') + ->where($deleteQuery->expr()->in('id', $deleteQuery->createParameter('ids'), IQueryBuilder::PARAM_INT_ARRAY)); + foreach (array_chunk($ids, 1000) as $chunk) { + $deleteQuery->setParameter('ids', $chunk, IQueryBuilder::PARAM_INT_ARRAY); + $numDeleted += $deleteQuery->executeStatement(); + } + + if ($numDeleted === $limit) { + $this->logger->info("Deleted $limit scheduling objects, continuing with next batch"); + $this->deleteOutdatedSchedulingObjects($modifiedBefore, $limit); + } } /** @@ -2714,6 +2943,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return void */ public function createSchedulingObject($principalUri, $objectUri, $objectData) { + $this->cachedObjects = []; $query = $this->db->getQueryBuilder(); $query->insert('schedulingobjects') ->values([ @@ -2731,37 +2961,86 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * Adds a change record to the calendarchanges table. * * @param mixed $calendarId - * @param string $objectUri + * @param string[] $objectUris * @param int $operation 1 = add, 2 = modify, 3 = delete. * @param int $calendarType * @return void */ - protected function addChange($calendarId, $objectUri, $operation, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + protected function addChanges(int $calendarId, array $objectUris, int $operation, int $calendarType = self::CALENDAR_TYPE_CALENDAR): void { + $this->cachedObjects = []; $table = $calendarType === self::CALENDAR_TYPE_CALENDAR ? 'calendars': 'calendarsubscriptions'; - $query = $this->db->getQueryBuilder(); - $query->select('synctoken') - ->from($table) - ->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))); - $result = $query->executeQuery(); - $syncToken = (int)$result->fetchOne(); - $result->closeCursor(); + $this->atomic(function () use ($calendarId, $objectUris, $operation, $calendarType, $table): void { + $query = $this->db->getQueryBuilder(); + $query->select('synctoken') + ->from($table) + ->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))); + $result = $query->executeQuery(); + $syncToken = (int)$result->fetchOne(); + $result->closeCursor(); - $query = $this->db->getQueryBuilder(); - $query->insert('calendarchanges') - ->values([ - 'uri' => $query->createNamedParameter($objectUri), - 'synctoken' => $query->createNamedParameter($syncToken), - 'calendarid' => $query->createNamedParameter($calendarId), - 'operation' => $query->createNamedParameter($operation), - 'calendartype' => $query->createNamedParameter($calendarType), - ]) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->insert('calendarchanges') + ->values([ + 'uri' => $query->createParameter('uri'), + 'synctoken' => $query->createNamedParameter($syncToken), + 'calendarid' => $query->createNamedParameter($calendarId), + 'operation' => $query->createNamedParameter($operation), + 'calendartype' => $query->createNamedParameter($calendarType), + 'created_at' => $query->createNamedParameter(time()), + ]); + foreach ($objectUris as $uri) { + $query->setParameter('uri', $uri); + $query->executeStatement(); + } - $stmt = $this->db->prepare("UPDATE `*PREFIX*$table` SET `synctoken` = `synctoken` + 1 WHERE `id` = ?"); - $stmt->execute([ - $calendarId - ]); + $query = $this->db->getQueryBuilder(); + $query->update($table) + ->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))) + ->executeStatement(); + }, $this->db); + } + + public function restoreChanges(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR): void { + $this->cachedObjects = []; + + $this->atomic(function () use ($calendarId, $calendarType): void { + $qbAdded = $this->db->getQueryBuilder(); + $qbAdded->select('uri') + ->from('calendarobjects') + ->where( + $qbAdded->expr()->andX( + $qbAdded->expr()->eq('calendarid', $qbAdded->createNamedParameter($calendarId)), + $qbAdded->expr()->eq('calendartype', $qbAdded->createNamedParameter($calendarType)), + $qbAdded->expr()->isNull('deleted_at'), + ) + ); + $resultAdded = $qbAdded->executeQuery(); + $addedUris = $resultAdded->fetchAll(\PDO::FETCH_COLUMN); + $resultAdded->closeCursor(); + // Track everything as changed + // Tracking the creation is not necessary because \OCA\DAV\CalDAV\CalDavBackend::getChangesForCalendar + // only returns the last change per object. + $this->addChanges($calendarId, $addedUris, 2, $calendarType); + + $qbDeleted = $this->db->getQueryBuilder(); + $qbDeleted->select('uri') + ->from('calendarobjects') + ->where( + $qbDeleted->expr()->andX( + $qbDeleted->expr()->eq('calendarid', $qbDeleted->createNamedParameter($calendarId)), + $qbDeleted->expr()->eq('calendartype', $qbDeleted->createNamedParameter($calendarType)), + $qbDeleted->expr()->isNotNull('deleted_at'), + ) + ); + $resultDeleted = $qbDeleted->executeQuery(); + $deletedUris = array_map(function (string $uri) { + return str_replace('-deleted.ics', '.ics', $uri); + }, $resultDeleted->fetchAll(\PDO::FETCH_COLUMN)); + $resultDeleted->closeCursor(); + $this->addChanges($calendarId, $deletedUris, 3, $calendarType); + }, $this->db); } /** @@ -2779,7 +3058,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $calendarData * @return array */ - public function getDenormalizedData($calendarData) { + public function getDenormalizedData(string $calendarData): array { $vObject = Reader::read($calendarData); $vEvents = []; $componentType = null; @@ -2793,7 +3072,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription if ($component->name !== 'VTIMEZONE') { // Finding all VEVENTs, and track them if ($component->name === 'VEVENT') { - array_push($vEvents, $component); + $vEvents[] = $component; if ($component->DTSTART) { $hasDTSTART = true; } @@ -2829,7 +3108,15 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $lastOccurrence = $firstOccurrence; } } else { - $it = new EventIterator($vEvents); + try { + $it = new EventIterator($vEvents); + } catch (NoInstancesException $e) { + $this->logger->debug('Caught no instance exception for calendar data. This usually indicates invalid calendar data.', [ + 'app' => 'dav', + 'exception' => $e, + ]); + throw new Forbidden($e->getMessage()); + } $maxDate = new DateTime(self::MAX_DATE); $firstOccurrence = $it->getDtStart()->getTimestamp(); if ($it->isInfinite()) { @@ -2861,7 +3148,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'size' => strlen($calendarData), 'componentType' => $componentType, 'firstOccurence' => is_null($firstOccurrence) ? null : max(0, $firstOccurrence), - 'lastOccurence' => $lastOccurrence, + 'lastOccurence' => is_null($lastOccurrence) ? null : max(0, $lastOccurrence), 'uid' => $uid, 'classification' => $classification ]; @@ -2880,80 +3167,73 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** - * @param IShareable $shareable - * @param array $add - * @param array $remove + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove */ - public function updateShares($shareable, $add, $remove) { - $calendarId = $shareable->getResourceId(); - $calendarRow = $this->getCalendarById($calendarId); - $oldShares = $this->getShares($calendarId); + public function updateShares(IShareable $shareable, array $add, array $remove): void { + $this->atomic(function () use ($shareable, $add, $remove): void { + $calendarId = $shareable->getResourceId(); + $calendarRow = $this->getCalendarById($calendarId); + if ($calendarRow === null) { + throw new \RuntimeException('Trying to update shares for non-existing calendar: ' . $calendarId); + } + $oldShares = $this->getShares($calendarId); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateShares', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateShares', - [ - 'calendarId' => $calendarId, - 'calendarData' => $calendarRow, - 'shares' => $oldShares, - 'add' => $add, - 'remove' => $remove, - ])); - $this->calendarSharingBackend->updateShares($shareable, $add, $remove); + $this->calendarSharingBackend->updateShares($shareable, $add, $remove, $oldShares); - $this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent((int)$calendarId, $calendarRow, $oldShares, $add, $remove)); + $this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent($calendarId, $calendarRow, $oldShares, $add, $remove)); + }, $this->db); } /** - * @param int $resourceId - * @return array + * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> */ - public function getShares($resourceId) { + public function getShares(int $resourceId): array { return $this->calendarSharingBackend->getShares($resourceId); } + public function preloadShares(array $resourceIds): void { + $this->calendarSharingBackend->preloadShares($resourceIds); + } + /** * @param boolean $value - * @param \OCA\DAV\CalDAV\Calendar $calendar + * @param Calendar $calendar * @return string|null */ public function setPublishStatus($value, $calendar) { - $calendarId = $calendar->getResourceId(); - $calendarData = $this->getCalendarById($calendarId); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::publishCalendar', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateShares', - [ - 'calendarId' => $calendarId, - 'calendarData' => $calendarData, - 'public' => $value, - ])); + return $this->atomic(function () use ($value, $calendar) { + $calendarId = $calendar->getResourceId(); + $calendarData = $this->getCalendarById($calendarId); - $query = $this->db->getQueryBuilder(); - if ($value) { - $publicUri = $this->random->generate(16, ISecureRandom::CHAR_HUMAN_READABLE); - $query->insert('dav_shares') - ->values([ - 'principaluri' => $query->createNamedParameter($calendar->getPrincipalURI()), - 'type' => $query->createNamedParameter('calendar'), - 'access' => $query->createNamedParameter(self::ACCESS_PUBLIC), - 'resourceid' => $query->createNamedParameter($calendar->getResourceId()), - 'publicuri' => $query->createNamedParameter($publicUri) - ]); - $query->executeStatement(); + $query = $this->db->getQueryBuilder(); + if ($value) { + $publicUri = $this->random->generate(16, ISecureRandom::CHAR_HUMAN_READABLE); + $query->insert('dav_shares') + ->values([ + 'principaluri' => $query->createNamedParameter($calendar->getPrincipalURI()), + 'type' => $query->createNamedParameter('calendar'), + 'access' => $query->createNamedParameter(self::ACCESS_PUBLIC), + 'resourceid' => $query->createNamedParameter($calendar->getResourceId()), + 'publicuri' => $query->createNamedParameter($publicUri) + ]); + $query->executeStatement(); - $this->dispatcher->dispatchTyped(new CalendarPublishedEvent((int)$calendarId, $calendarData, $publicUri)); - return $publicUri; - } - $query->delete('dav_shares') - ->where($query->expr()->eq('resourceid', $query->createNamedParameter($calendar->getResourceId()))) - ->andWhere($query->expr()->eq('access', $query->createNamedParameter(self::ACCESS_PUBLIC))); - $query->executeStatement(); + $this->dispatcher->dispatchTyped(new CalendarPublishedEvent($calendarId, $calendarData, $publicUri)); + return $publicUri; + } + $query->delete('dav_shares') + ->where($query->expr()->eq('resourceid', $query->createNamedParameter($calendar->getResourceId()))) + ->andWhere($query->expr()->eq('access', $query->createNamedParameter(self::ACCESS_PUBLIC))); + $query->executeStatement(); - $this->dispatcher->dispatchTyped(new CalendarUnpublishedEvent((int)$calendarId, $calendarData)); - return null; + $this->dispatcher->dispatchTyped(new CalendarUnpublishedEvent($calendarId, $calendarData)); + return null; + }, $this->db); } /** - * @param \OCA\DAV\CalDAV\Calendar $calendar + * @param Calendar $calendar * @return mixed */ public function getPublishStatus($calendar) { @@ -2971,15 +3251,14 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** * @param int $resourceId - * @param array $acl - * @return array + * @param list<array{privilege: string, principal: string, protected: bool}> $acl + * @return list<array{privilege: string, principal: string, protected: bool}> */ - public function applyShareAcl($resourceId, $acl) { - return $this->calendarSharingBackend->applyShareAcl($resourceId, $acl); + public function applyShareAcl(int $resourceId, array $acl): array { + $shares = $this->calendarSharingBackend->getShares($resourceId); + return $this->calendarSharingBackend->applyShareAcl($shares, $acl); } - - /** * update properties table * @@ -2989,127 +3268,172 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param int $calendarType */ public function updateProperties($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) { - $objectId = $this->getCalendarObjectId($calendarId, $objectUri, $calendarType); + $this->cachedObjects = []; + $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $calendarType): void { + $objectId = $this->getCalendarObjectId($calendarId, $objectUri, $calendarType); + + try { + $vCalendar = $this->readCalendarData($calendarData); + } catch (\Exception $ex) { + return; + } - try { - $vCalendar = $this->readCalendarData($calendarData); - } catch (\Exception $ex) { - return; - } + $this->purgeProperties($calendarId, $objectId); - $this->purgeProperties($calendarId, $objectId); + $query = $this->db->getQueryBuilder(); + $query->insert($this->dbObjectPropertiesTable) + ->values( + [ + 'calendarid' => $query->createNamedParameter($calendarId), + 'calendartype' => $query->createNamedParameter($calendarType), + 'objectid' => $query->createNamedParameter($objectId), + 'name' => $query->createParameter('name'), + 'parameter' => $query->createParameter('parameter'), + 'value' => $query->createParameter('value'), + ] + ); - $query = $this->db->getQueryBuilder(); - $query->insert($this->dbObjectPropertiesTable) - ->values( - [ - 'calendarid' => $query->createNamedParameter($calendarId), - 'calendartype' => $query->createNamedParameter($calendarType), - 'objectid' => $query->createNamedParameter($objectId), - 'name' => $query->createParameter('name'), - 'parameter' => $query->createParameter('parameter'), - 'value' => $query->createParameter('value'), - ] - ); + $indexComponents = ['VEVENT', 'VJOURNAL', 'VTODO']; + foreach ($vCalendar->getComponents() as $component) { + if (!in_array($component->name, $indexComponents)) { + continue; + } - $indexComponents = ['VEVENT', 'VJOURNAL', 'VTODO']; - foreach ($vCalendar->getComponents() as $component) { - if (!in_array($component->name, $indexComponents)) { - continue; - } + foreach ($component->children() as $property) { + if (in_array($property->name, self::INDEXED_PROPERTIES, true)) { + $value = $property->getValue(); + // is this a shitty db? + if (!$this->db->supports4ByteText()) { + $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value); + } + $value = mb_strcut($value, 0, 254); - foreach ($component->children() as $property) { - if (in_array($property->name, self::INDEXED_PROPERTIES, true)) { - $value = $property->getValue(); - // is this a shitty db? - if (!$this->db->supports4ByteText()) { - $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value); + $query->setParameter('name', $property->name); + $query->setParameter('parameter', null); + $query->setParameter('value', mb_strcut($value, 0, 254)); + $query->executeStatement(); } - $value = mb_strcut($value, 0, 254); - - $query->setParameter('name', $property->name); - $query->setParameter('parameter', null); - $query->setParameter('value', $value); - $query->executeStatement(); - } - - if (array_key_exists($property->name, self::$indexParameters)) { - $parameters = $property->parameters(); - $indexedParametersForProperty = self::$indexParameters[$property->name]; - foreach ($parameters as $key => $value) { - if (in_array($key, $indexedParametersForProperty)) { - // is this a shitty db? - if ($this->db->supports4ByteText()) { - $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value); + if (array_key_exists($property->name, self::$indexParameters)) { + $parameters = $property->parameters(); + $indexedParametersForProperty = self::$indexParameters[$property->name]; + + foreach ($parameters as $key => $value) { + if (in_array($key, $indexedParametersForProperty)) { + // is this a shitty db? + if ($this->db->supports4ByteText()) { + $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value); + } + + $query->setParameter('name', $property->name); + $query->setParameter('parameter', mb_strcut($key, 0, 254)); + $query->setParameter('value', mb_strcut($value, 0, 254)); + $query->executeStatement(); } - - $query->setParameter('name', $property->name); - $query->setParameter('parameter', mb_strcut($key, 0, 254)); - $query->setParameter('value', mb_strcut($value, 0, 254)); - $query->executeStatement(); } } } } - } + }, $this->db); } /** * deletes all birthday calendars */ public function deleteAllBirthdayCalendars() { - $query = $this->db->getQueryBuilder(); - $result = $query->select(['id'])->from('calendars') - ->where($query->expr()->eq('uri', $query->createNamedParameter(BirthdayService::BIRTHDAY_CALENDAR_URI))) - ->executeQuery(); + $this->atomic(function (): void { + $query = $this->db->getQueryBuilder(); + $result = $query->select(['id'])->from('calendars') + ->where($query->expr()->eq('uri', $query->createNamedParameter(BirthdayService::BIRTHDAY_CALENDAR_URI))) + ->executeQuery(); - $ids = $result->fetchAll(); - $result->closeCursor(); - foreach ($ids as $id) { - $this->deleteCalendar( - $id['id'], - true // No data to keep in the trashbin, if the user re-enables then we regenerate - ); - } + while (($row = $result->fetch()) !== false) { + $this->deleteCalendar( + $row['id'], + true // No data to keep in the trashbin, if the user re-enables then we regenerate + ); + } + $result->closeCursor(); + }, $this->db); } /** * @param $subscriptionId */ public function purgeAllCachedEventsForSubscription($subscriptionId) { - $query = $this->db->getQueryBuilder(); - $query->select('uri') - ->from('calendarobjects') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))); - $stmt = $query->executeQuery(); + $this->atomic(function () use ($subscriptionId): void { + $query = $this->db->getQueryBuilder(); + $query->select('uri') + ->from('calendarobjects') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))); + $stmt = $query->executeQuery(); - $uris = []; - foreach ($stmt->fetchAll() as $row) { - $uris[] = $row['uri']; - } - $stmt->closeCursor(); + $uris = []; + while (($row = $stmt->fetch()) !== false) { + $uris[] = $row['uri']; + } + $stmt->closeCursor(); - $query = $this->db->getQueryBuilder(); - $query->delete('calendarobjects') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->delete('calendarobjects') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); - $query->delete('calendarchanges') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->delete('calendarchanges') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); - $query->delete($this->dbObjectPropertiesTable) - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbObjectPropertiesTable) + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); + + $this->addChanges($subscriptionId, $uris, 3, self::CALENDAR_TYPE_SUBSCRIPTION); + }, $this->db); + } - foreach ($uris as $uri) { - $this->addChange($subscriptionId, $uri, 3, self::CALENDAR_TYPE_SUBSCRIPTION); + /** + * @param int $subscriptionId + * @param array<int> $calendarObjectIds + * @param array<string> $calendarObjectUris + */ + public function purgeCachedEventsForSubscription(int $subscriptionId, array $calendarObjectIds, array $calendarObjectUris): void { + if (empty($calendarObjectUris)) { + return; } + + $this->atomic(function () use ($subscriptionId, $calendarObjectIds, $calendarObjectUris): void { + foreach (array_chunk($calendarObjectIds, 1000) as $chunk) { + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbObjectPropertiesTable) + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->andWhere($query->expr()->in('id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY)) + ->executeStatement(); + + $query = $this->db->getQueryBuilder(); + $query->delete('calendarobjects') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->andWhere($query->expr()->in('id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY)) + ->executeStatement(); + } + + foreach (array_chunk($calendarObjectUris, 1000) as $chunk) { + $query = $this->db->getQueryBuilder(); + $query->delete('calendarchanges') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->andWhere($query->expr()->in('uri', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)) + ->executeStatement(); + } + $this->addChanges($subscriptionId, $calendarObjectUris, 3, self::CALENDAR_TYPE_SUBSCRIPTION); + }, $this->db); } /** @@ -3147,6 +3471,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param int $objectId */ protected function purgeProperties($calendarId, $objectId) { + $this->cachedObjects = []; $query = $this->db->getQueryBuilder(); $query->delete($this->dbObjectPropertiesTable) ->where($query->expr()->eq('objectid', $query->createNamedParameter($objectId))) @@ -3182,6 +3507,34 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** + * @throws \InvalidArgumentException + */ + public function pruneOutdatedSyncTokens(int $keep, int $retention): int { + if ($keep < 0) { + throw new \InvalidArgumentException(); + } + + $query = $this->db->getQueryBuilder(); + $query->select($query->func()->max('id')) + ->from('calendarchanges'); + + $result = $query->executeQuery(); + $maxId = (int)$result->fetchOne(); + $result->closeCursor(); + if (!$maxId || $maxId < $keep) { + return 0; + } + + $query = $this->db->getQueryBuilder(); + $query->delete('calendarchanges') + ->where( + $query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $query->expr()->lte('created_at', $query->createNamedParameter($retention)), + ); + return $query->executeStatement(); + } + + /** * return legacy endpoint principal name to new principal name * * @param $principalUri @@ -3270,4 +3623,68 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } return $subscription; } + + /** + * delete all invitations from a given calendar + * + * @since 31.0.0 + * + * @param int $calendarId + * + * @return void + */ + protected function purgeCalendarInvitations(int $calendarId): void { + // select all calendar object uid's + $cmd = $this->db->getQueryBuilder(); + $cmd->select('uid') + ->from($this->dbObjectsTable) + ->where($cmd->expr()->eq('calendarid', $cmd->createNamedParameter($calendarId))); + $allIds = $cmd->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + // delete all links that match object uid's + $cmd = $this->db->getQueryBuilder(); + $cmd->delete($this->dbObjectInvitationsTable) + ->where($cmd->expr()->in('uid', $cmd->createParameter('uids'), IQueryBuilder::PARAM_STR_ARRAY)); + foreach (array_chunk($allIds, 1000) as $chunkIds) { + $cmd->setParameter('uids', $chunkIds, IQueryBuilder::PARAM_STR_ARRAY); + $cmd->executeStatement(); + } + } + + /** + * Delete all invitations from a given calendar event + * + * @since 31.0.0 + * + * @param string $eventId UID of the event + * + * @return void + */ + protected function purgeObjectInvitations(string $eventId): void { + $cmd = $this->db->getQueryBuilder(); + $cmd->delete($this->dbObjectInvitationsTable) + ->where($cmd->expr()->eq('uid', $cmd->createNamedParameter($eventId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)); + $cmd->executeStatement(); + } + + public function unshare(IShareable $shareable, string $principal): void { + $this->atomic(function () use ($shareable, $principal): void { + $calendarData = $this->getCalendarById($shareable->getResourceId()); + if ($calendarData === null) { + throw new \RuntimeException('Trying to update shares for non-existing calendar: ' . $shareable->getResourceId()); + } + + $oldShares = $this->getShares($shareable->getResourceId()); + $unshare = $this->calendarSharingBackend->unshare($shareable, $principal); + + if ($unshare) { + $this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent( + $shareable->getResourceId(), + $calendarData, + $oldShares, + [], + [$principal] + )); + } + }, $this->db); + } } diff --git a/apps/dav/lib/CalDAV/Calendar.php b/apps/dav/lib/CalDAV/Calendar.php index 75c815c3b0a..dd3a4cf3f69 100644 --- a/apps/dav/lib/CalDAV/Calendar.php +++ b/apps/dav/lib/CalDAV/Calendar.php @@ -1,30 +1,10 @@ <?php + + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Gary Kim <gary@garykim.dev> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -51,28 +31,16 @@ use Sabre\DAV\PropPatch; * @property CalDavBackend $caldavBackend */ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable, IMoveTarget { - - /** @var IConfig */ - private $config; - - /** @var IL10N */ - protected $l10n; - - /** @var bool */ - private $useTrashbin = true; - - /** @var LoggerInterface */ - private $logger; - - /** - * Calendar constructor. - * - * @param BackendInterface $caldavBackend - * @param $calendarInfo - * @param IL10N $l10n - * @param IConfig $config - */ - public function __construct(BackendInterface $caldavBackend, $calendarInfo, IL10N $l10n, IConfig $config, LoggerInterface $logger) { + protected IL10N $l10n; + private bool $useTrashbin = true; + + public function __construct( + BackendInterface $caldavBackend, + $calendarInfo, + IL10N $l10n, + private IConfig $config, + private LoggerInterface $logger, + ) { // Convert deletion date to ISO8601 string if (isset($calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT])) { $calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT] = (new DateTimeImmutable()) @@ -82,39 +50,21 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable parent::__construct($caldavBackend, $calendarInfo); - if ($this->getName() === BirthdayService::BIRTHDAY_CALENDAR_URI) { + if ($this->getName() === BirthdayService::BIRTHDAY_CALENDAR_URI && strcasecmp($this->calendarInfo['{DAV:}displayname'], 'Contact birthdays') === 0) { $this->calendarInfo['{DAV:}displayname'] = $l10n->t('Contact birthdays'); } - if ($this->getName() === CalDavBackend::PERSONAL_CALENDAR_URI && - $this->calendarInfo['{DAV:}displayname'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { + if ($this->getName() === CalDavBackend::PERSONAL_CALENDAR_URI + && $this->calendarInfo['{DAV:}displayname'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { $this->calendarInfo['{DAV:}displayname'] = $l10n->t('Personal'); } - - $this->config = $config; $this->l10n = $l10n; - $this->logger = $logger; } /** - * Updates the list of shares. - * - * The first array is a list of people that are to be added to the - * resource. - * - * Every element in the add array has the following properties: - * * href - A url. Usually a mailto: address - * * commonName - Usually a first and last name, or false - * * summary - A description of the share, can also be false - * * readOnly - A boolean value - * - * Every element in the remove array is just the address string. - * - * @param array $add - * @param array $remove - * @return void + * {@inheritdoc} * @throws Forbidden */ - public function updateShares(array $add, array $remove) { + public function updateShares(array $add, array $remove): void { if ($this->isShared()) { throw new Forbidden(); } @@ -131,19 +81,16 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable * * readOnly - boolean * * summary - Optional, a description for the share * - * @return array + * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> */ - public function getShares() { + public function getShares(): array { if ($this->isShared()) { return []; } return $this->caldavBackend->getShares($this->getResourceId()); } - /** - * @return int - */ - public function getResourceId() { + public function getResourceId(): int { return $this->calendarInfo['id']; } @@ -155,7 +102,9 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable } /** - * @return array + * @param int $resourceId + * @param list<array{privilege: string, principal: string, protected: bool}> $acl + * @return list<array{privilege: string, principal: ?string, protected: bool}> */ public function getACL() { $acl = [ @@ -241,21 +190,23 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable $acl = $this->caldavBackend->applyShareAcl($this->getResourceId(), $acl); $allowedPrincipals = [ $this->getOwner(), - $this->getOwner(). '/calendar-proxy-read', - $this->getOwner(). '/calendar-proxy-write', + $this->getOwner() . '/calendar-proxy-read', + $this->getOwner() . '/calendar-proxy-write', parent::getOwner(), 'principals/system/public' ]; - return array_filter($acl, function ($rule) use ($allowedPrincipals) { + /** @var list<array{privilege: string, principal: string, protected: bool}> $acl */ + $acl = array_filter($acl, function (array $rule) use ($allowedPrincipals): bool { return \in_array($rule['principal'], $allowedPrincipals, true); }); + return $acl; } public function getChildACL() { return $this->getACL(); } - public function getOwner() { + public function getOwner(): ?string { if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) { return $this->calendarInfo['{http://owncloud.org/ns}owner-principal']; } @@ -263,20 +214,8 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable } public function delete() { - if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal']) && - $this->calendarInfo['{http://owncloud.org/ns}owner-principal'] !== $this->calendarInfo['principaluri']) { - $principal = 'principal:' . parent::getOwner(); - $shares = $this->caldavBackend->getShares($this->getResourceId()); - $shares = array_filter($shares, function ($share) use ($principal) { - return $share['href'] === $principal; - }); - if (empty($shares)) { - throw new Forbidden(); - } - - $this->caldavBackend->updateShares($this, [], [ - $principal - ]); + if ($this->isShared()) { + $this->caldavBackend->unshare($this, 'principal:' . $this->getPrincipalURI()); return; } @@ -400,7 +339,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable return isset($this->calendarInfo['{http://owncloud.org/ns}public']); } - protected function isShared() { + public function isShared() { if (!isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) { return false; } @@ -412,6 +351,13 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable return isset($this->calendarInfo['{http://calendarserver.org/ns/}source']); } + public function isDeleted(): bool { + if (!isset($this->calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT])) { + return false; + } + return $this->calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT] !== null; + } + /** * @inheritDoc */ @@ -427,7 +373,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable * @inheritDoc */ public function restore(): void { - $this->caldavBackend->restoreCalendar((int) $this->calendarInfo['id']); + $this->caldavBackend->restoreCalendar((int)$this->calendarInfo['id']); } public function disableTrashbin(): void { @@ -441,9 +387,14 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable if (!($sourceNode instanceof CalendarObject)) { return false; } - try { - return $this->caldavBackend->moveCalendarObject($sourceNode->getCalendarId(), (int)$this->calendarInfo['id'], $sourceNode->getId(), $sourceNode->getPrincipalUri()); + return $this->caldavBackend->moveCalendarObject( + $sourceNode->getOwner(), + $sourceNode->getId(), + $this->getOwner(), + $this->getResourceId(), + $targetName, + ); } catch (Exception $e) { $this->logger->error('Could not move calendar object: ' . $e->getMessage(), ['exception' => $e]); return false; diff --git a/apps/dav/lib/CalDAV/CalendarHome.php b/apps/dav/lib/CalDAV/CalendarHome.php index ceeba31800e..89b78ba9007 100644 --- a/apps/dav/lib/CalDAV/CalendarHome.php +++ b/apps/dav/lib/CalDAV/CalendarHome.php @@ -1,28 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -30,6 +11,10 @@ use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\CalDAV\Integration\ExternalCalendar; use OCA\DAV\CalDAV\Integration\ICalendarProvider; use OCA\DAV\CalDAV\Trashbin\TrashbinHome; +use OCP\App\IAppManager; +use OCP\IConfig; +use OCP\IL10N; +use OCP\Server; use Psr\Log\LoggerInterface; use Sabre\CalDAV\Backend\BackendInterface; use Sabre\CalDAV\Backend\NotificationSupport; @@ -44,30 +29,29 @@ use Sabre\DAV\MkCol; class CalendarHome extends \Sabre\CalDAV\CalendarHome { - /** @var \OCP\IL10N */ + /** @var IL10N */ private $l10n; - /** @var \OCP\IConfig */ + /** @var IConfig */ private $config; /** @var PluginManager */ private $pluginManager; - - /** @var bool */ - private $returnCachedSubscriptions = false; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(BackendInterface $caldavBackend, $principalInfo, LoggerInterface $logger) { + private ?array $cachedChildren = null; + + public function __construct( + BackendInterface $caldavBackend, + array $principalInfo, + private LoggerInterface $logger, + private bool $returnCachedSubscriptions, + ) { parent::__construct($caldavBackend, $principalInfo); $this->l10n = \OC::$server->getL10N('dav'); - $this->config = \OC::$server->getConfig(); + $this->config = Server::get(IConfig::class); $this->pluginManager = new PluginManager( \OC::$server, - \OC::$server->getAppManager() + Server::get(IAppManager::class) ); - $this->logger = $logger; } /** @@ -97,6 +81,9 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { * @inheritdoc */ public function getChildren() { + if ($this->cachedChildren) { + return $this->cachedChildren; + } $calendars = $this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']); $objects = []; foreach ($calendars as $calendar) { @@ -136,6 +123,7 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { } } + $this->cachedChildren = $objects; return $objects; } @@ -159,7 +147,16 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { return new TrashbinHome($this->caldavBackend, $this->principalInfo); } - // Calendars + // Calendar - this covers all "regular" calendars, but not shared + // only check if the method is available + if ($this->caldavBackend instanceof CalDavBackend) { + $calendar = $this->caldavBackend->getCalendarByUri($this->principalInfo['uri'], $name); + if (!empty($calendar)) { + return new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger); + } + } + + // Fallback to cover shared calendars foreach ($this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']) as $calendar) { if ($calendar['uri'] === $name) { return new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger); @@ -205,9 +202,4 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { $principalUri = $this->principalInfo['uri']; return $this->caldavBackend->calendarSearch($principalUri, $filters, $limit, $offset); } - - - public function enableCachedSubscriptionsForThisRequest() { - $this->returnCachedSubscriptions = true; - } } diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index 406389e3a3d..5f912da732e 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -3,70 +3,48 @@ declare(strict_types=1); /** - * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; +use Generator; use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; +use OCP\Calendar\CalendarExportOptions; use OCP\Calendar\Exceptions\CalendarException; +use OCP\Calendar\ICalendarExport; +use OCP\Calendar\ICalendarIsEnabled; +use OCP\Calendar\ICalendarIsShared; +use OCP\Calendar\ICalendarIsWritable; use OCP\Calendar\ICreateFromString; +use OCP\Calendar\IHandleImipMessage; use OCP\Constants; +use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp; use Sabre\DAV\Exception\Conflict; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; +use Sabre\VObject\Component\VTimeZone; +use Sabre\VObject\ITip\Message; +use Sabre\VObject\Property; +use Sabre\VObject\Reader; use function Sabre\Uri\split as uriSplit; -class CalendarImpl implements ICreateFromString { - - /** @var CalDavBackend */ - private $backend; - - /** @var Calendar */ - private $calendar; - - /** @var array */ - private $calendarInfo; - - /** - * CalendarImpl constructor. - * - * @param Calendar $calendar - * @param array $calendarInfo - * @param CalDavBackend $backend - */ - public function __construct(Calendar $calendar, - array $calendarInfo, - CalDavBackend $backend) { - $this->calendar = $calendar; - $this->calendarInfo = $calendarInfo; - $this->backend = $backend; +class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIsWritable, ICalendarIsShared, ICalendarExport, ICalendarIsEnabled { + public function __construct( + private Calendar $calendar, + /** @var array<string, mixed> */ + private array $calendarInfo, + private CalDavBackend $backend, + ) { } /** * @return string defining the technical unique key * @since 13.0.0 */ - public function getKey() { - return $this->calendarInfo['id']; + public function getKey(): string { + return (string)$this->calendarInfo['id']; } /** @@ -78,45 +56,60 @@ class CalendarImpl implements ICreateFromString { /** * In comparison to getKey() this function returns a human readable (maybe translated) name - * @return null|string * @since 13.0.0 */ - public function getDisplayName() { + public function getDisplayName(): ?string { return $this->calendarInfo['{DAV:}displayname']; } /** * Calendar color - * @return null|string * @since 13.0.0 */ - public function getDisplayColor() { + public function getDisplayColor(): ?string { return $this->calendarInfo['{http://apple.com/ns/ical/}calendar-color']; } - /** - * @param string $pattern which should match within the $searchProperties - * @param array $searchProperties defines the properties within the query pattern should match - * @param array $options - optional parameters: - * ['timerange' => ['start' => new DateTime(...), 'end' => new DateTime(...)]] - * @param integer|null $limit - limit number of search results - * @param integer|null $offset - offset for paging of search results - * @return array an array of events/journals/todos which are arrays of key-value-pairs - * @since 13.0.0 - */ - public function search($pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null) { + public function getSchedulingTransparency(): ?ScheduleCalendarTransp { + return $this->calendarInfo['{' . \OCA\DAV\CalDAV\Schedule\Plugin::NS_CALDAV . '}schedule-calendar-transp']; + } + + public function getSchedulingTimezone(): ?VTimeZone { + $tzProp = '{' . \OCA\DAV\CalDAV\Schedule\Plugin::NS_CALDAV . '}calendar-timezone'; + if (!isset($this->calendarInfo[$tzProp])) { + return null; + } + // This property contains a VCALENDAR with a single VTIMEZONE + /** @var string $timezoneProp */ + $timezoneProp = $this->calendarInfo[$tzProp]; + /** @var VCalendar $vobj */ + $vobj = Reader::read($timezoneProp); + $components = $vobj->getComponents(); + if (empty($components)) { + return null; + } + /** @var VTimeZone $vtimezone */ + $vtimezone = $components[0]; + return $vtimezone; + } + + public function search(string $pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null): array { return $this->backend->search($this->calendarInfo, $pattern, $searchProperties, $options, $limit, $offset); } /** - * @return integer build up using \OCP\Constants + * @return int build up using \OCP\Constants * @since 13.0.0 */ - public function getPermissions() { + public function getPermissions(): int { $permissions = $this->calendar->getACL(); $result = 0; foreach ($permissions as $permission) { + if ($this->calendarInfo['principaluri'] !== $permission['principal']) { + continue; + } + switch ($permission['privilege']) { case '{DAV:}read': $result |= Constants::PERMISSION_READ; @@ -135,19 +128,43 @@ class CalendarImpl implements ICreateFromString { } /** - * Create a new calendar event for this calendar - * by way of an ICS string - * - * @param string $name the file name - needs to contan the .ics ending - * @param string $calendarData a string containing a valid VEVENT ics - * - * @throws CalendarException + * @since 32.0.0 */ - public function createFromString(string $name, string $calendarData): void { - $server = new InvitationResponseServer(false); + public function isEnabled(): bool { + return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true; + } + + /** + * @since 31.0.0 + */ + public function isWritable(): bool { + return $this->calendar->canWrite(); + } + + /** + * @since 26.0.0 + */ + public function isDeleted(): bool { + return $this->calendar->isDeleted(); + } + + /** + * @since 31.0.0 + */ + public function isShared(): bool { + return $this->calendar->isShared(); + } + /** + * @throws CalendarException + */ + private function createFromStringInServer( + string $name, + string $calendarData, + \OCA\DAV\Connector\Sabre\Server $server, + ): void { /** @var CustomPrincipalPlugin $plugin */ - $plugin = $server->server->getPlugin('auth'); + $plugin = $server->getPlugin('auth'); // we're working around the previous implementation // that only allowed the public system principal to be used // so set the custom principal here @@ -163,18 +180,113 @@ class CalendarImpl implements ICreateFromString { // Force calendar change URI /** @var Schedule\Plugin $schedulingPlugin */ - $schedulingPlugin = $server->server->getPlugin('caldav-schedule'); + $schedulingPlugin = $server->getPlugin('caldav-schedule'); $schedulingPlugin->setPathOfCalendarObjectChange($fullCalendarFilename); $stream = fopen('php://memory', 'rb+'); fwrite($stream, $calendarData); rewind($stream); try { - $server->server->createFile($fullCalendarFilename, $stream); + $server->createFile($fullCalendarFilename, $stream); } catch (Conflict $e) { throw new CalendarException('Could not create new calendar event: ' . $e->getMessage(), 0, $e); } finally { fclose($stream); } } + + public function createFromString(string $name, string $calendarData): void { + $server = new EmbeddedCalDavServer(false); + $this->createFromStringInServer($name, $calendarData, $server->getServer()); + } + + public function createFromStringMinimal(string $name, string $calendarData): void { + $server = new InvitationResponseServer(false); + $this->createFromStringInServer($name, $calendarData, $server->getServer()); + } + + /** + * @throws CalendarException + */ + public function handleIMipMessage(string $name, string $calendarData): void { + $server = $this->getInvitationResponseServer(); + + /** @var CustomPrincipalPlugin $plugin */ + $plugin = $server->getServer()->getPlugin('auth'); + // we're working around the previous implementation + // that only allowed the public system principal to be used + // so set the custom principal here + $plugin->setCurrentPrincipal($this->calendar->getPrincipalURI()); + + if (empty($this->calendarInfo['uri'])) { + throw new CalendarException('Could not write to calendar as URI parameter is missing'); + } + // Force calendar change URI + /** @var Schedule\Plugin $schedulingPlugin */ + $schedulingPlugin = $server->getServer()->getPlugin('caldav-schedule'); + // Let sabre handle the rest + $iTipMessage = new Message(); + /** @var VCalendar $vObject */ + $vObject = Reader::read($calendarData); + /** @var VEvent $vEvent */ + $vEvent = $vObject->{'VEVENT'}; + + if ($vObject->{'METHOD'} === null) { + throw new CalendarException('No Method provided for scheduling data. Could not process message'); + } + + if (!isset($vEvent->{'ORGANIZER'}) || !isset($vEvent->{'ATTENDEE'})) { + throw new CalendarException('Could not process scheduling data, neccessary data missing from ICAL'); + } + $organizer = $vEvent->{'ORGANIZER'}->getValue(); + $attendee = $vEvent->{'ATTENDEE'}->getValue(); + + $iTipMessage->method = $vObject->{'METHOD'}->getValue(); + if ($iTipMessage->method === 'REQUEST') { + $iTipMessage->sender = $organizer; + $iTipMessage->recipient = $attendee; + } elseif ($iTipMessage->method === 'REPLY') { + if ($server->isExternalAttendee($vEvent->{'ATTENDEE'}->getValue())) { + $iTipMessage->recipient = $organizer; + } else { + $iTipMessage->recipient = $attendee; + } + $iTipMessage->sender = $attendee; + } elseif ($iTipMessage->method === 'CANCEL') { + $iTipMessage->recipient = $attendee; + $iTipMessage->sender = $organizer; + } + $iTipMessage->uid = isset($vEvent->{'UID'}) ? $vEvent->{'UID'}->getValue() : ''; + $iTipMessage->component = 'VEVENT'; + $iTipMessage->sequence = isset($vEvent->{'SEQUENCE'}) ? (int)$vEvent->{'SEQUENCE'}->getValue() : 0; + $iTipMessage->message = $vObject; + $server->server->emit('schedule', [$iTipMessage]); + } + + public function getInvitationResponseServer(): InvitationResponseServer { + return new InvitationResponseServer(false); + } + + /** + * Export objects + * + * @since 32.0.0 + * + * @return Generator<mixed, \Sabre\VObject\Component\VCalendar, mixed, mixed> + */ + public function export(?CalendarExportOptions $options = null): Generator { + foreach ( + $this->backend->exportCalendar( + $this->calendarInfo['id'], + $this->backend::CALENDAR_TYPE_CALENDAR, + $options + ) as $event + ) { + $vObject = Reader::read($event['calendardata']); + if ($vObject instanceof VCalendar) { + yield $vObject; + } + } + } + } diff --git a/apps/dav/lib/CalDAV/CalendarManager.php b/apps/dav/lib/CalDAV/CalendarManager.php index daa96a51392..a2d2f1cda8a 100644 --- a/apps/dav/lib/CalDAV/CalendarManager.php +++ b/apps/dav/lib/CalDAV/CalendarManager.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; @@ -31,18 +13,6 @@ use Psr\Log\LoggerInterface; class CalendarManager { - /** @var CalDavBackend */ - private $backend; - - /** @var IL10N */ - private $l10n; - - /** @var IConfig */ - private $config; - - /** @var LoggerInterface */ - private $logger; - /** * CalendarManager constructor. * @@ -50,11 +20,12 @@ class CalendarManager { * @param IL10N $l10n * @param IConfig $config */ - public function __construct(CalDavBackend $backend, IL10N $l10n, IConfig $config, LoggerInterface $logger) { - $this->backend = $backend; - $this->l10n = $l10n; - $this->config = $config; - $this->logger = $logger; + public function __construct( + private CalDavBackend $backend, + private IL10N $l10n, + private IConfig $config, + private LoggerInterface $logger, + ) { } /** diff --git a/apps/dav/lib/CalDAV/CalendarObject.php b/apps/dav/lib/CalDAV/CalendarObject.php index c927254fba3..02178b4236f 100644 --- a/apps/dav/lib/CalDAV/CalendarObject.php +++ b/apps/dav/lib/CalDAV/CalendarObject.php @@ -1,28 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2017, Georg Ehrke - * @copyright Copyright (c) 2020, Gary Kim <gary@garykim.dev> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Gary Kim <gary@garykim.dev> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -33,9 +14,6 @@ use Sabre\VObject\Reader; class CalendarObject extends \Sabre\CalDAV\CalendarObject { - /** @var IL10N */ - protected $l10n; - /** * CalendarObject constructor. * @@ -44,16 +22,17 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { * @param array $calendarInfo * @param array $objectData */ - public function __construct(CalDavBackend $caldavBackend, IL10N $l10n, - array $calendarInfo, - array $objectData) { + public function __construct( + CalDavBackend $caldavBackend, + protected IL10N $l10n, + array $calendarInfo, + array $objectData, + ) { parent::__construct($caldavBackend, $calendarInfo, $objectData); if ($this->isShared()) { unset($this->objectData['size']); } - - $this->l10n = $l10n; } /** @@ -82,7 +61,7 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { } public function getId(): int { - return (int) $this->objectData['id']; + return (int)$this->objectData['id']; } protected function isShared() { @@ -97,28 +76,29 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { * @param Component\VCalendar $vObject * @return void */ - private function createConfidentialObject(Component\VCalendar $vObject) { + private function createConfidentialObject(Component\VCalendar $vObject): void { /** @var Component $vElement */ - $vElement = null; - if (isset($vObject->VEVENT)) { - $vElement = $vObject->VEVENT; - } - if (isset($vObject->VJOURNAL)) { - $vElement = $vObject->VJOURNAL; - } - if (isset($vObject->VTODO)) { - $vElement = $vObject->VTODO; - } - if (!is_null($vElement)) { + $vElements = array_filter($vObject->getComponents(), static function ($vElement) { + return $vElement instanceof Component\VEvent || $vElement instanceof Component\VJournal || $vElement instanceof Component\VTodo; + }); + + foreach ($vElements as $vElement) { + if (empty($vElement->select('SUMMARY'))) { + $vElement->add('SUMMARY', $this->l10n->t('Busy')); // This is needed to mask "Untitled Event" events + } foreach ($vElement->children() as &$property) { /** @var Property $property */ switch ($property->name) { case 'CREATED': case 'DTSTART': case 'RRULE': + case 'RECURRENCE-ID': + case 'RDATE': case 'DURATION': case 'DTEND': case 'CLASS': + case 'EXRULE': + case 'EXDATE': case 'UID': break; case 'SUMMARY': @@ -162,4 +142,11 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { public function getPrincipalUri(): string { return $this->calendarInfo['principaluri']; } + + public function getOwner(): ?string { + if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) { + return $this->calendarInfo['{http://owncloud.org/ns}owner-principal']; + } + return parent::getOwner(); + } } diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php index f29c601db2d..3cc4039ed36 100644 --- a/apps/dav/lib/CalDAV/CalendarProvider.php +++ b/apps/dav/lib/CalDAV/CalendarProvider.php @@ -3,28 +3,13 @@ declare(strict_types=1); /** - * @copyright 2021 Anna Larch <anna.larch@gmx.net> - * - * @author Anna Larch <anna.larch@gmx.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; +use OCA\DAV\Db\Property; +use OCA\DAV\Db\PropertyMapper; use OCP\Calendar\ICalendarProvider; use OCP\IConfig; use OCP\IL10N; @@ -32,39 +17,28 @@ use Psr\Log\LoggerInterface; class CalendarProvider implements ICalendarProvider { - /** @var CalDavBackend */ - private $calDavBackend; - - /** @var IL10N */ - private $l10n; - - /** @var IConfig */ - private $config; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(CalDavBackend $calDavBackend, IL10N $l10n, IConfig $config, LoggerInterface $logger) { - $this->calDavBackend = $calDavBackend; - $this->l10n = $l10n; - $this->config = $config; - $this->logger = $logger; + public function __construct( + private CalDavBackend $calDavBackend, + private IL10N $l10n, + private IConfig $config, + private LoggerInterface $logger, + private PropertyMapper $propertyMapper, + ) { } public function getCalendars(string $principalUri, array $calendarUris = []): array { - $calendarInfos = []; - if (empty($calendarUris)) { - $calendarInfos = $this->calDavBackend->getCalendarsForUser($principalUri); - } else { - foreach ($calendarUris as $calendarUri) { - $calendarInfos[] = $this->calDavBackend->getCalendarByUri($principalUri, $calendarUri); - } - } - $calendarInfos = array_filter($calendarInfos); + $calendarInfos = $this->calDavBackend->getCalendarsForUser($principalUri) ?? []; + + if (!empty($calendarUris)) { + $calendarInfos = array_filter($calendarInfos, function ($calendar) use ($calendarUris) { + return in_array($calendar['uri'], $calendarUris); + }); + } $iCalendars = []; foreach ($calendarInfos as $calendarInfo) { + $calendarInfo = array_merge($calendarInfo, $this->getAdditionalProperties($calendarInfo['principaluri'], $calendarInfo['uri'])); $calendar = new Calendar($this->calDavBackend, $calendarInfo, $this->l10n, $this->config, $this->logger); $iCalendars[] = new CalendarImpl( $calendar, @@ -74,4 +48,23 @@ class CalendarProvider implements ICalendarProvider { } return $iCalendars; } + + public function getAdditionalProperties(string $principalUri, string $calendarUri): array { + $user = str_replace('principals/users/', '', $principalUri); + $path = 'calendars/' . $user . '/' . $calendarUri; + + $properties = $this->propertyMapper->findPropertiesByPath($user, $path); + + $list = []; + foreach ($properties as $property) { + if ($property instanceof Property) { + $list[$property->getPropertyname()] = match ($property->getPropertyname()) { + '{http://owncloud.org/ns}calendar-enabled' => (bool)$property->getPropertyvalue(), + default => $property->getPropertyvalue() + }; + } + } + + return $list; + } } diff --git a/apps/dav/lib/CalDAV/CalendarRoot.php b/apps/dav/lib/CalDAV/CalendarRoot.php index 0c701d9cdcf..c0a313955bb 100644 --- a/apps/dav/lib/CalDAV/CalendarRoot.php +++ b/apps/dav/lib/CalDAV/CalendarRoot.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -30,25 +12,29 @@ use Sabre\CalDAV\Backend; use Sabre\DAVACL\PrincipalBackend; class CalendarRoot extends \Sabre\CalDAV\CalendarRoot { - private LoggerInterface $logger; + private array $returnCachedSubscriptions = []; public function __construct( PrincipalBackend\BackendInterface $principalBackend, Backend\BackendInterface $caldavBackend, $principalPrefix, - LoggerInterface $logger + private LoggerInterface $logger, ) { parent::__construct($principalBackend, $caldavBackend, $principalPrefix); - $this->logger = $logger; } public function getChildForPrincipal(array $principal) { - return new CalendarHome($this->caldavBackend, $principal, $this->logger); + return new CalendarHome( + $this->caldavBackend, + $principal, + $this->logger, + array_key_exists($principal['uri'], $this->returnCachedSubscriptions) + ); } public function getName() { - if ($this->principalPrefix === 'principals/calendar-resources' || - $this->principalPrefix === 'principals/calendar-rooms') { + if ($this->principalPrefix === 'principals/calendar-resources' + || $this->principalPrefix === 'principals/calendar-rooms') { $parts = explode('/', $this->principalPrefix); return $parts[1]; @@ -56,4 +42,8 @@ class CalendarRoot extends \Sabre\CalDAV\CalendarRoot { return parent::getName(); } + + public function enableReturnCachedSubscriptions(string $principalUri): void { + $this->returnCachedSubscriptions['principals/users/' . $principalUri] = true; + } } diff --git a/apps/dav/lib/CalDAV/DefaultCalendarValidator.php b/apps/dav/lib/CalDAV/DefaultCalendarValidator.php new file mode 100644 index 00000000000..266e07ef255 --- /dev/null +++ b/apps/dav/lib/CalDAV/DefaultCalendarValidator.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use Sabre\DAV\Exception as DavException; + +class DefaultCalendarValidator { + /** + * Check if a given Calendar node is suitable to be used as the default calendar for scheduling. + * + * @throws DavException If the calendar is not suitable to be used as the default calendar + */ + public function validateScheduleDefaultCalendar(Calendar $calendar): void { + // Sanity checks for a calendar that should handle invitations + if ($calendar->isSubscription() + || !$calendar->canWrite() + || $calendar->isShared() + || $calendar->isDeleted()) { + throw new DavException('Calendar is a subscription, not writable, shared or deleted'); + } + + // Calendar must support VEVENTs + $sCCS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'; + $calendarProperties = $calendar->getProperties([$sCCS]); + if (isset($calendarProperties[$sCCS])) { + $supportedComponents = $calendarProperties[$sCCS]->getValue(); + } else { + $supportedComponents = ['VJOURNAL', 'VTODO', 'VEVENT']; + } + if (!in_array('VEVENT', $supportedComponents, true)) { + throw new DavException('Calendar does not support VEVENT components'); + } + } +} diff --git a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php new file mode 100644 index 00000000000..21d8c06fa99 --- /dev/null +++ b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php @@ -0,0 +1,118 @@ +<?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\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)); + } + + // wait with registering these until auth is handled and the filesystem is setup + $this->server->on('beforeMethod:*', function () use ($root): void { + // register plugins from apps + $pluginManager = new PluginManager( + \OC::$server, + Server::get(IAppManager::class) + ); + foreach ($pluginManager->getAppPlugins() as $appPlugin) { + $this->server->addPlugin($appPlugin); + } + foreach ($pluginManager->getAppCollections() as $appCollection) { + $root->addChild($appCollection); + } + }); + } + + public function getServer(): \OCA\DAV\Connector\Sabre\Server { + return $this->server; + } +} diff --git a/apps/dav/lib/CalDAV/EventComparisonService.php b/apps/dav/lib/CalDAV/EventComparisonService.php new file mode 100644 index 00000000000..63395e7ce1c --- /dev/null +++ b/apps/dav/lib/CalDAV/EventComparisonService.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV; + +use OCA\DAV\CalDAV\Schedule\IMipService; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; + +class EventComparisonService { + + /** @var string[] */ + private const EVENT_DIFF = [ + 'RECURRENCE-ID', + 'RRULE', + 'SEQUENCE', + 'LAST-MODIFIED' + ]; + + + /** + * If found, remove the event from $eventsToFilter that + * is identical to the passed $filterEvent + * and return whether an identical event was found + * + * This function takes into account the SEQUENCE, + * RRULE, RECURRENCE-ID and LAST-MODIFIED parameters + * + * @param VEvent $filterEvent + * @param array $eventsToFilter + * @return bool true if there was an identical event found and removed, false if there wasn't + */ + private function removeIfUnchanged(VEvent $filterEvent, array &$eventsToFilter): bool { + $filterEventData = []; + foreach (self::EVENT_DIFF as $eventDiff) { + $filterEventData[] = IMipService::readPropertyWithDefault($filterEvent, $eventDiff, ''); + } + + /** @var VEvent $component */ + foreach ($eventsToFilter as $k => $eventToFilter) { + $eventToFilterData = []; + foreach (self::EVENT_DIFF as $eventDiff) { + $eventToFilterData[] = IMipService::readPropertyWithDefault($eventToFilter, $eventDiff, ''); + } + // events are identical and can be removed + if ($filterEventData === $eventToFilterData) { + unset($eventsToFilter[$k]); + return true; + } + } + return false; + } + + /** + * Compare two VCalendars with each other and find all changed elements + * + * Returns an array of old and new events + * + * Old events are only detected if they are also changed + * If there is no corresponding old event for a VEvent, it + * has been newly created + * + * @param VCalendar $new + * @param VCalendar|null $old + * @return array<string, VEvent[]|null> + */ + public function findModified(VCalendar $new, ?VCalendar $old): array { + $newEventComponents = $new->getComponents(); + + foreach ($newEventComponents as $k => $event) { + if (!$event instanceof VEvent) { + unset($newEventComponents[$k]); + } + } + + if (empty($old)) { + return ['old' => null, 'new' => $newEventComponents]; + } + + $oldEventComponents = $old->getComponents(); + if (is_array($oldEventComponents) && !empty($oldEventComponents)) { + foreach ($oldEventComponents as $k => $event) { + if (!$event instanceof VEvent) { + unset($oldEventComponents[$k]); + continue; + } + if ($this->removeIfUnchanged($event, $newEventComponents)) { + unset($oldEventComponents[$k]); + } + } + } + + return ['old' => array_values($oldEventComponents), 'new' => array_values($newEventComponents)]; + } +} diff --git a/apps/dav/lib/CalDAV/EventReader.php b/apps/dav/lib/CalDAV/EventReader.php new file mode 100644 index 00000000000..ee2b8f33f9a --- /dev/null +++ b/apps/dav/lib/CalDAV/EventReader.php @@ -0,0 +1,771 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use DateTime; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use InvalidArgumentException; + +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; +use Sabre\VObject\Reader; + +class EventReader { + + protected VEvent $baseEvent; + protected DateTimeInterface $baseEventStartDate; + protected DateTimeZone $baseEventStartTimeZone; + protected DateTimeInterface $baseEventEndDate; + protected DateTimeZone $baseEventEndTimeZone; + protected bool $baseEventStartDateFloating = false; + protected bool $baseEventEndDateFloating = false; + protected int $baseEventDuration; + + protected ?EventReaderRRule $rruleIterator = null; + protected ?EventReaderRDate $rdateIterator = null; + protected ?EventReaderRRule $eruleIterator = null; + protected ?EventReaderRDate $edateIterator = null; + + protected array $recurrenceModified; + protected ?DateTimeInterface $recurrenceCurrentDate; + + protected array $dayNamesMap = [ + 'MO' => 'Monday', 'TU' => 'Tuesday', 'WE' => 'Wednesday', 'TH' => 'Thursday', 'FR' => 'Friday', 'SA' => 'Saturday', 'SU' => 'Sunday' + ]; + protected array $monthNamesMap = [ + 1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', 5 => 'May', 6 => 'June', + 7 => 'July', 8 => 'August', 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December' + ]; + protected array $relativePositionNamesMap = [ + 1 => 'First', 2 => 'Second', 3 => 'Third', 4 => 'Fourth', 5 => 'Fifth', + -1 => 'Last', -2 => 'Second Last', -3 => 'Third Last', -4 => 'Fourth Last', -5 => 'Fifth Last' + ]; + + /** + * Initilizes the Event Reader + * + * There is several ways to set up the iterator. + * + * 1. You can pass a VCALENDAR component (as object or string) and a UID. + * 2. You can pass an array of VEVENTs (all UIDS should match). + * 3. You can pass a single VEVENT component (as object or string). + * + * Only the second method is recommended. The other 1 and 3 will be removed + * at some point in the future. + * + * The $uid parameter is only required for the first method. + * + * @since 30.0.0 + * + * @param VCalendar|VEvent|Array|String $input + * @param string|null $uid + * @param DateTimeZone|null $timeZone reference timezone for floating dates and times + */ + public function __construct(VCalendar|VEvent|array|string $input, ?string $uid = null, ?DateTimeZone $timeZone = null) { + + $timeZoneFactory = new TimeZoneFactory(); + + // evaluate if the input is a string and convert it to and vobject if required + if (is_string($input)) { + $input = Reader::read($input); + } + // evaluate if input is a single event vobject and convert it to a collection + if ($input instanceof VEvent) { + $events = [$input]; + } + // evaluate if input is a calendar vobject + elseif ($input instanceof VCalendar) { + // Calendar + UID mode. + if ($uid === null) { + throw new InvalidArgumentException('The UID argument is required when a VCALENDAR object is used'); + } + // extract events from calendar + $events = $input->getByUID($uid); + // evaluate if any event where found + if (count($events) === 0) { + throw new InvalidArgumentException('This VCALENDAR did not have an event with UID: ' . $uid); + } + // extract calendar timezone + if (isset($input->VTIMEZONE) && isset($input->VTIMEZONE->TZID)) { + $calendarTimeZone = $timeZoneFactory->fromName($input->VTIMEZONE->TZID->getValue()); + } + } + // evaluate if input is a collection of event vobjects + elseif (is_array($input)) { + $events = $input; + } else { + throw new InvalidArgumentException('Invalid input data type'); + } + // find base event instance and remove it from events collection + foreach ($events as $key => $vevent) { + if (!isset($vevent->{'RECURRENCE-ID'})) { + $this->baseEvent = $vevent; + unset($events[$key]); + } + } + + // No base event was found. CalDAV does allow cases where only + // overridden instances are stored. + // + // In this particular case, we're just going to grab the first + // event and use that instead. This may not always give the + // desired result. + if (!isset($this->baseEvent) && count($events) > 0) { + $this->baseEvent = array_shift($events); + } + + // determine the event starting time zone + // we require this to align all other dates times + // evaluate if timezone parameter was used (treat this as a override) + if ($timeZone !== null) { + $this->baseEventStartTimeZone = $timeZone; + } + // evaluate if event start date has a timezone parameter + elseif (isset($this->baseEvent->DTSTART->parameters['TZID'])) { + $this->baseEventStartTimeZone = $timeZoneFactory->fromName($this->baseEvent->DTSTART->parameters['TZID']->getValue()) ?? new DateTimeZone('UTC'); + } + // evaluate if event parent calendar has a time zone + elseif (isset($calendarTimeZone)) { + $this->baseEventStartTimeZone = clone $calendarTimeZone; + } + // otherwise, as a last resort use the UTC timezone + else { + $this->baseEventStartTimeZone = new DateTimeZone('UTC'); + } + + // determine the event end time zone + // we require this to align all other dates and times + // evaluate if timezone parameter was used (treat this as a override) + if ($timeZone !== null) { + $this->baseEventEndTimeZone = $timeZone; + } + // evaluate if event end date has a timezone parameter + elseif (isset($this->baseEvent->DTEND->parameters['TZID'])) { + $this->baseEventEndTimeZone = $timeZoneFactory->fromName($this->baseEvent->DTEND->parameters['TZID']->getValue()) ?? new DateTimeZone('UTC'); + } + // evaluate if event parent calendar has a time zone + elseif (isset($calendarTimeZone)) { + $this->baseEventEndTimeZone = clone $calendarTimeZone; + } + // otherwise, as a last resort use the start date time zone + else { + $this->baseEventEndTimeZone = clone $this->baseEventStartTimeZone; + } + // extract start date and time + $this->baseEventStartDate = $this->baseEvent->DTSTART->getDateTime($this->baseEventStartTimeZone); + $this->baseEventStartDateFloating = $this->baseEvent->DTSTART->isFloating(); + // determine event end date and duration + // evaluate if end date exists + // extract end date and calculate duration + if (isset($this->baseEvent->DTEND)) { + $this->baseEventEndDate = $this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone); + $this->baseEventEndDateFloating = $this->baseEvent->DTEND->isFloating(); + $this->baseEventDuration + = $this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone)->getTimeStamp() + - $this->baseEventStartDate->getTimeStamp(); + } + // evaluate if duration exists + // extract duration and calculate end date + elseif (isset($this->baseEvent->DURATION)) { + $this->baseEventEndDate = DateTimeImmutable::createFromInterface($this->baseEventStartDate) + ->add($this->baseEvent->DURATION->getDateInterval()); + $this->baseEventDuration = $this->baseEventEndDate->getTimestamp() - $this->baseEventStartDate->getTimestamp(); + } + // evaluate if start date is floating + // set duration to 24 hours and calculate the end date + // according to the rfc any event without a end date or duration is a complete day + elseif ($this->baseEventStartDateFloating == true) { + $this->baseEventDuration = 86400; + $this->baseEventEndDate = DateTimeImmutable::createFromInterface($this->baseEventStartDate) + ->setTimestamp($this->baseEventStartDate->getTimestamp() + $this->baseEventDuration); + } + // otherwise, set duration to zero this should never happen + else { + $this->baseEventDuration = 0; + $this->baseEventEndDate = $this->baseEventStartDate; + } + // evaluate if RRULE exist and construct iterator + if (isset($this->baseEvent->RRULE)) { + $this->rruleIterator = new EventReaderRRule( + $this->baseEvent->RRULE->getParts(), + $this->baseEventStartDate + ); + } + // evaluate if RDATE exist and construct iterator + if (isset($this->baseEvent->RDATE)) { + $dates = []; + foreach ($this->baseEvent->RDATE as $entry) { + $dates[] = $entry->getValue(); + } + $this->rdateIterator = new EventReaderRDate( + implode(',', $dates), + $this->baseEventStartDate + ); + } + // evaluate if EXRULE exist and construct iterator + if (isset($this->baseEvent->EXRULE)) { + $this->eruleIterator = new EventReaderRRule( + $this->baseEvent->EXRULE->getParts(), + $this->baseEventStartDate + ); + } + // evaluate if EXDATE exist and construct iterator + if (isset($this->baseEvent->EXDATE)) { + $dates = []; + foreach ($this->baseEvent->EXDATE as $entry) { + $dates[] = $entry->getValue(); + } + $this->edateIterator = new EventReaderRDate( + implode(',', $dates), + $this->baseEventStartDate + ); + } + // construct collection of modified events with recurrence id as hash + foreach ($events as $vevent) { + $this->recurrenceModified[$vevent->{'RECURRENCE-ID'}->getDateTime($this->baseEventStartTimeZone)->getTimeStamp()] = $vevent; + } + + $this->recurrenceCurrentDate = clone $this->baseEventStartDate; + } + + /** + * retrieve date and time of event start + * + * @since 30.0.0 + * + * @return DateTime + */ + public function startDateTime(): DateTime { + return DateTime::createFromInterface($this->baseEventStartDate); + } + + /** + * retrieve time zone of event start + * + * @since 30.0.0 + * + * @return DateTimeZone + */ + public function startTimeZone(): DateTimeZone { + return $this->baseEventStartTimeZone; + } + + /** + * retrieve date and time of event end + * + * @since 30.0.0 + * + * @return DateTime + */ + public function endDateTime(): DateTime { + return DateTime::createFromInterface($this->baseEventEndDate); + } + + /** + * retrieve time zone of event end + * + * @since 30.0.0 + * + * @return DateTimeZone + */ + public function endTimeZone(): DateTimeZone { + return $this->baseEventEndTimeZone; + } + + /** + * is this an all day event + * + * @since 30.0.0 + * + * @return bool + */ + public function entireDay(): bool { + return $this->baseEventStartDateFloating; + } + + /** + * is this a recurring event + * + * @since 30.0.0 + * + * @return bool + */ + public function recurs(): bool { + return ($this->rruleIterator !== null || $this->rdateIterator !== null); + } + + /** + * event recurrence pattern + * + * @since 30.0.0 + * + * @return string|null R - Relative or A - Absolute + */ + public function recurringPattern(): ?string { + if ($this->rruleIterator === null && $this->rdateIterator === null) { + return null; + } + if ($this->rruleIterator?->isRelative()) { + return 'R'; + } + return 'A'; + } + + /** + * event recurrence precision + * + * @since 30.0.0 + * + * @return string|null daily, weekly, monthly, yearly, fixed + */ + public function recurringPrecision(): ?string { + if ($this->rruleIterator !== null) { + return $this->rruleIterator->precision(); + } + if ($this->rdateIterator !== null) { + return 'fixed'; + } + return null; + } + + /** + * event recurrence interval + * + * @since 30.0.0 + * + * @return int|null + */ + public function recurringInterval(): ?int { + return $this->rruleIterator?->interval(); + } + + /** + * event recurrence conclusion + * + * returns true if RRULE with UNTIL or COUNT (calculated) is used + * returns true RDATE is used + * returns false if RRULE or RDATE are absent, or RRRULE is infinite + * + * @since 30.0.0 + * + * @return bool + */ + public function recurringConcludes(): bool { + + // retrieve rrule conclusions + if ($this->rruleIterator?->concludesOn() !== null + || $this->rruleIterator?->concludesAfter() !== null) { + return true; + } + // retrieve rdate conclusions + if ($this->rdateIterator?->concludesAfter() !== null) { + return true; + } + + return false; + + } + + /** + * event recurrence conclusion iterations + * + * returns the COUNT value if RRULE is used + * returns the collection count if RDATE is used + * returns combined count of RRULE COUNT and RDATE if both are used + * returns null if RRULE and RDATE are absent + * + * @since 30.0.0 + * + * @return int|null + */ + public function recurringConcludesAfter(): ?int { + + // construct count place holder + $count = 0; + // retrieve and add RRULE iterations count + $count += (int)$this->rruleIterator?->concludesAfter(); + // retrieve and add RDATE iterations count + $count += (int)$this->rdateIterator?->concludesAfter(); + // return count + return !empty($count) ? $count : null; + + } + + /** + * event recurrence conclusion date + * + * returns the last date of UNTIL or COUNT (calculated) if RRULE is used + * returns the last date in the collection if RDATE is used + * returns the highest date if both RRULE and RDATE are used + * returns null if RRULE and RDATE are absent or RRULE is infinite + * + * @since 30.0.0 + * + * @return DateTime|null + */ + public function recurringConcludesOn(): ?DateTime { + + if ($this->rruleIterator !== null) { + // retrieve rrule conclusion date + $rrule = $this->rruleIterator->concludes(); + // evaluate if rrule conclusion is null + // if this is null that means the recurrence is infinate + if ($rrule === null) { + return null; + } + } + // retrieve rdate conclusion date + if ($this->rdateIterator !== null) { + $rdate = $this->rdateIterator->concludes(); + } + // evaluate if both rrule and rdate have date + if (isset($rdate) && isset($rrule)) { + // return the highest date + return (($rdate > $rrule) ? $rdate : $rrule); + } elseif (isset($rrule)) { + return $rrule; + } elseif (isset($rdate)) { + return $rdate; + } + + return null; + + } + + /** + * event recurrence days of the week + * + * returns collection of RRULE BYDAY day(s) ['MO','WE','FR'] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringDaysOfWeek(): array { + // evaluate if RRULE exists and return day(s) of the week + return $this->rruleIterator !== null ? $this->rruleIterator->daysOfWeek() : []; + } + + /** + * event recurrence days of the week (named) + * + * returns collection of RRULE BYDAY day(s) ['Monday','Wednesday','Friday'] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringDaysOfWeekNamed(): array { + // evaluate if RRULE exists and extract day(s) of the week + $days = $this->rruleIterator !== null ? $this->rruleIterator->daysOfWeek() : []; + // convert numberic month to month name + foreach ($days as $key => $value) { + $days[$key] = $this->dayNamesMap[$value]; + } + // return names collection + return $days; + } + + /** + * event recurrence days of the month + * + * returns collection of RRULE BYMONTHDAY day(s) [7, 15, 31] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringDaysOfMonth(): array { + // evaluate if RRULE exists and return day(s) of the month + return $this->rruleIterator !== null ? $this->rruleIterator->daysOfMonth() : []; + } + + /** + * event recurrence days of the year + * + * returns collection of RRULE BYYEARDAY day(s) [57, 205, 365] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringDaysOfYear(): array { + // evaluate if RRULE exists and return day(s) of the year + return $this->rruleIterator !== null ? $this->rruleIterator->daysOfYear() : []; + } + + /** + * event recurrence weeks of the month + * + * returns collection of RRULE SETPOS weeks(s) [1, 3, -1] + * returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringWeeksOfMonth(): array { + // evaluate if RRULE exists and RRULE is relative return relative position(s) + return $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : []; + } + + /** + * event recurrence weeks of the month (named) + * + * returns collection of RRULE SETPOS weeks(s) [1, 3, -1] + * returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringWeeksOfMonthNamed(): array { + // evaluate if RRULE exists and extract relative position(s) + $positions = $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : []; + // convert numberic relative position to relative label + foreach ($positions as $key => $value) { + $positions[$key] = $this->relativePositionNamesMap[$value]; + } + // return positions collection + return $positions; + } + + /** + * event recurrence weeks of the year + * + * returns collection of RRULE BYWEEKNO weeks(s) [12, 32, 52] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringWeeksOfYear(): array { + // evaluate if RRULE exists and return weeks(s) of the year + return $this->rruleIterator !== null ? $this->rruleIterator->weeksOfYear() : []; + } + + /** + * event recurrence months of the year + * + * returns collection of RRULE BYMONTH month(s) [3, 7, 12] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringMonthsOfYear(): array { + // evaluate if RRULE exists and return month(s) of the year + return $this->rruleIterator !== null ? $this->rruleIterator->monthsOfYear() : []; + } + + /** + * event recurrence months of the year (named) + * + * returns collection of RRULE BYMONTH month(s) [3, 7, 12] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringMonthsOfYearNamed(): array { + // evaluate if RRULE exists and extract month(s) of the year + $months = $this->rruleIterator !== null ? $this->rruleIterator->monthsOfYear() : []; + // convert numberic month to month name + foreach ($months as $key => $value) { + $months[$key] = $this->monthNamesMap[$value]; + } + // return months collection + return $months; + } + + /** + * event recurrence relative positions + * + * returns collection of RRULE SETPOS value(s) [1, 5, -3] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringRelativePosition(): array { + // evaluate if RRULE exists and return relative position(s) + return $this->rruleIterator !== null ? $this->rruleIterator->relativePosition() : []; + } + + /** + * event recurrence relative positions (named) + * + * returns collection of RRULE SETPOS [1, 3, -1] + * returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringRelativePositionNamed(): array { + // evaluate if RRULE exists and extract relative position(s) + $positions = $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : []; + // convert numberic relative position to relative label + foreach ($positions as $key => $value) { + $positions[$key] = $this->relativePositionNamesMap[$value]; + } + // return positions collection + return $positions; + } + + /** + * event recurrence date + * + * returns date of currently selected recurrence + * + * @since 30.0.0 + * + * @return DateTime + */ + public function recurrenceDate(): ?DateTime { + if ($this->recurrenceCurrentDate !== null) { + return DateTime::createFromInterface($this->recurrenceCurrentDate); + } else { + return null; + } + } + + /** + * event recurrence rewind + * + * sets the current recurrence to the first recurrence in the collection + * + * @since 30.0.0 + * + * @return void + */ + public function recurrenceRewind(): void { + // rewind and increment rrule + if ($this->rruleIterator !== null) { + $this->rruleIterator->rewind(); + } + // rewind and increment rdate + if ($this->rdateIterator !== null) { + $this->rdateIterator->rewind(); + } + // rewind and increment exrule + if ($this->eruleIterator !== null) { + $this->eruleIterator->rewind(); + } + // rewind and increment exdate + if ($this->edateIterator !== null) { + $this->edateIterator->rewind(); + } + // set current date to event start date + $this->recurrenceCurrentDate = clone $this->baseEventStartDate; + } + + /** + * event recurrence advance + * + * sets the current recurrence to the next recurrence in the collection + * + * @since 30.0.0 + * + * @return void + */ + public function recurrenceAdvance(): void { + // place holders + $nextOccurrenceDate = null; + $nextExceptionDate = null; + $rruleDate = null; + $rdateDate = null; + $eruleDate = null; + $edateDate = null; + // evaludate if rrule is set and advance one interation past current date + if ($this->rruleIterator !== null) { + // forward rrule to the next future date + while ($this->rruleIterator->valid() && $this->rruleIterator->current() <= $this->recurrenceCurrentDate) { + $this->rruleIterator->next(); + } + $rruleDate = $this->rruleIterator->current(); + } + // evaludate if rdate is set and advance one interation past current date + if ($this->rdateIterator !== null) { + // forward rdate to the next future date + while ($this->rdateIterator->valid() && $this->rdateIterator->current() <= $this->recurrenceCurrentDate) { + $this->rdateIterator->next(); + } + $rdateDate = $this->rdateIterator->current(); + } + if ($rruleDate !== null && $rdateDate !== null) { + $nextOccurrenceDate = ($rruleDate <= $rdateDate) ? $rruleDate : $rdateDate; + } elseif ($rruleDate !== null) { + $nextOccurrenceDate = $rruleDate; + } elseif ($rdateDate !== null) { + $nextOccurrenceDate = $rdateDate; + } + + // evaludate if exrule is set and advance one interation past current date + if ($this->eruleIterator !== null) { + // forward exrule to the next future date + while ($this->eruleIterator->valid() && $this->eruleIterator->current() <= $this->recurrenceCurrentDate) { + $this->eruleIterator->next(); + } + $eruleDate = $this->eruleIterator->current(); + } + // evaludate if exdate is set and advance one interation past current date + if ($this->edateIterator !== null) { + // forward exdate to the next future date + while ($this->edateIterator->valid() && $this->edateIterator->current() <= $this->recurrenceCurrentDate) { + $this->edateIterator->next(); + } + $edateDate = $this->edateIterator->current(); + } + // evaludate if exrule and exdate are set and set nextExDate to the first next date + if ($eruleDate !== null && $edateDate !== null) { + $nextExceptionDate = ($eruleDate <= $edateDate) ? $eruleDate : $edateDate; + } elseif ($eruleDate !== null) { + $nextExceptionDate = $eruleDate; + } elseif ($edateDate !== null) { + $nextExceptionDate = $edateDate; + } + // if the next date is part of exrule or exdate find another date + if ($nextOccurrenceDate !== null && $nextExceptionDate !== null && $nextOccurrenceDate == $nextExceptionDate) { + $this->recurrenceCurrentDate = $nextOccurrenceDate; + $this->recurrenceAdvance(); + } else { + $this->recurrenceCurrentDate = $nextOccurrenceDate; + } + } + + /** + * event recurrence advance + * + * sets the current recurrence to the next recurrence in the collection after the specific date + * + * @since 30.0.0 + * + * @param DateTimeInterface $dt date and time to advance + * + * @return void + */ + public function recurrenceAdvanceTo(DateTimeInterface $dt): void { + while ($this->recurrenceCurrentDate !== null && $this->recurrenceCurrentDate < $dt) { + $this->recurrenceAdvance(); + } + } + +} diff --git a/apps/dav/lib/CalDAV/EventReaderRDate.php b/apps/dav/lib/CalDAV/EventReaderRDate.php new file mode 100644 index 00000000000..20234d06c00 --- /dev/null +++ b/apps/dav/lib/CalDAV/EventReaderRDate.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use DateTime; + +class EventReaderRDate extends \Sabre\VObject\Recur\RDateIterator { + + public function concludes(): ?DateTime { + return $this->concludesOn(); + } + + public function concludesAfter(): ?int { + return !empty($this->dates) ? count($this->dates) : null; + } + + public function concludesOn(): ?DateTime { + if (count($this->dates) > 0) { + return new DateTime( + $this->dates[array_key_last($this->dates)], + $this->startDate->getTimezone() + ); + } + + return null; + } + +} diff --git a/apps/dav/lib/CalDAV/EventReaderRRule.php b/apps/dav/lib/CalDAV/EventReaderRRule.php new file mode 100644 index 00000000000..d2b4968c479 --- /dev/null +++ b/apps/dav/lib/CalDAV/EventReaderRRule.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use DateTime; +use DateTimeInterface; + +class EventReaderRRule extends \Sabre\VObject\Recur\RRuleIterator { + + public function precision(): string { + return $this->frequency; + } + + public function interval(): int { + return $this->interval; + } + + public function concludes(): ?DateTime { + // evaluate if until value is a date + if ($this->until instanceof DateTimeInterface) { + return DateTime::createFromInterface($this->until); + } + // evaluate if count value is higher than 0 + if ($this->count > 0) { + // temporarily store current recurrence date and counter + $currentReccuranceDate = $this->currentDate; + $currentCounter = $this->counter; + // iterate over occurrences until last one (subtract 2 from count for start and end occurrence) + while ($this->counter <= ($this->count - 2)) { + $this->next(); + } + // temporarly store last reccurance date + $lastReccuranceDate = $this->currentDate; + // restore current recurrence date and counter + $this->currentDate = $currentReccuranceDate; + $this->counter = $currentCounter; + // return last recurrence date + return DateTime::createFromInterface($lastReccuranceDate); + } + + return null; + } + + public function concludesAfter(): ?int { + return !empty($this->count) ? $this->count : null; + } + + public function concludesOn(): ?DateTime { + return isset($this->until) ? DateTime::createFromInterface($this->until) : null; + } + + public function daysOfWeek(): array { + return is_array($this->byDay) ? $this->byDay : []; + } + + public function daysOfMonth(): array { + return is_array($this->byMonthDay) ? $this->byMonthDay : []; + } + + public function daysOfYear(): array { + return is_array($this->byYearDay) ? $this->byYearDay : []; + } + + public function weeksOfYear(): array { + return is_array($this->byWeekNo) ? $this->byWeekNo : []; + } + + public function monthsOfYear(): array { + return is_array($this->byMonth) ? $this->byMonth : []; + } + + public function isRelative(): bool { + return isset($this->bySetPos); + } + + public function relativePosition(): array { + return is_array($this->bySetPos) ? $this->bySetPos : []; + } + +} diff --git a/apps/dav/lib/CalDAV/Export/ExportService.php b/apps/dav/lib/CalDAV/Export/ExportService.php new file mode 100644 index 00000000000..552b9e2b675 --- /dev/null +++ b/apps/dav/lib/CalDAV/Export/ExportService.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Export; + +use Generator; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\ICalendarExport; +use OCP\ServerVersion; +use Sabre\VObject\Component; +use Sabre\VObject\Writer; + +/** + * Calendar Export Service + */ +class ExportService { + + public const FORMATS = ['ical', 'jcal', 'xcal']; + private string $systemVersion; + + public function __construct(ServerVersion $serverVersion) { + $this->systemVersion = $serverVersion->getVersionString(); + } + + /** + * Generates serialized content stream for a calendar and objects based in selected format + * + * @return Generator<string> + */ + public function export(ICalendarExport $calendar, CalendarExportOptions $options): Generator { + // output start of serialized content based on selected format + yield $this->exportStart($options->getFormat()); + // iterate through each returned vCalendar entry + // extract each component except timezones, convert to appropriate format and output + // extract any timezones and save them but do not output + $timezones = []; + foreach ($calendar->export($options) as $entry) { + $consecutive = false; + foreach ($entry->getComponents() as $vComponent) { + if ($vComponent->name === 'VTIMEZONE') { + if (isset($vComponent->TZID) && !isset($timezones[$vComponent->TZID->getValue()])) { + $timezones[$vComponent->TZID->getValue()] = clone $vComponent; + } + } else { + yield $this->exportObject($vComponent, $options->getFormat(), $consecutive); + $consecutive = true; + } + } + } + // iterate through each saved vTimezone entry, convert to appropriate format and output + foreach ($timezones as $vComponent) { + yield $this->exportObject($vComponent, $options->getFormat(), $consecutive); + $consecutive = true; + } + // output end of serialized content based on selected format + yield $this->exportFinish($options->getFormat()); + } + + /** + * Generates serialized content start based on selected format + */ + private function exportStart(string $format): string { + return match ($format) { + 'jcal' => '["vcalendar",[["version",{},"text","2.0"],["prodid",{},"text","-\/\/IDN nextcloud.com\/\/Calendar Export v' . $this->systemVersion . '\/\/EN"]],[', + 'xcal' => '<?xml version="1.0" encoding="UTF-8"?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><version><text>2.0</text></version><prodid><text>-//IDN nextcloud.com//Calendar Export v' . $this->systemVersion . '//EN</text></prodid></properties><components>', + default => "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//IDN nextcloud.com//Calendar Export v" . $this->systemVersion . "//EN\n" + }; + } + + /** + * Generates serialized content end based on selected format + */ + private function exportFinish(string $format): string { + return match ($format) { + 'jcal' => ']]', + 'xcal' => '</components></vcalendar></icalendar>', + default => "END:VCALENDAR\n" + }; + } + + /** + * Generates serialized content for a component based on selected format + */ + private function exportObject(Component $vobject, string $format, bool $consecutive): string { + return match ($format) { + 'jcal' => $consecutive ? ',' . Writer::writeJson($vobject) : Writer::writeJson($vobject), + 'xcal' => $this->exportObjectXml($vobject), + default => Writer::write($vobject) + }; + } + + /** + * Generates serialized content for a component in xml format + */ + private function exportObjectXml(Component $vobject): string { + $writer = new \Sabre\Xml\Writer(); + $writer->openMemory(); + $writer->setIndent(false); + $vobject->xmlSerialize($writer); + return $writer->outputMemory(); + } + +} diff --git a/apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php b/apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php new file mode 100644 index 00000000000..c2c474a90fe --- /dev/null +++ b/apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\FreeBusy; + +use Sabre\VObject\Component\VCalendar; + +/** + * @psalm-suppress PropertyNotSetInConstructor + */ +class FreeBusyGenerator extends \Sabre\VObject\FreeBusyGenerator { + + public function __construct() { + parent::__construct(); + } + + public function getVCalendar(): VCalendar { + return new VCalendar(); + } +} diff --git a/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php b/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php index ae568720c55..08dc10f7bf4 100644 --- a/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php +++ b/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php @@ -1,29 +1,13 @@ <?php + /** - * @copyright 2019, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\ICSExportPlugin; use OCP\IConfig; -use OCP\ILogger; +use Psr\Log\LoggerInterface; use Sabre\HTTP\ResponseInterface; use Sabre\VObject\DateTimeParser; use Sabre\VObject\InvalidDataException; @@ -35,24 +19,16 @@ use Sabre\VObject\Property\ICalendar\Duration; * @package OCA\DAV\CalDAV\ICSExportPlugin */ class ICSExportPlugin extends \Sabre\CalDAV\ICSExportPlugin { - - /** @var IConfig */ - private $config; - - /** @var ILogger */ - private $logger; - /** @var string */ private const DEFAULT_REFRESH_INTERVAL = 'PT4H'; /** * ICSExportPlugin constructor. - * - * @param IConfig $config */ - public function __construct(IConfig $config, ILogger $logger) { - $this->config = $config; - $this->logger = $logger; + public function __construct( + private IConfig $config, + private LoggerInterface $logger, + ) { } /** diff --git a/apps/dav/lib/CalDAV/IRestorable.php b/apps/dav/lib/CalDAV/IRestorable.php index fab73c43d3a..5850e0a5645 100644 --- a/apps/dav/lib/CalDAV/IRestorable.php +++ b/apps/dav/lib/CalDAV/IRestorable.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; diff --git a/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php b/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php index 9c801a08a26..acf81638679 100644 --- a/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php +++ b/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright 2020, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Integration; @@ -49,21 +33,16 @@ abstract class ExternalCalendar implements CalDAV\ICalendar, DAV\IProperties { */ private const DELIMITER = '--'; - /** @var string */ - private $appId; - - /** @var string */ - private $calendarUri; - /** * ExternalCalendar constructor. * * @param string $appId * @param string $calendarUri */ - public function __construct(string $appId, string $calendarUri) { - $this->appId = $appId; - $this->calendarUri = $calendarUri; + public function __construct( + private string $appId, + private string $calendarUri, + ) { } /** @@ -98,7 +77,7 @@ abstract class ExternalCalendar implements CalDAV\ICalendar, DAV\IProperties { * @return bool */ public static function isAppGeneratedCalendar(string $calendarUri):bool { - return strpos($calendarUri, self::PREFIX) === 0 && substr_count($calendarUri, self::DELIMITER) >= 2; + return str_starts_with($calendarUri, self::PREFIX) && substr_count($calendarUri, self::DELIMITER) >= 2; } /** @@ -126,6 +105,6 @@ abstract class ExternalCalendar implements CalDAV\ICalendar, DAV\IProperties { * @return bool */ public static function doesViolateReservedName(string $calendarUri):bool { - return strpos($calendarUri, self::PREFIX) === 0; + return str_starts_with($calendarUri, self::PREFIX); } } diff --git a/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php b/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php index c72112f06ba..40a8860dcb4 100644 --- a/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php +++ b/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright 2020, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Integration; diff --git a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php index 5317dc1b169..c8a7109abde 100644 --- a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php +++ b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php @@ -1,44 +1,35 @@ <?php + /** - * @copyright Copyright (c) 2018, Georg Ehrke. - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\InvitationResponse; use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\Auth\PublicPrincipalPlugin; +use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\CalDAV\Publishing\PublishPlugin; use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin; use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin; use OCA\DAV\Connector\Sabre\CachingTree; use OCA\DAV\Connector\Sabre\DavAclPlugin; +use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin; +use OCA\DAV\Connector\Sabre\LockPlugin; +use OCA\DAV\Connector\Sabre\MaintenancePlugin; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\DAV\RootCollection; +use OCA\Theming\ThemingDefaults; +use OCP\App\IAppManager; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\Server; +use Psr\Log\LoggerInterface; use Sabre\VObject\ITip\Message; class InvitationResponseServer { - /** @var \OCA\DAV\Connector\Sabre\Server */ public $server; @@ -47,21 +38,23 @@ class InvitationResponseServer { */ public function __construct(bool $public = true) { $baseUri = \OC::$WEBROOT . '/remote.php/dav/'; - $logger = \OC::$server->getLogger(); - /** @var IEventDispatcher $dispatcher */ - $dispatcher = \OC::$server->query(IEventDispatcher::class); + $logger = Server::get(LoggerInterface::class); + $dispatcher = Server::get(IEventDispatcher::class); $root = new RootCollection(); $this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root)); // Add maintenance plugin - $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\MaintenancePlugin(\OC::$server->getConfig(), \OC::$server->getL10N('dav'))); + $this->server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), \OC::$server->getL10N('dav'))); // Set URL explicitly due to reverse-proxy situations $this->server->httpRequest->setUrl($baseUri); $this->server->setBaseUri($baseUri); - $this->server->addPlugin(new BlockLegacyClientPlugin(\OC::$server->getConfig())); + $this->server->addPlugin(new BlockLegacyClientPlugin( + Server::get(IConfig::class), + Server::get(ThemingDefaults::class), + )); $this->server->addPlugin(new AnonymousOptionsPlugin()); // allow custom principal uri option @@ -75,8 +68,8 @@ class InvitationResponseServer { $event = new SabrePluginAuthInitEvent($this->server); $dispatcher->dispatchTyped($event); - $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin('webdav', $logger)); - $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\LockPlugin()); + $this->server->addPlugin(new ExceptionLoggerPlugin('webdav', $logger)); + $this->server->addPlugin(new LockPlugin()); $this->server->addPlugin(new \Sabre\DAV\Sync\Plugin()); // acl @@ -89,21 +82,21 @@ class InvitationResponseServer { // calendar plugins $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin()); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OC::$server->getConfig())); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class))); $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin()); //$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest())); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Publishing\PublishPlugin( - \OC::$server->getConfig(), - \OC::$server->getURLGenerator() + $this->server->addPlugin(new PublishPlugin( + Server::get(IConfig::class), + Server::get(IURLGenerator::class) )); // wait with registering these until auth is handled and the filesystem is setup - $this->server->on('beforeMethod:*', function () use ($root) { + $this->server->on('beforeMethod:*', function () use ($root): void { // register plugins from apps $pluginManager = new PluginManager( \OC::$server, - \OC::$server->getAppManager() + Server::get(IAppManager::class) ); foreach ($pluginManager->getAppPlugins() as $appPlugin) { $this->server->addPlugin($appPlugin); @@ -126,7 +119,11 @@ class InvitationResponseServer { public function isExternalAttendee(string $principalUri): bool { /** @var \Sabre\DAVACL\Plugin $aclPlugin */ - $aclPlugin = $this->server->getPlugin('acl'); + $aclPlugin = $this->getServer()->getPlugin('acl'); return $aclPlugin->getPrincipalByUri($principalUri) === null; } + + public function getServer(): \OCA\DAV\Connector\Sabre\Server { + return $this->server; + } } diff --git a/apps/dav/lib/CalDAV/Outbox.php b/apps/dav/lib/CalDAV/Outbox.php index eebb48e1294..608114d8093 100644 --- a/apps/dav/lib/CalDAV/Outbox.php +++ b/apps/dav/lib/CalDAV/Outbox.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2018, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; @@ -33,9 +16,6 @@ use Sabre\CalDAV\Plugin as CalDAVPlugin; */ class Outbox extends \Sabre\CalDAV\Schedule\Outbox { - /** @var IConfig */ - private $config; - /** @var null|bool */ private $disableFreeBusy = null; @@ -45,9 +25,11 @@ class Outbox extends \Sabre\CalDAV\Schedule\Outbox { * @param IConfig $config * @param string $principalUri */ - public function __construct(IConfig $config, string $principalUri) { + public function __construct( + private IConfig $config, + string $principalUri, + ) { parent::__construct($principalUri); - $this->config = $config; } /** diff --git a/apps/dav/lib/CalDAV/Plugin.php b/apps/dav/lib/CalDAV/Plugin.php index 5b367c51053..24448ae71ab 100644 --- a/apps/dav/lib/CalDAV/Plugin.php +++ b/apps/dav/lib/CalDAV/Plugin.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud GmbH. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud GmbH. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; diff --git a/apps/dav/lib/CalDAV/Principal/Collection.php b/apps/dav/lib/CalDAV/Principal/Collection.php index 27997741609..b76fde66464 100644 --- a/apps/dav/lib/CalDAV/Principal/Collection.php +++ b/apps/dav/lib/CalDAV/Principal/Collection.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017, Christoph Seitz <christoph.seitz@posteo.de> - * - * @author Christoph Seitz <christoph.seitz@posteo.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Principal; diff --git a/apps/dav/lib/CalDAV/Principal/User.php b/apps/dav/lib/CalDAV/Principal/User.php index 904ecc32e89..047d83827ed 100644 --- a/apps/dav/lib/CalDAV/Principal/User.php +++ b/apps/dav/lib/CalDAV/Principal/User.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017, Christoph Seitz <christoph.seitz@posteo.de> - * - * @author Christoph Seitz <christoph.seitz@posteo.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Principal; diff --git a/apps/dav/lib/CalDAV/Proxy/Proxy.php b/apps/dav/lib/CalDAV/Proxy/Proxy.php index 8bafe8cc3b3..ef1ad8c634f 100644 --- a/apps/dav/lib/CalDAV/Proxy/Proxy.php +++ b/apps/dav/lib/CalDAV/Proxy/Proxy.php @@ -3,29 +3,13 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Proxy; use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; /** * @method string getOwnerId() @@ -45,8 +29,8 @@ class Proxy extends Entity { protected $permissions; public function __construct() { - $this->addType('ownerId', 'string'); - $this->addType('proxyId', 'string'); - $this->addType('permissions', 'int'); + $this->addType('ownerId', Types::STRING); + $this->addType('proxyId', Types::STRING); + $this->addType('permissions', Types::INTEGER); } } diff --git a/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php b/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php index 19c72ffa0e9..3b9b9c3d9eb 100644 --- a/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php +++ b/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Proxy; @@ -34,6 +15,8 @@ use OCP\IDBConnection; * Class ProxyMapper * * @package OCA\DAV\CalDAV\Proxy + * + * @template-extends QBMapper<Proxy> */ class ProxyMapper extends QBMapper { public const PERMISSION_READ = 1; diff --git a/apps/dav/lib/CalDAV/PublicCalendar.php b/apps/dav/lib/CalDAV/PublicCalendar.php index 4a29c8d237a..9af6e544165 100644 --- a/apps/dav/lib/CalDAV/PublicCalendar.php +++ b/apps/dav/lib/CalDAV/PublicCalendar.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017, Georg Ehrke - * - * @author Gary Kim <gary@garykim.dev> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; @@ -84,7 +66,7 @@ class PublicCalendar extends Calendar { * public calendars are always shared * @return bool */ - protected function isShared() { + public function isShared() { return true; } } diff --git a/apps/dav/lib/CalDAV/PublicCalendarObject.php b/apps/dav/lib/CalDAV/PublicCalendarObject.php index 69a5583d8f5..2ab40b94347 100644 --- a/apps/dav/lib/CalDAV/PublicCalendarObject.php +++ b/apps/dav/lib/CalDAV/PublicCalendarObject.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017, Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; diff --git a/apps/dav/lib/CalDAV/PublicCalendarRoot.php b/apps/dav/lib/CalDAV/PublicCalendarRoot.php index 4f7dfea2682..edfb9f8dccc 100644 --- a/apps/dav/lib/CalDAV/PublicCalendarRoot.php +++ b/apps/dav/lib/CalDAV/PublicCalendarRoot.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -32,18 +14,6 @@ use Sabre\DAV\Collection; class PublicCalendarRoot extends Collection { - /** @var CalDavBackend */ - protected $caldavBackend; - - /** @var \OCP\IL10N */ - protected $l10n; - - /** @var \OCP\IConfig */ - protected $config; - - /** @var LoggerInterface */ - private $logger; - /** * PublicCalendarRoot constructor. * @@ -51,12 +21,12 @@ class PublicCalendarRoot extends Collection { * @param IL10N $l10n * @param IConfig $config */ - public function __construct(CalDavBackend $caldavBackend, IL10N $l10n, - IConfig $config, LoggerInterface $logger) { - $this->caldavBackend = $caldavBackend; - $this->l10n = $l10n; - $this->config = $config; - $this->logger = $logger; + public function __construct( + protected CalDavBackend $caldavBackend, + protected IL10N $l10n, + protected IConfig $config, + private LoggerInterface $logger, + ) { } /** diff --git a/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php b/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php index 97e942f9da2..76378e7a1c5 100644 --- a/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php +++ b/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php @@ -1,34 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2016 Thomas Citharel <tcit@tcit.fr> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Publishing; use OCA\DAV\CalDAV\Calendar; use OCA\DAV\CalDAV\Publishing\Xml\Publisher; +use OCP\AppFramework\Http; use OCP\IConfig; use OCP\IURLGenerator; use Sabre\CalDAV\Xml\Property\AllowedSharingModes; @@ -51,28 +31,21 @@ class PublishPlugin extends ServerPlugin { protected $server; /** - * Config instance to get instance secret. - * - * @var IConfig - */ - protected $config; - - /** - * URL Generator for absolute URLs. - * - * @var IURLGenerator - */ - protected $urlGenerator; - - /** * PublishPlugin constructor. * * @param IConfig $config * @param IURLGenerator $urlGenerator */ - public function __construct(IConfig $config, IURLGenerator $urlGenerator) { - $this->config = $config; - $this->urlGenerator = $urlGenerator; + public function __construct( + /** + * Config instance to get instance secret. + */ + protected IConfig $config, + /** + * URL Generator for absolute URLs. + */ + protected IURLGenerator $urlGenerator, + ) { } /** @@ -114,28 +87,28 @@ class PublishPlugin extends ServerPlugin { $this->server = $server; $this->server->on('method:POST', [$this, 'httpPost']); - $this->server->on('propFind', [$this, 'propFind']); + $this->server->on('propFind', [$this, 'propFind']); } public function propFind(PropFind $propFind, INode $node) { if ($node instanceof Calendar) { - $propFind->handle('{'.self::NS_CALENDARSERVER.'}publish-url', function () use ($node) { + $propFind->handle('{' . self::NS_CALENDARSERVER . '}publish-url', function () use ($node) { if ($node->getPublishStatus()) { // We return the publish-url only if the calendar is published. $token = $node->getPublishStatus(); - $publishUrl = $this->urlGenerator->getAbsoluteURL($this->server->getBaseUri().'public-calendars/').$token; + $publishUrl = $this->urlGenerator->getAbsoluteURL($this->server->getBaseUri() . 'public-calendars/') . $token; return new Publisher($publishUrl, true); } }); - $propFind->handle('{'.self::NS_CALENDARSERVER.'}allowed-sharing-modes', function () use ($node) { + $propFind->handle('{' . self::NS_CALENDARSERVER . '}allowed-sharing-modes', function () use ($node) { $canShare = (!$node->isSubscription() && $node->canWrite()); $canPublish = (!$node->isSubscription() && $node->canWrite()); if ($this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes') { - $canShare &= ($node->getOwner() === $node->getPrincipalURI()); - $canPublish &= ($node->getOwner() === $node->getPrincipalURI()); + $canShare = $canShare && ($node->getOwner() === $node->getPrincipalURI()); + $canPublish = $canPublish && ($node->getOwner() === $node->getPrincipalURI()); } return new AllowedSharingModes($canShare, $canPublish); @@ -155,8 +128,8 @@ class PublishPlugin extends ServerPlugin { $path = $request->getPath(); // Only handling xml - $contentType = $request->getHeader('Content-Type'); - if (strpos($contentType, 'application/xml') === false && strpos($contentType, 'text/xml') === false) { + $contentType = (string)$request->getHeader('Content-Type'); + if (!str_contains($contentType, 'application/xml') && !str_contains($contentType, 'text/xml')) { return; } @@ -182,74 +155,74 @@ class PublishPlugin extends ServerPlugin { switch ($documentType) { - case '{'.self::NS_CALENDARSERVER.'}publish-calendar': + case '{' . self::NS_CALENDARSERVER . '}publish-calendar': - // We can only deal with IShareableCalendar objects - if (!$node instanceof Calendar) { - return; - } - $this->server->transactionType = 'post-publish-calendar'; + // We can only deal with IShareableCalendar objects + if (!$node instanceof Calendar) { + return; + } + $this->server->transactionType = 'post-publish-calendar'; - // Getting ACL info - $acl = $this->server->getPlugin('acl'); + // Getting ACL info + $acl = $this->server->getPlugin('acl'); - // If there's no ACL support, we allow everything - if ($acl) { - /** @var \Sabre\DAVACL\Plugin $acl */ - $acl->checkPrivileges($path, '{DAV:}write'); + // If there's no ACL support, we allow everything + if ($acl) { + /** @var \Sabre\DAVACL\Plugin $acl */ + $acl->checkPrivileges($path, '{DAV:}write'); - $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes'; - $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner(); - if ($limitSharingToOwner && !$isOwner) { - return; + $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes'; + $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner(); + if ($limitSharingToOwner && !$isOwner) { + return; + } } - } - $node->setPublishStatus(true); + $node->setPublishStatus(true); - // iCloud sends back the 202, so we will too. - $response->setStatus(202); + // iCloud sends back the 202, so we will too. + $response->setStatus(Http::STATUS_ACCEPTED); - // Adding this because sending a response body may cause issues, - // and I wanted some type of indicator the response was handled. - $response->setHeader('X-Sabre-Status', 'everything-went-well'); + // Adding this because sending a response body may cause issues, + // and I wanted some type of indicator the response was handled. + $response->setHeader('X-Sabre-Status', 'everything-went-well'); - // Breaking the event chain - return false; + // Breaking the event chain + return false; - case '{'.self::NS_CALENDARSERVER.'}unpublish-calendar': + case '{' . self::NS_CALENDARSERVER . '}unpublish-calendar': - // We can only deal with IShareableCalendar objects - if (!$node instanceof Calendar) { - return; - } - $this->server->transactionType = 'post-unpublish-calendar'; + // We can only deal with IShareableCalendar objects + if (!$node instanceof Calendar) { + return; + } + $this->server->transactionType = 'post-unpublish-calendar'; - // Getting ACL info - $acl = $this->server->getPlugin('acl'); + // Getting ACL info + $acl = $this->server->getPlugin('acl'); - // If there's no ACL support, we allow everything - if ($acl) { - /** @var \Sabre\DAVACL\Plugin $acl */ - $acl->checkPrivileges($path, '{DAV:}write'); + // If there's no ACL support, we allow everything + if ($acl) { + /** @var \Sabre\DAVACL\Plugin $acl */ + $acl->checkPrivileges($path, '{DAV:}write'); - $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes'; - $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner(); - if ($limitSharingToOwner && !$isOwner) { - return; + $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes'; + $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner(); + if ($limitSharingToOwner && !$isOwner) { + return; + } } - } - $node->setPublishStatus(false); + $node->setPublishStatus(false); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); - // Adding this because sending a response body may cause issues, - // and I wanted some type of indicator the response was handled. - $response->setHeader('X-Sabre-Status', 'everything-went-well'); + // Adding this because sending a response body may cause issues, + // and I wanted some type of indicator the response was handled. + $response->setHeader('X-Sabre-Status', 'everything-went-well'); - // Breaking the event chain - return false; + // Breaking the event chain + return false; } } diff --git a/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php b/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php index 35bce872bf8..fb9b7298f9b 100644 --- a/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php +++ b/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Thomas Citharel <tcit@tcit.fr> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Publishing\Xml; @@ -29,22 +12,13 @@ use Sabre\Xml\XmlSerializable; class Publisher implements XmlSerializable { /** - * @var string $publishUrl - */ - protected $publishUrl; - - /** - * @var boolean $isPublished - */ - protected $isPublished; - - /** * @param string $publishUrl * @param boolean $isPublished */ - public function __construct($publishUrl, $isPublished) { - $this->publishUrl = $publishUrl; - $this->isPublished = $isPublished; + public function __construct( + protected $publishUrl, + protected $isPublished, + ) { } /** @@ -55,7 +29,7 @@ class Publisher implements XmlSerializable { } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * Use the $writer argument to write its own xml serialization. * diff --git a/apps/dav/lib/CalDAV/Reminder/Backend.php b/apps/dav/lib/CalDAV/Reminder/Backend.php index b0476e9594c..329af3a2f56 100644 --- a/apps/dav/lib/CalDAV/Reminder/Backend.php +++ b/apps/dav/lib/CalDAV/Reminder/Backend.php @@ -3,28 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; @@ -38,22 +18,16 @@ use OCP\IDBConnection; */ class Backend { - /** @var IDBConnection */ - protected $db; - - /** @var ITimeFactory */ - private $timeFactory; - /** * Backend constructor. * * @param IDBConnection $db * @param ITimeFactory $timeFactory */ - public function __construct(IDBConnection $db, - ITimeFactory $timeFactory) { - $this->db = $db; - $this->timeFactory = $timeFactory; + public function __construct( + protected IDBConnection $db, + protected ITimeFactory $timeFactory, + ) { } /** @@ -64,12 +38,13 @@ class Backend { */ public function getRemindersToProcess():array { $query = $this->db->getQueryBuilder(); - $query->select(['cr.*', 'co.calendardata', 'c.displayname', 'c.principaluri']) + $query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri']) ->from('calendar_reminders', 'cr') ->where($query->expr()->lte('cr.notification_date', $query->createNamedParameter($this->timeFactory->getTime()))) ->join('cr', 'calendarobjects', 'co', $query->expr()->eq('cr.object_id', 'co.id')) - ->join('cr', 'calendars', 'c', $query->expr()->eq('cr.calendar_id', 'c.id')); - $stmt = $query->execute(); + ->join('cr', 'calendars', 'c', $query->expr()->eq('cr.calendar_id', 'c.id')) + ->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri'); + $stmt = $query->executeQuery(); return array_map( [$this, 'fixRowTyping'], @@ -88,7 +63,7 @@ class Backend { $query->select('*') ->from('calendar_reminders') ->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId))); - $stmt = $query->execute(); + $stmt = $query->executeQuery(); return array_map( [$this, 'fixRowTyping'], @@ -114,17 +89,17 @@ class Backend { * @return int The insert id */ public function insertReminder(int $calendarId, - int $objectId, - string $uid, - bool $isRecurring, - int $recurrenceId, - bool $isRecurrenceException, - string $eventHash, - string $alarmHash, - string $type, - bool $isRelative, - int $notificationDate, - bool $isRepeatBased):int { + int $objectId, + string $uid, + bool $isRecurring, + int $recurrenceId, + bool $isRecurrenceException, + string $eventHash, + string $alarmHash, + string $type, + bool $isRelative, + int $notificationDate, + bool $isRepeatBased):int { $query = $this->db->getQueryBuilder(); $query->insert('calendar_reminders') ->values([ @@ -141,7 +116,7 @@ class Backend { 'notification_date' => $query->createNamedParameter($notificationDate), 'is_repeat_based' => $query->createNamedParameter($isRepeatBased ? 1 : 0), ]) - ->execute(); + ->executeStatement(); return $query->getLastInsertId(); } @@ -153,12 +128,12 @@ class Backend { * @param int $newNotificationDate */ public function updateReminder(int $reminderId, - int $newNotificationDate):void { + int $newNotificationDate):void { $query = $this->db->getQueryBuilder(); $query->update('calendar_reminders') ->set('notification_date', $query->createNamedParameter($newNotificationDate)) ->where($query->expr()->eq('id', $query->createNamedParameter($reminderId))) - ->execute(); + ->executeStatement(); } /** @@ -172,7 +147,7 @@ class Backend { $query->delete('calendar_reminders') ->where($query->expr()->eq('id', $query->createNamedParameter($reminderId))) - ->execute(); + ->executeStatement(); } /** @@ -185,7 +160,7 @@ class Backend { $query->delete('calendar_reminders') ->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId))) - ->execute(); + ->executeStatement(); } /** @@ -199,7 +174,7 @@ class Backend { $query->delete('calendar_reminders') ->where($query->expr()->eq('calendar_id', $query->createNamedParameter($calendarId))) - ->execute(); + ->executeStatement(); } /** @@ -207,15 +182,15 @@ class Backend { * @return array */ private function fixRowTyping(array $row): array { - $row['id'] = (int) $row['id']; - $row['calendar_id'] = (int) $row['calendar_id']; - $row['object_id'] = (int) $row['object_id']; - $row['is_recurring'] = (bool) $row['is_recurring']; - $row['recurrence_id'] = (int) $row['recurrence_id']; - $row['is_recurrence_exception'] = (bool) $row['is_recurrence_exception']; - $row['is_relative'] = (bool) $row['is_relative']; - $row['notification_date'] = (int) $row['notification_date']; - $row['is_repeat_based'] = (bool) $row['is_repeat_based']; + $row['id'] = (int)$row['id']; + $row['calendar_id'] = (int)$row['calendar_id']; + $row['object_id'] = (int)$row['object_id']; + $row['is_recurring'] = (bool)$row['is_recurring']; + $row['recurrence_id'] = (int)$row['recurrence_id']; + $row['is_recurrence_exception'] = (bool)$row['is_recurrence_exception']; + $row['is_relative'] = (bool)$row['is_relative']; + $row['notification_date'] = (int)$row['notification_date']; + $row['is_repeat_based'] = (bool)$row['is_repeat_based']; return $row; } diff --git a/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php b/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php index a6b439c0b4f..31d60f1531d 100644 --- a/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php +++ b/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; @@ -41,11 +22,13 @@ interface INotificationProvider { * Send notification * * @param VEvent $vevent - * @param string $calendarDisplayName + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses All email addresses associated to the principal owning the calendar object * @param IUser[] $users * @return void */ public function send(VEvent $vevent, - string $calendarDisplayName, - array $users = []): void; + ?string $calendarDisplayName, + array $principalEmailAddresses, + array $users = []): void; } diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php index 044e5fac4e2..94edff98e52 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php @@ -3,39 +3,18 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; use OCA\DAV\CalDAV\Reminder\INotificationProvider; use OCP\IConfig; use OCP\IL10N; -use OCP\ILogger; use OCP\IURLGenerator; use OCP\IUser; use OCP\L10N\IFactory as L10NFactory; +use Psr\Log\LoggerInterface; use Sabre\VObject\Component\VEvent; use Sabre\VObject\DateTimeParser; use Sabre\VObject\Property; @@ -50,51 +29,33 @@ abstract class AbstractProvider implements INotificationProvider { /** @var string */ public const NOTIFICATION_TYPE = ''; - /** @var ILogger */ - protected $logger; - - /** @var L10NFactory */ - protected $l10nFactory; - /** @var IL10N[] */ private $l10ns; /** @var string */ private $fallbackLanguage; - /** @var IURLGenerator */ - protected $urlGenerator; - - /** @var IConfig */ - protected $config; - - /** - * @param ILogger $logger - * @param L10NFactory $l10nFactory - * @param IConfig $config - * @param IUrlGenerator $urlGenerator - */ - public function __construct(ILogger $logger, - L10NFactory $l10nFactory, - IURLGenerator $urlGenerator, - IConfig $config) { - $this->logger = $logger; - $this->l10nFactory = $l10nFactory; - $this->urlGenerator = $urlGenerator; - $this->config = $config; + public function __construct( + protected LoggerInterface $logger, + protected L10NFactory $l10nFactory, + protected IURLGenerator $urlGenerator, + protected IConfig $config, + ) { } /** * Send notification * * @param VEvent $vevent - * @param string $calendarDisplayName + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses * @param IUser[] $users * @return void */ abstract public function send(VEvent $vevent, - string $calendarDisplayName, - array $users = []): void; + ?string $calendarDisplayName, + array $principalEmailAddresses, + array $users = []): void; /** * @return string @@ -139,7 +100,7 @@ abstract class AbstractProvider implements INotificationProvider { */ private function getStatusOfEvent(VEvent $vevent):string { if ($vevent->STATUS) { - return (string) $vevent->STATUS; + return (string)$vevent->STATUS; } // Doesn't say so in the standard, @@ -189,4 +150,8 @@ abstract class AbstractProvider implements INotificationProvider { return clone $vevent->DTSTART; } + + protected function getCalendarDisplayNameFallback(string $lang): string { + return $this->getL10NForLang($lang)->t('Untitled calendar'); + } } diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php index 4b369b34dc0..01d51489a3b 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php index 456b9f8b42d..0fd39a9e459 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php @@ -3,42 +3,22 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Richard Steinmetz <richard@steinmetz.cloud> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; use DateTime; use OCP\IConfig; use OCP\IL10N; -use OCP\ILogger; use OCP\IURLGenerator; +use OCP\IUser; use OCP\L10N\IFactory as L10NFactory; +use OCP\Mail\Headers\AutoSubmitted; use OCP\Mail\IEMailTemplate; use OCP\Mail\IMailer; +use OCP\Util; +use Psr\Log\LoggerInterface; use Sabre\VObject; use Sabre\VObject\Component\VEvent; use Sabre\VObject\Parameter; @@ -50,44 +30,46 @@ use Sabre\VObject\Property; * @package OCA\DAV\CalDAV\Reminder\NotificationProvider */ class EmailProvider extends AbstractProvider { - /** @var string */ public const NOTIFICATION_TYPE = 'EMAIL'; - /** @var IMailer */ - private $mailer; - - /** - * @param IConfig $config - * @param IMailer $mailer - * @param ILogger $logger - * @param L10NFactory $l10nFactory - * @param IUrlGenerator $urlGenerator - */ - public function __construct(IConfig $config, - IMailer $mailer, - ILogger $logger, - L10NFactory $l10nFactory, - IURLGenerator $urlGenerator) { + public function __construct( + IConfig $config, + private IMailer $mailer, + LoggerInterface $logger, + L10NFactory $l10nFactory, + IURLGenerator $urlGenerator, + ) { parent::__construct($logger, $l10nFactory, $urlGenerator, $config); - $this->mailer = $mailer; } /** * Send out notification via email * * @param VEvent $vevent - * @param string $calendarDisplayName + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses * @param array $users * @throws \Exception */ public function send(VEvent $vevent, - string $calendarDisplayName, - array $users = []):void { + ?string $calendarDisplayName, + array $principalEmailAddresses, + array $users = []):void { $fallbackLanguage = $this->getFallbackLanguage(); + $organizerEmailAddress = null; + if (isset($vevent->ORGANIZER)) { + $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER); + } + $emailAddressesOfSharees = $this->getEMailAddressesOfAllUsersWithWriteAccessToCalendar($users); - $emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent); + $emailAddressesOfAttendees = []; + if (count($principalEmailAddresses) === 0 + || ($organizerEmailAddress && in_array($organizerEmailAddress, $principalEmailAddresses, true)) + ) { + $emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent); + } // Quote from php.net: // If the input arrays have the same string keys, then the later value for that key will overwrite the previous one. @@ -105,12 +87,12 @@ class EmailProvider extends AbstractProvider { $lang = $fallbackLanguage; } $l10n = $this->getL10NForLang($lang); - $fromEMail = \OCP\Util::getDefaultEmailAddress('reminders-noreply'); + $fromEMail = Util::getDefaultEmailAddress('reminders-noreply'); $template = $this->mailer->createEMailTemplate('dav.calendarReminder'); $template->addHeader(); $this->addSubjectAndHeading($template, $l10n, $vevent); - $this->addBulletList($template, $l10n, $calendarDisplayName, $vevent); + $this->addBulletList($template, $l10n, $calendarDisplayName ?? $this->getCalendarDisplayNameFallback($lang), $vevent); $template->addFooter(); foreach ($emailAddresses as $emailAddress) { @@ -126,6 +108,7 @@ class EmailProvider extends AbstractProvider { } $message->setTo([$emailAddress]); $message->useTemplate($template); + $message->setAutoSubmitted(AutoSubmitted::VALUE_AUTO_GENERATED); try { $failed = $this->mailer->send($message); @@ -133,7 +116,7 @@ class EmailProvider extends AbstractProvider { $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); } } catch (\Exception $ex) { - $this->logger->logException($ex, ['app' => 'dav']); + $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]); } } } @@ -156,9 +139,9 @@ class EmailProvider extends AbstractProvider { * @param array $eventData */ private function addBulletList(IEMailTemplate $template, - IL10N $l10n, - string $calendarDisplayName, - VEvent $vevent):void { + IL10N $l10n, + string $calendarDisplayName, + VEvent $vevent):void { $template->addBodyListItem($calendarDisplayName, $l10n->t('Calendar:'), $this->getAbsoluteImagePath('actions/info.png')); @@ -166,19 +149,15 @@ class EmailProvider extends AbstractProvider { $this->getAbsoluteImagePath('places/calendar.png')); if (isset($vevent->LOCATION)) { - $template->addBodyListItem((string) $vevent->LOCATION, $l10n->t('Where:'), + $template->addBodyListItem((string)$vevent->LOCATION, $l10n->t('Where:'), $this->getAbsoluteImagePath('actions/address.png')); } if (isset($vevent->DESCRIPTION)) { - $template->addBodyListItem((string) $vevent->DESCRIPTION, $l10n->t('Description:'), + $template->addBodyListItem((string)$vevent->DESCRIPTION, $l10n->t('Description:'), $this->getAbsoluteImagePath('actions/more.png')); } } - /** - * @param string $path - * @return string - */ private function getAbsoluteImagePath(string $path):string { return $this->urlGenerator->getAbsoluteURL( $this->urlGenerator->imagePath('core', $path) @@ -201,7 +180,7 @@ class EmailProvider extends AbstractProvider { $organizerEMail = substr($organizer->getValue(), 7); - if ($organizerEMail === false || !$this->mailer->validateMailAddress($organizerEMail)) { + if (!$this->mailer->validateMailAddress($organizerEMail)) { return null; } @@ -214,12 +193,11 @@ class EmailProvider extends AbstractProvider { } /** - * @param array $emails - * @param string $defaultLanguage - * @return array + * @param array<string, array{LANG?: string}> $emails + * @return array<string, string[]> */ private function sortEMailAddressesByLanguage(array $emails, - string $defaultLanguage):array { + string $defaultLanguage):array { $sortedByLanguage = []; foreach ($emails as $emailAddress => $parameters) { @@ -241,7 +219,7 @@ class EmailProvider extends AbstractProvider { /** * @param VEvent $vevent - * @return array + * @return array<string, array{LANG?: string}> */ private function getAllEMailAddressesFromEvent(VEvent $vevent):array { $emailAddresses = []; @@ -272,7 +250,10 @@ class EmailProvider extends AbstractProvider { $emailAddressesOfDelegates = $delegates->getParts(); foreach ($emailAddressesOfDelegates as $addressesOfDelegate) { if (strcasecmp($addressesOfDelegate, 'mailto:') === 0) { - $emailAddresses[substr($addressesOfDelegate, 7)] = []; + $delegateEmail = substr($addressesOfDelegate, 7); + if ($this->mailer->validateMailAddress($delegateEmail)) { + $emailAddresses[$delegateEmail] = []; + } } } @@ -284,7 +265,7 @@ class EmailProvider extends AbstractProvider { $properties = []; $langProp = $attendee->offsetGet('LANG'); - if ($langProp instanceof VObject\Parameter) { + if ($langProp instanceof VObject\Parameter && $langProp->getValue() !== null) { $properties['LANG'] = $langProp->getValue(); } @@ -294,18 +275,15 @@ class EmailProvider extends AbstractProvider { } if (isset($vevent->ORGANIZER) && $this->hasAttendeeMailURI($vevent->ORGANIZER)) { - $emailAddresses[$this->getEMailAddressOfAttendee($vevent->ORGANIZER)] = []; + $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER); + if ($organizerEmailAddress !== null) { + $emailAddresses[$organizerEmailAddress] = []; + } } return $emailAddresses; } - - - /** - * @param VObject\Property $attendee - * @return string - */ private function getCUTypeOfAttendee(VObject\Property $attendee):string { $cuType = $attendee->offsetGet('CUTYPE'); if ($cuType instanceof VObject\Parameter) { @@ -315,10 +293,6 @@ class EmailProvider extends AbstractProvider { return 'INDIVIDUAL'; } - /** - * @param VObject\Property $attendee - * @return string - */ private function getPartstatOfAttendee(VObject\Property $attendee):string { $partstat = $attendee->offsetGet('PARTSTAT'); if ($partstat instanceof VObject\Parameter) { @@ -328,29 +302,25 @@ class EmailProvider extends AbstractProvider { return 'NEEDS-ACTION'; } - /** - * @param VObject\Property $attendee - * @return bool - */ - private function hasAttendeeMailURI(VObject\Property $attendee):bool { + private function hasAttendeeMailURI(VObject\Property $attendee): bool { return stripos($attendee->getValue(), 'mailto:') === 0; } - /** - * @param VObject\Property $attendee - * @return string|null - */ - private function getEMailAddressOfAttendee(VObject\Property $attendee):?string { + private function getEMailAddressOfAttendee(VObject\Property $attendee): ?string { if (!$this->hasAttendeeMailURI($attendee)) { return null; } + $attendeeEMail = substr($attendee->getValue(), 7); + if (!$this->mailer->validateMailAddress($attendeeEMail)) { + return null; + } - return substr($attendee->getValue(), 7); + return $attendeeEMail; } /** - * @param array $users - * @return array + * @param IUser[] $users + * @return array<string, array{LANG?: string}> */ private function getEMailAddressesOfAllUsersWithWriteAccessToCalendar(array $users):array { $emailAddresses = []; @@ -373,12 +343,9 @@ class EmailProvider extends AbstractProvider { } /** - * @param IL10N $l10n - * @param VEvent $vevent - * @return string * @throws \Exception */ - private function generateDateString(IL10N $l10n, VEvent $vevent):string { + private function generateDateString(IL10N $l10n, VEvent $vevent): string { $isAllDay = $vevent->DTSTART instanceof Property\ICalendar\Date; /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ @@ -444,57 +411,27 @@ class EmailProvider extends AbstractProvider { . ' (' . $startTimezone . ')'; } - /** - * @param DateTime $dtStart - * @param DateTime $dtEnd - * @return bool - */ private function isDayEqual(DateTime $dtStart, - DateTime $dtEnd):bool { + DateTime $dtEnd):bool { return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); } - /** - * @param IL10N $l10n - * @param DateTime $dt - * @return string - */ private function getWeekDayName(IL10N $l10n, DateTime $dt):string { - return $l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); + return (string)$l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); } - /** - * @param IL10N $l10n - * @param DateTime $dt - * @return string - */ private function getDateString(IL10N $l10n, DateTime $dt):string { - return $l10n->l('date', $dt, ['width' => 'medium']); + return (string)$l10n->l('date', $dt, ['width' => 'medium']); } - /** - * @param IL10N $l10n - * @param DateTime $dt - * @return string - */ private function getDateTimeString(IL10N $l10n, DateTime $dt):string { - return $l10n->l('datetime', $dt, ['width' => 'medium|short']); + return (string)$l10n->l('datetime', $dt, ['width' => 'medium|short']); } - /** - * @param IL10N $l10n - * @param DateTime $dt - * @return string - */ private function getTimeString(IL10N $l10n, DateTime $dt):string { - return $l10n->l('time', $dt, ['width' => 'short']); + return (string)$l10n->l('time', $dt, ['width' => 'short']); } - /** - * @param VEvent $vevent - * @param IL10N $l10n - * @return string - */ private function getTitleFromVEvent(VEvent $vevent, IL10N $l10n):string { if (isset($vevent->SUMMARY)) { return (string)$vevent->SUMMARY; diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php index 2e4f9a38493..15994bacf49 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Thomas Citharel <tcit@tcit.fr> - * - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php index fb123960df8..a3f0cce547a 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php @@ -3,41 +3,20 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; use OCA\DAV\AppInfo\Application; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; -use OCP\ILogger; use OCP\IURLGenerator; use OCP\IUser; use OCP\L10N\IFactory as L10NFactory; use OCP\Notification\IManager; use OCP\Notification\INotification; +use Psr\Log\LoggerInterface; use Sabre\VObject\Component\VEvent; use Sabre\VObject\Property; @@ -51,55 +30,44 @@ class PushProvider extends AbstractProvider { /** @var string */ public const NOTIFICATION_TYPE = 'DISPLAY'; - /** @var IManager */ - private $manager; - - /** @var ITimeFactory */ - private $timeFactory; - - /** - * @param IConfig $config - * @param IManager $manager - * @param ILogger $logger - * @param L10NFactory $l10nFactory - * @param IUrlGenerator $urlGenerator - * @param ITimeFactory $timeFactory - */ - public function __construct(IConfig $config, - IManager $manager, - ILogger $logger, - L10NFactory $l10nFactory, - IURLGenerator $urlGenerator, - ITimeFactory $timeFactory) { + public function __construct( + IConfig $config, + private IManager $manager, + LoggerInterface $logger, + L10NFactory $l10nFactory, + IURLGenerator $urlGenerator, + private ITimeFactory $timeFactory, + ) { parent::__construct($logger, $l10nFactory, $urlGenerator, $config); - $this->manager = $manager; - $this->timeFactory = $timeFactory; } /** * Send push notification to all users. * * @param VEvent $vevent - * @param string $calendarDisplayName + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses * @param IUser[] $users * @throws \Exception */ public function send(VEvent $vevent, - string $calendarDisplayName = null, - array $users = []):void { - if ($this->config->getAppValue('dav', 'sendEventRemindersPush', 'no') !== 'yes') { + ?string $calendarDisplayName, + array $principalEmailAddresses, + array $users = []):void { + if ($this->config->getAppValue('dav', 'sendEventRemindersPush', 'yes') !== 'yes') { return; } $eventDetails = $this->extractEventDetails($vevent); - $eventDetails['calendar_displayname'] = $calendarDisplayName; - $eventUUID = (string) $vevent->UID; + $eventUUID = (string)$vevent->UID; if (!$eventUUID) { return; }; $eventUUIDHash = hash('sha256', $eventUUID, false); foreach ($users as $user) { + $eventDetails['calendar_displayname'] = $calendarDisplayName ?? $this->getCalendarDisplayNameFallback($this->l10nFactory->getUserLanguage($user)); + /** @var INotification $notification */ $notification = $this->manager->createNotification(); $notification->setApp(Application::APP_ID) @@ -117,8 +85,6 @@ class PushProvider extends AbstractProvider { } /** - * @var VEvent $vevent - * @return array * @throws \Exception */ protected function extractEventDetails(VEvent $vevent):array { @@ -128,13 +94,13 @@ class PushProvider extends AbstractProvider { return [ 'title' => isset($vevent->SUMMARY) - ? ((string) $vevent->SUMMARY) + ? ((string)$vevent->SUMMARY) : null, 'description' => isset($vevent->DESCRIPTION) - ? ((string) $vevent->DESCRIPTION) + ? ((string)$vevent->DESCRIPTION) : null, 'location' => isset($vevent->LOCATION) - ? ((string) $vevent->LOCATION) + ? ((string)$vevent->LOCATION) : null, 'all_day' => $start instanceof Property\ICalendar\Date, 'start_atom' => $start->getDateTime()->format(\DateTimeInterface::ATOM), diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php b/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php index cd8030a1177..265db09b061 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php @@ -3,30 +3,15 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; +use OCA\DAV\CalDAV\Reminder\NotificationProvider\ProviderNotAvailableException; +use OCP\AppFramework\QueryException; +use OCP\Server; + /** * Class NotificationProviderManager * @@ -61,7 +46,7 @@ class NotificationProviderManager { if (isset($this->providers[$type])) { return $this->providers[$type]; } - throw new NotificationProvider\ProviderNotAvailableException($type); + throw new ProviderNotAvailableException($type); } throw new NotificationTypeDoesNotExistException($type); } @@ -70,10 +55,10 @@ class NotificationProviderManager { * Registers a new provider * * @param string $providerClassName - * @throws \OCP\AppFramework\QueryException + * @throws QueryException */ public function registerProvider(string $providerClassName):void { - $provider = \OC::$server->query($providerClassName); + $provider = Server::get($providerClassName); if (!$provider instanceof INotificationProvider) { throw new \InvalidArgumentException('Invalid notification provider registered'); diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php b/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php index 16fb858bc3a..6fd2a29ede5 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; diff --git a/apps/dav/lib/CalDAV/Reminder/Notifier.php b/apps/dav/lib/CalDAV/Reminder/Notifier.php index 8535c55054a..137fb509f56 100644 --- a/apps/dav/lib/CalDAV/Reminder/Notifier.php +++ b/apps/dav/lib/CalDAV/Reminder/Notifier.php @@ -3,29 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; @@ -38,6 +17,7 @@ use OCP\L10N\IFactory; use OCP\Notification\AlreadyProcessedException; use OCP\Notification\INotification; use OCP\Notification\INotifier; +use OCP\Notification\UnknownNotificationException; /** * Class Notifier @@ -46,31 +26,21 @@ use OCP\Notification\INotifier; */ class Notifier implements INotifier { - /** @var IFactory */ - private $l10nFactory; - - /** @var IURLGenerator */ - private $urlGenerator; - /** @var IL10N */ private $l10n; - /** @var ITimeFactory */ - private $timeFactory; - /** * Notifier constructor. * - * @param IFactory $factory + * @param IFactory $l10nFactory * @param IURLGenerator $urlGenerator * @param ITimeFactory $timeFactory */ - public function __construct(IFactory $factory, - IURLGenerator $urlGenerator, - ITimeFactory $timeFactory) { - $this->l10nFactory = $factory; - $this->urlGenerator = $urlGenerator; - $this->timeFactory = $timeFactory; + public function __construct( + private IFactory $l10nFactory, + private IURLGenerator $urlGenerator, + private ITimeFactory $timeFactory, + ) { } /** @@ -99,12 +69,12 @@ class Notifier implements INotifier { * @param INotification $notification * @param string $languageCode The code of the language that should be used to prepare the notification * @return INotification - * @throws \Exception + * @throws UnknownNotificationException */ public function prepare(INotification $notification, - string $languageCode):INotification { + string $languageCode):INotification { if ($notification->getApp() !== Application::APP_ID) { - throw new \InvalidArgumentException('Notification not from this app'); + throw new UnknownNotificationException('Notification not from this app'); } // Read the language from the notification @@ -116,7 +86,7 @@ class Notifier implements INotifier { return $this->prepareReminderNotification($notification); default: - throw new \InvalidArgumentException('Unknown subject'); + throw new UnknownNotificationException('Unknown subject'); } } @@ -170,21 +140,35 @@ class Notifier implements INotifier { $components[] = $this->l10n->n('%n minute', '%n minutes', $diff->i); } - // Limiting to the first three components to prevent - // the string from getting too long - $firstThreeComponents = array_slice($components, 0, 2); - $diffLabel = implode(', ', $firstThreeComponents); + if (count($components) > 0 && !$this->hasPhpDatetimeDiffBug()) { + // Limiting to the first three components to prevent + // the string from getting too long + $firstThreeComponents = array_slice($components, 0, 2); + $diffLabel = implode(', ', $firstThreeComponents); - if ($diff->invert) { - $title = $this->l10n->t('%s (in %s)', [$title, $diffLabel]); - } else { - $title = $this->l10n->t('%s (%s ago)', [$title, $diffLabel]); + if ($diff->invert) { + $title = $this->l10n->t('%s (in %s)', [$title, $diffLabel]); + } else { + $title = $this->l10n->t('%s (%s ago)', [$title, $diffLabel]); + } } $notification->setParsedSubject($title); } /** + * @see https://github.com/nextcloud/server/issues/41615 + * @see https://github.com/php/php-src/issues/9699 + */ + private function hasPhpDatetimeDiffBug(): bool { + $d1 = DateTime::createFromFormat(\DateTimeInterface::ATOM, '2023-11-22T11:52:00+01:00'); + $d2 = new DateTime('2023-11-22T10:52:03', new \DateTimeZone('UTC')); + + // The difference is 3 seconds, not -1year+11months+… + return $d1->diff($d2)->y < 0; + } + + /** * Sets the notification message based on the parameters set in PushProvider * * @param INotification $notification @@ -289,7 +273,7 @@ class Notifier implements INotifier { * @return bool */ private function isDayEqual(DateTime $dtStart, - DateTime $dtEnd):bool { + DateTime $dtEnd):bool { return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); } @@ -298,7 +282,7 @@ class Notifier implements INotifier { * @return string */ private function getWeekDayName(DateTime $dt):string { - return $this->l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); + return (string)$this->l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); } /** @@ -306,7 +290,7 @@ class Notifier implements INotifier { * @return string */ private function getDateString(DateTime $dt):string { - return $this->l10n->l('date', $dt, ['width' => 'medium']); + return (string)$this->l10n->l('date', $dt, ['width' => 'medium']); } /** @@ -314,7 +298,7 @@ class Notifier implements INotifier { * @return string */ private function getDateTimeString(DateTime $dt):string { - return $this->l10n->l('datetime', $dt, ['width' => 'medium|short']); + return (string)$this->l10n->l('datetime', $dt, ['width' => 'medium|short']); } /** @@ -322,6 +306,6 @@ class Notifier implements INotifier { * @return string */ private function getTimeString(DateTime $dt):string { - return $this->l10n->l('time', $dt, ['width' => 'short']); + return (string)$this->l10n->l('time', $dt, ['width' => 'short']); } } diff --git a/apps/dav/lib/CalDAV/Reminder/ReminderService.php b/apps/dav/lib/CalDAV/Reminder/ReminderService.php index d6901cc4fb0..c75090e1560 100644 --- a/apps/dav/lib/CalDAV/Reminder/ReminderService.php +++ b/apps/dav/lib/CalDAV/Reminder/ReminderService.php @@ -3,73 +3,35 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; use DateTimeImmutable; +use DateTimeZone; use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\Connector\Sabre\Principal; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; use OCP\IUserManager; +use Psr\Log\LoggerInterface; use Sabre\VObject; use Sabre\VObject\Component\VAlarm; use Sabre\VObject\Component\VEvent; use Sabre\VObject\InvalidDataException; use Sabre\VObject\ParseException; use Sabre\VObject\Recur\EventIterator; +use Sabre\VObject\Recur\MaxInstancesExceededException; use Sabre\VObject\Recur\NoInstancesException; +use function count; use function strcasecmp; class ReminderService { - /** @var Backend */ - private $backend; - - /** @var NotificationProviderManager */ - private $notificationProviderManager; - - /** @var IUserManager */ - private $userManager; - - /** @var IGroupManager */ - private $groupManager; - - /** @var CalDavBackend */ - private $caldavBackend; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var IConfig */ - private $config; - public const REMINDER_TYPE_EMAIL = 'EMAIL'; public const REMINDER_TYPE_DISPLAY = 'DISPLAY'; public const REMINDER_TYPE_AUDIO = 'AUDIO'; @@ -85,31 +47,17 @@ class ReminderService { self::REMINDER_TYPE_AUDIO ]; - /** - * ReminderService constructor. - * - * @param Backend $backend - * @param NotificationProviderManager $notificationProviderManager - * @param IUserManager $userManager - * @param IGroupManager $groupManager - * @param CalDavBackend $caldavBackend - * @param ITimeFactory $timeFactory - * @param IConfig $config - */ - public function __construct(Backend $backend, - NotificationProviderManager $notificationProviderManager, - IUserManager $userManager, - IGroupManager $groupManager, - CalDavBackend $caldavBackend, - ITimeFactory $timeFactory, - IConfig $config) { - $this->backend = $backend; - $this->notificationProviderManager = $notificationProviderManager; - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->caldavBackend = $caldavBackend; - $this->timeFactory = $timeFactory; - $this->config = $config; + public function __construct( + private Backend $backend, + private NotificationProviderManager $notificationProviderManager, + private IUserManager $userManager, + private IGroupManager $groupManager, + private CalDavBackend $caldavBackend, + private ITimeFactory $timeFactory, + private IConfig $config, + private LoggerInterface $logger, + private Principal $principalConnector, + ) { } /** @@ -118,8 +66,11 @@ class ReminderService { * @throws NotificationProvider\ProviderNotAvailableException * @throws NotificationTypeDoesNotExistException */ - public function processReminders():void { + public function processReminders() :void { $reminders = $this->backend->getRemindersToProcess(); + $this->logger->debug('{numReminders} reminders to process', [ + 'numReminders' => count($reminders), + ]); foreach ($reminders as $reminder) { $calendarData = is_resource($reminder['calendardata']) @@ -132,27 +83,46 @@ class ReminderService { $vcalendar = $this->parseCalendarData($calendarData); if (!$vcalendar) { + $this->logger->debug('Reminder {id} does not belong to a valid calendar', [ + 'id' => $reminder['id'], + ]); + $this->backend->removeReminder($reminder['id']); + continue; + } + + try { + $vevent = $this->getVEventByRecurrenceId($vcalendar, $reminder['recurrence_id'], $reminder['is_recurrence_exception']); + } catch (MaxInstancesExceededException $e) { + $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]); $this->backend->removeReminder($reminder['id']); continue; } - $vevent = $this->getVEventByRecurrenceId($vcalendar, $reminder['recurrence_id'], $reminder['is_recurrence_exception']); if (!$vevent) { + $this->logger->debug('Reminder {id} does not belong to a valid event', [ + 'id' => $reminder['id'], + ]); $this->backend->removeReminder($reminder['id']); continue; } if ($this->wasEventCancelled($vevent)) { + $this->logger->debug('Reminder {id} belongs to a cancelled event', [ + 'id' => $reminder['id'], + ]); $this->deleteOrProcessNext($reminder, $vevent); continue; } if (!$this->notificationProviderManager->hasProvider($reminder['type'])) { + $this->logger->debug('Reminder {id} does not belong to a valid notification provider', [ + 'id' => $reminder['id'], + ]); $this->deleteOrProcessNext($reminder, $vevent); continue; } - if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedGroupMembers', 'yes') === 'no') { + if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'no') { $users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']); } else { $users = []; @@ -163,8 +133,18 @@ class ReminderService { $users[] = $user; } + $userPrincipalEmailAddresses = []; + $userPrincipal = $this->principalConnector->getPrincipalByPath($reminder['principaluri']); + if ($userPrincipal) { + $userPrincipalEmailAddresses = $this->principalConnector->getEmailAddressesOfPrincipal($userPrincipal); + } + + $this->logger->debug('Reminder {id} will be sent to {numUsers} users', [ + 'id' => $reminder['id'], + 'numUsers' => count($users), + ]); $notificationProvider = $this->notificationProviderManager->getProvider($reminder['type']); - $notificationProvider->send($vevent, $reminder['displayname'], $users); + $notificationProvider->send($vevent, $reminder['displayname'], $userPrincipalEmailAddresses, $users); $this->deleteOrProcessNext($reminder, $vevent); } @@ -188,18 +168,18 @@ class ReminderService { return; } - /** @var VObject\Component\VCalendar $vcalendar */ $vcalendar = $this->parseCalendarData($calendarData); if (!$vcalendar) { return; } + $calendarTimeZone = $this->getCalendarTimeZone((int)$objectData['calendarid']); $vevents = $this->getAllVEventsFromVCalendar($vcalendar); if (count($vevents) === 0) { return; } - $uid = (string) $vevents[0]->UID; + $uid = (string)$vevents[0]->UID; $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); $masterItem = $this->getMasterItemFromListOfVEvents($vevents); $now = $this->timeFactory->getDateTime(); @@ -221,7 +201,7 @@ class ReminderService { continue; } - $alarms = $this->getRemindersForVAlarm($valarm, $objectData, + $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone, $eventHash, $alarmHash, true, true); $this->writeRemindersToDatabase($alarms); } @@ -247,6 +227,10 @@ class ReminderService { // instance. We are skipping this event from the output // entirely. return; + } catch (MaxInstancesExceededException $e) { + // The event has more than 3500 recurring-instances + // so we can ignore it + return; } while ($iterator->valid() && count($processedAlarms) < count($masterAlarms)) { @@ -265,7 +249,7 @@ class ReminderService { continue; } - if (!\in_array((string) $valarm->ACTION, self::REMINDER_TYPES, true)) { + if (!\in_array((string)$valarm->ACTION, self::REMINDER_TYPES, true)) { // Action allows x-name, we don't insert reminders // into the database if they are not standard $processedAlarms[] = $alarmHash; @@ -274,6 +258,16 @@ class ReminderService { try { $triggerTime = $valarm->getEffectiveTriggerTime(); + /** + * @psalm-suppress DocblockTypeContradiction + * https://github.com/vimeo/psalm/issues/9244 + */ + if ($triggerTime->getTimezone() === false || $triggerTime->getTimezone()->getName() === 'UTC') { + $triggerTime = new DateTimeImmutable( + $triggerTime->format('Y-m-d H:i:s'), + $calendarTimeZone + ); + } } catch (InvalidDataException $e) { continue; } @@ -292,7 +286,7 @@ class ReminderService { continue; } - $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $masterHash, $alarmHash, $isRecurring, false); + $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone, $masterHash, $alarmHash, $isRecurring, false); $this->writeRemindersToDatabase($alarms); $processedAlarms[] = $alarmHash; } @@ -325,12 +319,13 @@ class ReminderService { return; } - $this->backend->cleanRemindersForEvent((int) $objectData['id']); + $this->backend->cleanRemindersForEvent((int)$objectData['id']); } /** * @param VAlarm $valarm * @param array $objectData + * @param DateTimeZone $calendarTimeZone * @param string|null $eventHash * @param string|null $alarmHash * @param bool $isRecurring @@ -338,11 +333,12 @@ class ReminderService { * @return array */ private function getRemindersForVAlarm(VAlarm $valarm, - array $objectData, - string $eventHash = null, - string $alarmHash = null, - bool $isRecurring = false, - bool $isRecurrenceException = false):array { + array $objectData, + DateTimeZone $calendarTimeZone, + ?string $eventHash = null, + ?string $alarmHash = null, + bool $isRecurring = false, + bool $isRecurrenceException = false):array { if ($eventHash === null) { $eventHash = $this->getEventHash($valarm->parent); } @@ -354,6 +350,16 @@ class ReminderService { $isRelative = $this->isAlarmRelative($valarm); /** @var DateTimeImmutable $notificationDate */ $notificationDate = $valarm->getEffectiveTriggerTime(); + /** + * @psalm-suppress DocblockTypeContradiction + * https://github.com/vimeo/psalm/issues/9244 + */ + if ($notificationDate->getTimezone() === false || $notificationDate->getTimezone()->getName() === 'UTC') { + $notificationDate = new DateTimeImmutable( + $notificationDate->format('Y-m-d H:i:s'), + $calendarTimeZone + ); + } $clonedNotificationDate = new \DateTime('now', $notificationDate->getTimezone()); $clonedNotificationDate->setTimestamp($notificationDate->getTimestamp()); @@ -362,19 +368,19 @@ class ReminderService { $alarms[] = [ 'calendar_id' => $objectData['calendarid'], 'object_id' => $objectData['id'], - 'uid' => (string) $valarm->parent->UID, + 'uid' => (string)$valarm->parent->UID, 'is_recurring' => $isRecurring, 'recurrence_id' => $recurrenceId, 'is_recurrence_exception' => $isRecurrenceException, 'event_hash' => $eventHash, 'alarm_hash' => $alarmHash, - 'type' => (string) $valarm->ACTION, + 'type' => (string)$valarm->ACTION, 'is_relative' => $isRelative, 'notification_date' => $notificationDate->getTimestamp(), 'is_repeat_based' => false, ]; - $repeat = isset($valarm->REPEAT) ? (int) $valarm->REPEAT->getValue() : 0; + $repeat = isset($valarm->REPEAT) ? (int)$valarm->REPEAT->getValue() : 0; for ($i = 0; $i < $repeat; $i++) { if ($valarm->DURATION === null) { continue; @@ -384,13 +390,13 @@ class ReminderService { $alarms[] = [ 'calendar_id' => $objectData['calendarid'], 'object_id' => $objectData['id'], - 'uid' => (string) $valarm->parent->UID, + 'uid' => (string)$valarm->parent->UID, 'is_recurring' => $isRecurring, 'recurrence_id' => $recurrenceId, 'is_recurrence_exception' => $isRecurrenceException, 'event_hash' => $eventHash, 'alarm_hash' => $alarmHash, - 'type' => (string) $valarm->ACTION, + 'type' => (string)$valarm->ACTION, 'is_relative' => $isRelative, 'notification_date' => $clonedNotificationDate->getTimestamp(), 'is_repeat_based' => true, @@ -404,19 +410,26 @@ class ReminderService { * @param array $reminders */ private function writeRemindersToDatabase(array $reminders): void { + $uniqueReminders = []; foreach ($reminders as $reminder) { + $key = $reminder['notification_date'] . $reminder['event_hash'] . $reminder['type']; + if (!isset($uniqueReminders[$key])) { + $uniqueReminders[$key] = $reminder; + } + } + foreach (array_values($uniqueReminders) as $reminder) { $this->backend->insertReminder( - (int) $reminder['calendar_id'], - (int) $reminder['object_id'], + (int)$reminder['calendar_id'], + (int)$reminder['object_id'], $reminder['uid'], $reminder['is_recurring'], - (int) $reminder['recurrence_id'], + (int)$reminder['recurrence_id'], $reminder['is_recurrence_exception'], $reminder['event_hash'], $reminder['alarm_hash'], $reminder['type'], $reminder['is_relative'], - (int) $reminder['notification_date'], + (int)$reminder['notification_date'], $reminder['is_repeat_based'] ); } @@ -427,11 +440,11 @@ class ReminderService { * @param VEvent $vevent */ private function deleteOrProcessNext(array $reminder, - VObject\Component\VEvent $vevent):void { - if ($reminder['is_repeat_based'] || - !$reminder['is_recurring'] || - !$reminder['is_relative'] || - $reminder['is_recurrence_exception']) { + VObject\Component\VEvent $vevent):void { + if ($reminder['is_repeat_based'] + || !$reminder['is_recurring'] + || !$reminder['is_relative'] + || $reminder['is_recurrence_exception']) { $this->backend->removeReminder($reminder['id']); return; } @@ -439,6 +452,7 @@ class ReminderService { $vevents = $this->getAllVEventsFromVCalendar($vevent->parent); $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); $now = $this->timeFactory->getDateTime(); + $calendarTimeZone = $this->getCalendarTimeZone((int)$reminder['calendar_id']); try { $iterator = new EventIterator($vevents, $reminder['uid']); @@ -449,49 +463,54 @@ class ReminderService { return; } - while ($iterator->valid()) { - $event = $iterator->getEventObject(); - - // Recurrence-exceptions are handled separately, so just ignore them here - if (\in_array($event, $recurrenceExceptions, true)) { - $iterator->next(); - continue; - } - - $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($event); - if ($reminder['recurrence_id'] >= $recurrenceId) { - $iterator->next(); - continue; - } + try { + while ($iterator->valid()) { + $event = $iterator->getEventObject(); - foreach ($event->VALARM as $valarm) { - /** @var VAlarm $valarm */ - $alarmHash = $this->getAlarmHash($valarm); - if ($alarmHash !== $reminder['alarm_hash']) { + // Recurrence-exceptions are handled separately, so just ignore them here + if (\in_array($event, $recurrenceExceptions, true)) { + $iterator->next(); continue; } - $triggerTime = $valarm->getEffectiveTriggerTime(); - - // If effective trigger time is in the past - // just skip and generate for next event - $diff = $now->diff($triggerTime); - if ($diff->invert === 1) { + $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($event); + if ($reminder['recurrence_id'] >= $recurrenceId) { + $iterator->next(); continue; } - $this->backend->removeReminder($reminder['id']); - $alarms = $this->getRemindersForVAlarm($valarm, [ - 'calendarid' => $reminder['calendar_id'], - 'id' => $reminder['object_id'], - ], $reminder['event_hash'], $alarmHash, true, false); - $this->writeRemindersToDatabase($alarms); + foreach ($event->VALARM as $valarm) { + /** @var VAlarm $valarm */ + $alarmHash = $this->getAlarmHash($valarm); + if ($alarmHash !== $reminder['alarm_hash']) { + continue; + } - // Abort generating reminders after creating one successfully - return; - } + $triggerTime = $valarm->getEffectiveTriggerTime(); + + // If effective trigger time is in the past + // just skip and generate for next event + $diff = $now->diff($triggerTime); + if ($diff->invert === 1) { + continue; + } + + $this->backend->removeReminder($reminder['id']); + $alarms = $this->getRemindersForVAlarm($valarm, [ + 'calendarid' => $reminder['calendar_id'], + 'id' => $reminder['object_id'], + ], $calendarTimeZone, $reminder['event_hash'], $alarmHash, true, false); + $this->writeRemindersToDatabase($alarms); + + // Abort generating reminders after creating one successfully + return; + } - $iterator->next(); + $iterator->next(); + } + } catch (MaxInstancesExceededException $e) { + // Using debug logger as this isn't really an error + $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]); } $this->backend->removeReminder($reminder['id']); @@ -549,26 +568,26 @@ class ReminderService { */ private function getEventHash(VEvent $vevent):string { $properties = [ - (string) $vevent->DTSTART->serialize(), + (string)$vevent->DTSTART->serialize(), ]; if ($vevent->DTEND) { - $properties[] = (string) $vevent->DTEND->serialize(); + $properties[] = (string)$vevent->DTEND->serialize(); } if ($vevent->DURATION) { - $properties[] = (string) $vevent->DURATION->serialize(); + $properties[] = (string)$vevent->DURATION->serialize(); } if ($vevent->{'RECURRENCE-ID'}) { - $properties[] = (string) $vevent->{'RECURRENCE-ID'}->serialize(); + $properties[] = (string)$vevent->{'RECURRENCE-ID'}->serialize(); } if ($vevent->RRULE) { - $properties[] = (string) $vevent->RRULE->serialize(); + $properties[] = (string)$vevent->RRULE->serialize(); } if ($vevent->EXDATE) { - $properties[] = (string) $vevent->EXDATE->serialize(); + $properties[] = (string)$vevent->EXDATE->serialize(); } if ($vevent->RDATE) { - $properties[] = (string) $vevent->RDATE->serialize(); + $properties[] = (string)$vevent->RDATE->serialize(); } return md5(implode('::', $properties)); @@ -583,15 +602,15 @@ class ReminderService { */ private function getAlarmHash(VAlarm $valarm):string { $properties = [ - (string) $valarm->ACTION->serialize(), - (string) $valarm->TRIGGER->serialize(), + (string)$valarm->ACTION->serialize(), + (string)$valarm->TRIGGER->serialize(), ]; if ($valarm->DURATION) { - $properties[] = (string) $valarm->DURATION->serialize(); + $properties[] = (string)$valarm->DURATION->serialize(); } if ($valarm->REPEAT) { - $properties[] = (string) $valarm->REPEAT->serialize(); + $properties[] = (string)$valarm->REPEAT->serialize(); } return md5(implode('::', $properties)); @@ -604,14 +623,14 @@ class ReminderService { * @return VEvent|null */ private function getVEventByRecurrenceId(VObject\Component\VCalendar $vcalendar, - int $recurrenceId, - bool $isRecurrenceException):?VEvent { + int $recurrenceId, + bool $isRecurrenceException):?VEvent { $vevents = $this->getAllVEventsFromVCalendar($vcalendar); if (count($vevents) === 0) { return null; } - $uid = (string) $vevents[0]->UID; + $uid = (string)$vevents[0]->UID; $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); $masterItem = $this->getMasterItemFromListOfVEvents($vevents); @@ -662,7 +681,7 @@ class ReminderService { */ private function getStatusOfEvent(VEvent $vevent):string { if ($vevent->STATUS) { - return (string) $vevent->STATUS; + return (string)$vevent->STATUS; } // Doesn't say so in the standard, @@ -724,6 +743,10 @@ class ReminderService { if ($child->name !== 'VEVENT') { continue; } + // Ignore invalid events with no DTSTART + if ($child->DTSTART === null) { + continue; + } $vevents[] = $child; } @@ -788,4 +811,26 @@ class ReminderService { private function isRecurring(VEvent $vevent):bool { return isset($vevent->RRULE) || isset($vevent->RDATE); } + + /** + * @param int $calendarid + * + * @return DateTimeZone + */ + private function getCalendarTimeZone(int $calendarid): DateTimeZone { + $calendarInfo = $this->caldavBackend->getCalendarById($calendarid); + $tzProp = '{urn:ietf:params:xml:ns:caldav}calendar-timezone'; + if (empty($calendarInfo[$tzProp])) { + // Defaulting to UTC + return new DateTimeZone('UTC'); + } + // This property contains a VCALENDAR with a single VTIMEZONE + /** @var string $timezoneProp */ + $timezoneProp = $calendarInfo[$tzProp]; + /** @var VObject\Component\VCalendar $vtimezoneObj */ + $vtimezoneObj = VObject\Reader::read($timezoneProp); + /** @var VObject\Component\VTimeZone $vtimezone */ + $vtimezone = $vtimezoneObj->VTIMEZONE; + return $vtimezone->getTimeZone(); + } } diff --git a/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php index aebb5a24f0e..68bb3373346 100644 --- a/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php +++ b/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright 2019, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Anna Larch <anna.larch@gmx.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\ResourceBooking; @@ -33,8 +14,8 @@ use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\IGroupManager; -use OCP\ILogger; use OCP\IUserSession; +use Psr\Log\LoggerInterface; use Sabre\DAV\PropPatch; use Sabre\DAVACL\PrincipalBackend\BackendInterface; use function array_intersect; @@ -45,24 +26,6 @@ use function array_values; abstract class AbstractPrincipalBackend implements BackendInterface { - /** @var IDBConnection */ - private $db; - - /** @var IUserSession */ - private $userSession; - - /** @var IGroupManager */ - private $groupManager; - - /** @var ILogger */ - private $logger; - - /** @var ProxyMapper */ - private $proxyMapper; - - /** @var string */ - private $principalPrefix; - /** @var string */ private $dbTableName; @@ -72,36 +35,19 @@ abstract class AbstractPrincipalBackend implements BackendInterface { /** @var string */ private $dbForeignKeyName; - /** @var string */ - private $cuType; - - /** - * @param IDBConnection $dbConnection - * @param IUserSession $userSession - * @param IGroupManager $groupManager - * @param ILogger $logger - * @param string $principalPrefix - * @param string $dbPrefix - * @param string $cuType - */ - public function __construct(IDBConnection $dbConnection, - IUserSession $userSession, - IGroupManager $groupManager, - ILogger $logger, - ProxyMapper $proxyMapper, - string $principalPrefix, - string $dbPrefix, - string $cuType) { - $this->db = $dbConnection; - $this->userSession = $userSession; - $this->groupManager = $groupManager; - $this->logger = $logger; - $this->proxyMapper = $proxyMapper; - $this->principalPrefix = $principalPrefix; + public function __construct( + private IDBConnection $db, + private IUserSession $userSession, + private IGroupManager $groupManager, + private LoggerInterface $logger, + private ProxyMapper $proxyMapper, + private string $principalPrefix, + string $dbPrefix, + private string $cuType, + ) { $this->dbTableName = 'calendar_' . $dbPrefix . 's'; $this->dbMetaDataTableName = $this->dbTableName . '_md'; $this->dbForeignKeyName = $dbPrefix . '_id'; - $this->cuType = $cuType; } use PrincipalProxyTrait; @@ -140,8 +86,8 @@ abstract class AbstractPrincipalBackend implements BackendInterface { $metaDataById[$metaDataRow[$this->dbForeignKeyName]] = []; } - $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] = - $metaDataRow['value']; + $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] + = $metaDataRow['value']; } while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { @@ -170,12 +116,12 @@ abstract class AbstractPrincipalBackend implements BackendInterface { * @return array */ public function getPrincipalByPath($path) { - if (strpos($path, $this->principalPrefix) !== 0) { + if (!str_starts_with($path, $this->principalPrefix)) { return null; } [, $name] = \Sabre\Uri\split($path); - [$backendId, $resourceId] = explode('-', $name, 2); + [$backendId, $resourceId] = explode('-', $name, 2); $query = $this->db->getQueryBuilder(); $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname']) @@ -319,7 +265,7 @@ abstract class AbstractPrincipalBackend implements BackendInterface { case IRoomMetadata::CAPACITY: case IResourceMetadata::VEHICLE_SEATING_CAPACITY: - $results[] = $this->searchPrincipalsByCapacity($prop,$value); + $results[] = $this->searchPrincipalsByCapacity($prop, $value); break; default: @@ -416,7 +362,7 @@ abstract class AbstractPrincipalBackend implements BackendInterface { try { $stmt = $query->executeQuery(); } catch (Exception $e) { - $this->logger->error("Could not search resources: " . $e->getMessage(), ['exception' => $e]); + $this->logger->error('Could not search resources: ' . $e->getMessage(), ['exception' => $e]); } $rows = []; @@ -453,7 +399,7 @@ abstract class AbstractPrincipalBackend implements BackendInterface { } $usersGroups = $this->groupManager->getUserGroupIds($user); - if (strpos($uri, 'mailto:') === 0) { + if (str_starts_with($uri, 'mailto:')) { $email = substr($uri, 7); $query = $this->db->getQueryBuilder(); $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions']) @@ -473,14 +419,14 @@ abstract class AbstractPrincipalBackend implements BackendInterface { return $this->rowToPrincipal($row)['uri']; } - if (strpos($uri, 'principal:') === 0) { + if (str_starts_with($uri, 'principal:')) { $path = substr($uri, 10); - if (strpos($path, $this->principalPrefix) !== 0) { + if (!str_starts_with($path, $this->principalPrefix)) { return null; } [, $name] = \Sabre\Uri\split($path); - [$backendId, $resourceId] = explode('-', $name, 2); + [$backendId, $resourceId] = explode('-', $name, 2); $query = $this->db->getQueryBuilder(); $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions']) @@ -525,14 +471,14 @@ abstract class AbstractPrincipalBackend implements BackendInterface { * @return bool */ private function isAllowedToAccessResource(array $row, array $userGroups): bool { - if (!isset($row['group_restrictions']) || - $row['group_restrictions'] === null || - $row['group_restrictions'] === '') { + if (!isset($row['group_restrictions']) + || $row['group_restrictions'] === null + || $row['group_restrictions'] === '') { return true; } // group restrictions contains something, but not parsable, deny access and log warning - $json = json_decode($row['group_restrictions']); + $json = json_decode($row['group_restrictions'], null, 512, JSON_THROW_ON_ERROR); if (!\is_array($json)) { $this->logger->info('group_restrictions field could not be parsed for ' . $this->dbTableName . '::' . $row['id'] . ', denying access to resource'); return false; diff --git a/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php index 65203e24da5..c70d93daf52 100644 --- a/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php +++ b/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php @@ -1,32 +1,16 @@ <?php + /** - * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\ResourceBooking; use OCA\DAV\CalDAV\Proxy\ProxyMapper; use OCP\IDBConnection; use OCP\IGroupManager; -use OCP\ILogger; use OCP\IUserSession; +use Psr\Log\LoggerInterface; /** * Class ResourcePrincipalBackend @@ -37,18 +21,12 @@ class ResourcePrincipalBackend extends AbstractPrincipalBackend { /** * ResourcePrincipalBackend constructor. - * - * @param IDBConnection $dbConnection - * @param IUserSession $userSession - * @param IGroupManager $groupManager - * @param ILogger $logger - * @param ProxyMapper $proxyMapper */ public function __construct(IDBConnection $dbConnection, - IUserSession $userSession, - IGroupManager $groupManager, - ILogger $logger, - ProxyMapper $proxyMapper) { + IUserSession $userSession, + IGroupManager $groupManager, + LoggerInterface $logger, + ProxyMapper $proxyMapper) { parent::__construct($dbConnection, $userSession, $groupManager, $logger, $proxyMapper, 'principals/calendar-resources', 'resource', 'RESOURCE'); } diff --git a/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php index ca78ebd4bc4..5704b23ae14 100644 --- a/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php +++ b/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php @@ -1,32 +1,16 @@ <?php + /** - * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\ResourceBooking; use OCA\DAV\CalDAV\Proxy\ProxyMapper; use OCP\IDBConnection; use OCP\IGroupManager; -use OCP\ILogger; use OCP\IUserSession; +use Psr\Log\LoggerInterface; /** * Class RoomPrincipalBackend @@ -37,18 +21,12 @@ class RoomPrincipalBackend extends AbstractPrincipalBackend { /** * RoomPrincipalBackend constructor. - * - * @param IDBConnection $dbConnection - * @param IUserSession $userSession - * @param IGroupManager $groupManager - * @param ILogger $logger - * @param ProxyMapper $proxyMapper */ public function __construct(IDBConnection $dbConnection, - IUserSession $userSession, - IGroupManager $groupManager, - ILogger $logger, - ProxyMapper $proxyMapper) { + IUserSession $userSession, + IGroupManager $groupManager, + LoggerInterface $logger, + ProxyMapper $proxyMapper) { parent::__construct($dbConnection, $userSession, $groupManager, $logger, $proxyMapper, 'principals/calendar-rooms', 'room', 'ROOM'); } diff --git a/apps/dav/lib/CalDAV/RetentionService.php b/apps/dav/lib/CalDAV/RetentionService.php index 1d92d847641..399d1a46639 100644 --- a/apps/dav/lib/CalDAV/RetentionService.php +++ b/apps/dav/lib/CalDAV/RetentionService.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; @@ -34,29 +17,19 @@ class RetentionService { public const RETENTION_CONFIG_KEY = 'calendarRetentionObligation'; private const DEFAULT_RETENTION_SECONDS = 30 * 24 * 60 * 60; - /** @var IConfig */ - private $config; - - /** @var ITimeFactory */ - private $time; - - /** @var CalDavBackend */ - private $calDavBackend; - - public function __construct(IConfig $config, - ITimeFactory $time, - CalDavBackend $calDavBackend) { - $this->config = $config; - $this->time = $time; - $this->calDavBackend = $calDavBackend; + public function __construct( + private IConfig $config, + private ITimeFactory $time, + private CalDavBackend $calDavBackend, + ) { } public function getDuration(): int { return max( - (int) $this->config->getAppValue( + (int)$this->config->getAppValue( Application::APP_ID, self::RETENTION_CONFIG_KEY, - (string) self::DEFAULT_RETENTION_SECONDS + (string)self::DEFAULT_RETENTION_SECONDS ), 0 // Just making sure we don't delete things in the future when a negative number is passed ); diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 8aacc33bb46..2af6b162d8d 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -1,60 +1,34 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2017, Georg Ehrke - * @copyright Copyright (C) 2007-2015 fruux GmbH (https://fruux.com/). - * @copyright Copyright (C) 2007-2015 fruux GmbH (https://fruux.com/). - * - * @author brad2014 <brad2014@users.noreply.github.com> - * @author Brad Rubenstein <brad@wbr.tech> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Leon Klingele <leon@struktur.de> - * @author Nick Sweeting <git@sweeting.me> - * @author rakekniven <mark.ziegler@rakekniven.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-FileCopyrightText: 2007-2015 fruux GmbH (https://fruux.com/) + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV\Schedule; +use OCA\DAV\CalDAV\CalendarObject; +use OCA\DAV\CalDAV\EventComparisonService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Defaults; -use OCP\IConfig; -use OCP\IDBConnection; -use OCP\IL10N; -use OCP\ILogger; -use OCP\IURLGenerator; -use OCP\IUserManager; -use OCP\L10N\IFactory as L10NFactory; -use OCP\Mail\IEMailTemplate; +use OCP\IAppConfig; +use OCP\IUserSession; use OCP\Mail\IMailer; -use OCP\Security\ISecureRandom; +use OCP\Mail\Provider\Address; +use OCP\Mail\Provider\Attachment; +use OCP\Mail\Provider\IManager as IMailManager; +use OCP\Mail\Provider\IMessageSend; use OCP\Util; +use Psr\Log\LoggerInterface; use Sabre\CalDAV\Schedule\IMipPlugin as SabreIMipPlugin; +use Sabre\DAV; +use Sabre\DAV\INode; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VEvent; -use Sabre\VObject\DateTimeParser; use Sabre\VObject\ITip\Message; use Sabre\VObject\Parameter; -use Sabre\VObject\Property; -use Sabre\VObject\Recur\EventIterator; +use Sabre\VObject\Reader; /** * iMIP handler. @@ -72,75 +46,47 @@ use Sabre\VObject\Recur\EventIterator; */ class IMipPlugin extends SabreIMipPlugin { - /** @var string */ - private $userId; - - /** @var IConfig */ - private $config; - - /** @var IMailer */ - private $mailer; - - /** @var ILogger */ - private $logger; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var L10NFactory */ - private $l10nFactory; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var ISecureRandom */ - private $random; - - /** @var IDBConnection */ - private $db; - - /** @var Defaults */ - private $defaults; - - /** @var IUserManager */ - private $userManager; - + private ?VCalendar $vCalendar = null; public const MAX_DATE = '2038-01-01'; - public const METHOD_REQUEST = 'request'; public const METHOD_REPLY = 'reply'; public const METHOD_CANCEL = 'cancel'; - public const IMIP_INDENT = 15; // Enough for the length of all body bullet items, in all languages + public const IMIP_INDENT = 15; + + public function __construct( + private IAppConfig $config, + private IMailer $mailer, + private LoggerInterface $logger, + private ITimeFactory $timeFactory, + private Defaults $defaults, + private IUserSession $userSession, + private IMipService $imipService, + private EventComparisonService $eventComparisonService, + private IMailManager $mailManager, + ) { + parent::__construct(''); + } + + public function initialize(DAV\Server $server): void { + parent::initialize($server); + $server->on('beforeWriteContent', [$this, 'beforeWriteContent'], 10); + } /** - * @param IConfig $config - * @param IMailer $mailer - * @param ILogger $logger - * @param ITimeFactory $timeFactory - * @param L10NFactory $l10nFactory - * @param IUrlGenerator $urlGenerator - * @param Defaults $defaults - * @param ISecureRandom $random - * @param IDBConnection $db - * @param string $userId + * Check quota before writing content + * + * @param string $uri target file URI + * @param INode $node Sabre Node + * @param resource $data data + * @param bool $modified modified */ - public function __construct(IConfig $config, IMailer $mailer, ILogger $logger, - ITimeFactory $timeFactory, L10NFactory $l10nFactory, - IURLGenerator $urlGenerator, Defaults $defaults, - ISecureRandom $random, IDBConnection $db, IUserManager $userManager, - $userId) { - parent::__construct(''); - $this->userId = $userId; - $this->config = $config; - $this->mailer = $mailer; - $this->logger = $logger; - $this->timeFactory = $timeFactory; - $this->l10nFactory = $l10nFactory; - $this->urlGenerator = $urlGenerator; - $this->random = $random; - $this->db = $db; - $this->defaults = $defaults; - $this->userManager = $userManager; + public function beforeWriteContent($uri, INode $node, $data, $modified): void { + if (!$node instanceof CalendarObject) { + return; + } + /** @var VCalendar $vCalendar */ + $vCalendar = Reader::read($node->get()); + $this->setVCalendar($vCalendar); } /** @@ -151,8 +97,7 @@ class IMipPlugin extends SabreIMipPlugin { */ public function schedule(Message $iTipMessage) { - // Not sending any emails if the system considers the update - // insignificant. + // Not sending any emails if the system considers the update insignificant if (!$iTipMessage->significantChange) { if (!$iTipMessage->scheduleStatus) { $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; @@ -160,103 +105,114 @@ class IMipPlugin extends SabreIMipPlugin { return; } - $summary = $iTipMessage->message->VEVENT->SUMMARY; - - if (parse_url($iTipMessage->sender, PHP_URL_SCHEME) !== 'mailto') { - return; - } - - if (parse_url($iTipMessage->recipient, PHP_URL_SCHEME) !== 'mailto') { + if (parse_url($iTipMessage->sender, PHP_URL_SCHEME) !== 'mailto' + || parse_url($iTipMessage->recipient, PHP_URL_SCHEME) !== 'mailto') { return; } // don't send out mails for events that already took place - $lastOccurrence = $this->getLastOccurrence($iTipMessage->message); + $lastOccurrence = $this->imipService->getLastOccurrence($iTipMessage->message); $currentTime = $this->timeFactory->getTime(); if ($lastOccurrence < $currentTime) { return; } // Strip off mailto: - $sender = substr($iTipMessage->sender, 7); $recipient = substr($iTipMessage->recipient, 7); - if ($recipient === false || !$this->mailer->validateMailAddress($recipient)) { + if (!$this->mailer->validateMailAddress($recipient)) { // Nothing to send if the recipient doesn't have a valid email address $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; return; } - - $senderName = $iTipMessage->senderName ?: null; - $recipientName = $iTipMessage->recipientName ?: null; - - if ($senderName === null || empty(trim($senderName))) { - $user = $this->userManager->get($this->userId); - if ($user) { - // getDisplayName automatically uses the uid - // if no display-name is set - $senderName = $user->getDisplayName(); - } + $recipientName = $iTipMessage->recipientName ? (string)$iTipMessage->recipientName : null; + + $newEvents = $iTipMessage->message; + $oldEvents = $this->getVCalendar(); + + $modified = $this->eventComparisonService->findModified($newEvents, $oldEvents); + /** @var VEvent $vEvent */ + $vEvent = array_pop($modified['new']); + /** @var VEvent $oldVevent */ + $oldVevent = !empty($modified['old']) && is_array($modified['old']) ? array_pop($modified['old']) : null; + $isModified = isset($oldVevent); + + // No changed events after all - this shouldn't happen if there is significant change yet here we are + // The scheduling status is debatable + if (empty($vEvent)) { + $this->logger->warning('iTip message said the change was significant but comparison did not detect any updated VEvents'); + $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; + return; } - /** @var VEvent $vevent */ - $vevent = $iTipMessage->message->VEVENT; - - $attendee = $this->getCurrentAttendee($iTipMessage); - $defaultLang = $this->l10nFactory->findGenericLanguage(); - $lang = $this->getAttendeeLangOrDefault($defaultLang, $attendee); - $l10n = $this->l10nFactory->get('dav', $lang); - - $meetingAttendeeName = $recipientName ?: $recipient; - $meetingInviteeName = $senderName ?: $sender; - - $meetingTitle = $vevent->SUMMARY; - $meetingDescription = $vevent->DESCRIPTION; - - - $meetingUrl = $vevent->URL; - $meetingLocation = $vevent->LOCATION; + // we (should) have one event component left + // as the ITip\Broker creates one iTip message per change + // and triggers the "schedule" event once per message + // we also might not have an old event as this could be a new + // invitation, or a new recurrence exception + $attendee = $this->imipService->getCurrentAttendee($iTipMessage); + if ($attendee === null) { + $uid = $vEvent->UID ?? 'no UID found'; + $this->logger->debug('Could not find recipient ' . $recipient . ' as attendee for event with UID ' . $uid); + $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; + return; + } + // Don't send emails to rooms, resources and circles + if ($this->imipService->isRoomOrResource($attendee) + || $this->imipService->isCircle($attendee)) { + $this->logger->debug('No invitation sent as recipient is room, resource or circle', [ + 'attendee' => $recipient, + ]); + $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; + return; + } + $this->imipService->setL10n($attendee); + + // Build the sender name. + // Due to a bug in sabre, the senderName property for an iTIP message can actually also be a VObject Property + // If the iTIP message senderName is null or empty use the user session name as the senderName + if (($iTipMessage->senderName instanceof Parameter) && !empty(trim($iTipMessage->senderName->getValue()))) { + $senderName = trim($iTipMessage->senderName->getValue()); + } elseif (is_string($iTipMessage->senderName) && !empty(trim($iTipMessage->senderName))) { + $senderName = trim($iTipMessage->senderName); + } elseif ($this->userSession->getUser() !== null) { + $senderName = trim($this->userSession->getUser()->getDisplayName()); + } else { + $senderName = ''; + } - $defaultVal = '--'; + $sender = substr($iTipMessage->sender, 7); - $method = self::METHOD_REQUEST; + $replyingAttendee = null; switch (strtolower($iTipMessage->method)) { case self::METHOD_REPLY: $method = self::METHOD_REPLY; + $data = $this->imipService->buildReplyBodyData($vEvent); + $replyingAttendee = $this->imipService->getReplyingAttendee($iTipMessage); break; case self::METHOD_CANCEL: $method = self::METHOD_CANCEL; + $data = $this->imipService->buildCancelledBodyData($vEvent); + break; + default: + $method = self::METHOD_REQUEST; + $data = $this->imipService->buildBodyData($vEvent, $oldVevent); break; } - $data = [ - 'attendee_name' => (string)$meetingAttendeeName ?: $defaultVal, - 'invitee_name' => (string)$meetingInviteeName ?: $defaultVal, - 'meeting_title' => (string)$meetingTitle ?: $defaultVal, - 'meeting_description' => (string)$meetingDescription ?: $defaultVal, - 'meeting_url' => (string)$meetingUrl ?: $defaultVal, - ]; + $data['attendee_name'] = ($recipientName ?: $recipient); + $data['invitee_name'] = ($senderName ?: $sender); $fromEMail = Util::getDefaultEmailAddress('invitations-noreply'); - $fromName = $l10n->t('%1$s via %2$s', [$senderName, $this->defaults->getName()]); - - $message = $this->mailer->createMessage() - ->setFrom([$fromEMail => $fromName]) - ->setTo([$recipient => $recipientName]); - - if ($sender !== false) { - $message->setReplyTo([$sender => $senderName]); - } + $fromName = $this->imipService->getFrom($senderName, $this->defaults->getName()); $template = $this->mailer->createEMailTemplate('dav.calendarInvite.' . $method, $data); $template->addHeader(); - $summary = ((string) $summary !== '') ? (string) $summary : $l10n->t('Untitled event'); - - $this->addSubjectAndHeading($template, $l10n, $method, $summary); - $this->addBulletList($template, $l10n, $vevent); + $this->imipService->addSubjectAndHeading($template, $method, $data['invitee_name'], $data['meeting_title'], $isModified, $replyingAttendee); + $this->imipService->addBulletList($template, $vEvent, $data); // Only add response buttons to invitation requests: Fix Issue #11230 - if (($method == self::METHOD_REQUEST) && $this->getAttendeeRsvpOrReqForParticipant($attendee)) { + if (strcasecmp($method, self::METHOD_REQUEST) === 0 && $this->imipService->getAttendeeRsvpOrReqForParticipant($attendee)) { /* ** Only offer invitation accept/reject buttons, which link back to the @@ -277,453 +233,106 @@ class IMipPlugin extends SabreIMipPlugin { ** To suppress URLs entirely, set invitation_link_recipients to boolean "no". */ - $recipientDomain = substr(strrchr($recipient, "@"), 1); - $invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getAppValue('dav', 'invitation_link_recipients', 'yes')))); + $recipientDomain = substr(strrchr($recipient, '@'), 1); + $invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getValueString('dav', 'invitation_link_recipients', 'yes')))); if (strcmp('yes', $invitationLinkRecipients[0]) === 0 - || in_array(strtolower($recipient), $invitationLinkRecipients) - || in_array(strtolower($recipientDomain), $invitationLinkRecipients)) { - $this->addResponseButtons($template, $l10n, $iTipMessage, $lastOccurrence); + || in_array(strtolower($recipient), $invitationLinkRecipients) + || in_array(strtolower($recipientDomain), $invitationLinkRecipients)) { + $token = $this->imipService->createInvitationToken($iTipMessage, $vEvent, $lastOccurrence); + $this->imipService->addResponseButtons($template, $token); + $this->imipService->addMoreOptionsButton($template, $token); } } $template->addFooter(); + // convert iTip Message to string + $itip_msg = $iTipMessage->message->serialize(); - $message->useTemplate($template); - - $attachment = $this->mailer->createAttachment( - $iTipMessage->message->serialize(), - 'event.ics',// TODO(leon): Make file name unique, e.g. add event id - 'text/calendar; method=' . $iTipMessage->method - ); - $message->attach($attachment); + $mailService = null; try { - $failed = $this->mailer->send($message); - $iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip'; - if ($failed) { - $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); - $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; - } - } catch (\Exception $ex) { - $this->logger->logException($ex, ['app' => 'dav']); - $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; - } - } - - /** - * check if event took place in the past already - * @param VCalendar $vObject - * @return int - */ - private function getLastOccurrence(VCalendar $vObject) { - /** @var VEvent $component */ - $component = $vObject->VEVENT; - - $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp(); - // Finding the last occurrence is a bit harder - if (!isset($component->RRULE)) { - if (isset($component->DTEND)) { - $lastOccurrence = $component->DTEND->getDateTime()->getTimeStamp(); - } elseif (isset($component->DURATION)) { - /** @var \DateTime $endDate */ - $endDate = clone $component->DTSTART->getDateTime(); - // $component->DTEND->getDateTime() returns DateTimeImmutable - $endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue())); - $lastOccurrence = $endDate->getTimestamp(); - } elseif (!$component->DTSTART->hasTime()) { - /** @var \DateTime $endDate */ - $endDate = clone $component->DTSTART->getDateTime(); - // $component->DTSTART->getDateTime() returns DateTimeImmutable - $endDate = $endDate->modify('+1 day'); - $lastOccurrence = $endDate->getTimestamp(); - } else { - $lastOccurrence = $firstOccurrence; - } - } else { - $it = new EventIterator($vObject, (string)$component->UID); - $maxDate = new \DateTime(self::MAX_DATE); - if ($it->isInfinite()) { - $lastOccurrence = $maxDate->getTimestamp(); - } else { - $end = $it->getDtEnd(); - while ($it->valid() && $end < $maxDate) { - $end = $it->getDtEnd(); - $it->next(); + if ($this->config->getValueBool('core', 'mail_providers_enabled', true)) { + // retrieve user object + $user = $this->userSession->getUser(); + if ($user !== null) { + // retrieve appropriate service with the same address as sender + $mailService = $this->mailManager->findServiceByAddress($user->getUID(), $sender); } - $lastOccurrence = $end->getTimestamp(); } - } - - return $lastOccurrence; - } - /** - * @param Message $iTipMessage - * @return null|Property - */ - private function getCurrentAttendee(Message $iTipMessage) { - /** @var VEvent $vevent */ - $vevent = $iTipMessage->message->VEVENT; - $attendees = $vevent->select('ATTENDEE'); - foreach ($attendees as $attendee) { - /** @var Property $attendee */ - if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) { - return $attendee; - } - } - return null; - } - - /** - * @param string $default - * @param Property|null $attendee - * @return string - */ - private function getAttendeeLangOrDefault($default, Property $attendee = null) { - if ($attendee !== null) { - $lang = $attendee->offsetGet('LANGUAGE'); - if ($lang instanceof Parameter) { - return $lang->getValue(); - } - } - return $default; - } - - /** - * @param Property|null $attendee - * @return bool - */ - private function getAttendeeRsvpOrReqForParticipant(Property $attendee = null) { - if ($attendee !== null) { - $rsvp = $attendee->offsetGet('RSVP'); - if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { - return true; - } - $role = $attendee->offsetGet('ROLE'); - // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16 - // Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set - if ($role === null - || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0)) - || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0)) - ) { - return true; - } - } - // RFC 5545 3.2.17: default RSVP is false - return false; - } - - /** - * @param IL10N $l10n - * @param VEvent $vevent - */ - private function generateWhenString(IL10N $l10n, VEvent $vevent) { - $dtstart = $vevent->DTSTART; - if (isset($vevent->DTEND)) { - $dtend = $vevent->DTEND; - } elseif (isset($vevent->DURATION)) { - $isFloating = $vevent->DTSTART->isFloating(); - $dtend = clone $vevent->DTSTART; - $endDateTime = $dtend->getDateTime(); - $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); - $dtend->setDateTime($endDateTime, $isFloating); - } elseif (!$vevent->DTSTART->hasTime()) { - $isFloating = $vevent->DTSTART->isFloating(); - $dtend = clone $vevent->DTSTART; - $endDateTime = $dtend->getDateTime(); - $endDateTime = $endDateTime->modify('+1 day'); - $dtend->setDateTime($endDateTime, $isFloating); - } else { - $dtend = clone $vevent->DTSTART; - } - - $isAllDay = $dtstart instanceof Property\ICalendar\Date; - - /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ - /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */ - /** @var \DateTimeImmutable $dtstartDt */ - $dtstartDt = $dtstart->getDateTime(); - /** @var \DateTimeImmutable $dtendDt */ - $dtendDt = $dtend->getDateTime(); - - $diff = $dtstartDt->diff($dtendDt); - - $dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM)); - $dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM)); - - if ($isAllDay) { - // One day event - if ($diff->days === 1) { - return $l10n->l('date', $dtstartDt, ['width' => 'medium']); - } - - // DTEND is exclusive, so if the ics data says 2020-01-01 to 2020-01-05, - // the email should show 2020-01-01 to 2020-01-04. - $dtendDt->modify('-1 day'); - - //event that spans over multiple days - $localeStart = $l10n->l('date', $dtstartDt, ['width' => 'medium']); - $localeEnd = $l10n->l('date', $dtendDt, ['width' => 'medium']); - - return $localeStart . ' - ' . $localeEnd; - } - - /** @var Property\ICalendar\DateTime $dtstart */ - /** @var Property\ICalendar\DateTime $dtend */ - $isFloating = $dtstart->isFloating(); - $startTimezone = $endTimezone = null; - if (!$isFloating) { - $prop = $dtstart->offsetGet('TZID'); - if ($prop instanceof Parameter) { - $startTimezone = $prop->getValue(); - } - - $prop = $dtend->offsetGet('TZID'); - if ($prop instanceof Parameter) { - $endTimezone = $prop->getValue(); - } - } - - $localeStart = $l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' . - $l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']); - - // always show full date with timezone if timezones are different - if ($startTimezone !== $endTimezone) { - $localeEnd = $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); - - return $localeStart . ' (' . $startTimezone . ') - ' . - $localeEnd . ' (' . $endTimezone . ')'; - } - - // show only end time if date is the same - if ($this->isDayEqual($dtstartDt, $dtendDt)) { - $localeEnd = $l10n->l('time', $dtendDt, ['width' => 'short']); - } else { - $localeEnd = $l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' . - $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); - } - - return $localeStart . ' - ' . $localeEnd . ' (' . $startTimezone . ')'; - } - - /** - * @param \DateTime $dtStart - * @param \DateTime $dtEnd - * @return bool - */ - private function isDayEqual(\DateTime $dtStart, \DateTime $dtEnd) { - return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); - } - - /** - * @param IEMailTemplate $template - * @param IL10N $l10n - * @param string $method - * @param string $summary - */ - private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, - $method, $summary) { - if ($method === self::METHOD_CANCEL) { - // TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}" - $template->setSubject($l10n->t('Cancelled: %1$s', [$summary])); - $template->addHeading($l10n->t('Invitation canceled')); - } elseif ($method === self::METHOD_REPLY) { - // TRANSLATORS Subject for email, when an invitation is updated. Ex: "Re: {{Event Name}}" - $template->setSubject($l10n->t('Re: %1$s', [$summary])); - $template->addHeading($l10n->t('Invitation updated')); - } else { - // TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}" - $template->setSubject($l10n->t('Invitation: %1$s', [$summary])); - $template->addHeading($l10n->t('Invitation')); - } - } - - /** - * @param IEMailTemplate $template - * @param IL10N $l10n - * @param VEVENT $vevent - */ - private function addBulletList(IEMailTemplate $template, IL10N $l10n, $vevent) { - if ($vevent->SUMMARY) { - $template->addBodyListItem($vevent->SUMMARY, $l10n->t('Title:'), - $this->getAbsoluteImagePath('caldav/title.png'),'','',self::IMIP_INDENT); - } - $meetingWhen = $this->generateWhenString($l10n, $vevent); - if ($meetingWhen) { - $template->addBodyListItem($meetingWhen, $l10n->t('Time:'), - $this->getAbsoluteImagePath('caldav/time.png'),'','',self::IMIP_INDENT); - } - if ($vevent->LOCATION) { - $template->addBodyListItem($vevent->LOCATION, $l10n->t('Location:'), - $this->getAbsoluteImagePath('caldav/location.png'),'','',self::IMIP_INDENT); - } - if ($vevent->URL) { - $url = $vevent->URL->getValue(); - $template->addBodyListItem(sprintf('<a href="%s">%s</a>', - htmlspecialchars($url), - htmlspecialchars($url)), - $l10n->t('Link:'), - $this->getAbsoluteImagePath('caldav/link.png'), - $url,'',self::IMIP_INDENT); - } - - $this->addAttendees($template, $l10n, $vevent); - - /* Put description last, like an email body, since it can be arbitrarily long */ - if ($vevent->DESCRIPTION) { - $template->addBodyListItem($vevent->DESCRIPTION->getValue(), $l10n->t('Description:'), - $this->getAbsoluteImagePath('caldav/description.png'),'','',self::IMIP_INDENT); - } - } - - /** - * addAttendees: add organizer and attendee names/emails to iMip mail. - * - * Enable with DAV setting: invitation_list_attendees (default: no) - * - * The default is 'no', which matches old behavior, and is privacy preserving. - * - * To enable including attendees in invitation emails: - * % php occ config:app:set dav invitation_list_attendees --value yes - * - * @param IEMailTemplate $template - * @param IL10N $l10n - * @param Message $iTipMessage - * @param int $lastOccurrence - * @author brad2014 on github.com - */ - - private function addAttendees(IEMailTemplate $template, IL10N $l10n, VEvent $vevent) { - if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') { - return; - } - - if (isset($vevent->ORGANIZER)) { - /** @var Property\ICalendar\CalAddress $organizer */ - $organizer = $vevent->ORGANIZER; - $organizerURI = $organizer->getNormalizedValue(); - [$scheme,$organizerEmail] = explode(':',$organizerURI,2); # strip off scheme mailto: - /** @var string|null $organizerName */ - $organizerName = isset($organizer['CN']) ? $organizer['CN'] : null; - $organizerHTML = sprintf('<a href="%s">%s</a>', - htmlspecialchars($organizerURI), - htmlspecialchars($organizerName ?: $organizerEmail)); - $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail); - if (isset($organizer['PARTSTAT'])) { - /** @var Parameter $partstat */ - $partstat = $organizer['PARTSTAT']; - if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { - $organizerHTML .= ' ✔︎'; - $organizerText .= ' ✔︎'; - } + // The display name in Nextcloud can use utf-8. + // As the default charset for text/* is us-ascii, it's important to explicitly define it. + // See https://www.rfc-editor.org/rfc/rfc6047.html#section-2.4. + $contentType = 'text/calendar; method=' . $iTipMessage->method . '; charset="utf-8"'; + + // evaluate if a mail service was found and has sending capabilities + if ($mailService instanceof IMessageSend) { + // construct mail message and set required parameters + $message = $mailService->initiateMessage(); + $message->setFrom( + (new Address($sender, $fromName)) + ); + $message->setTo( + (new Address($recipient, $recipientName)) + ); + $message->setSubject($template->renderSubject()); + $message->setBodyPlain($template->renderText()); + $message->setBodyHtml($template->renderHtml()); + // Adding name=event.ics is a trick to make the invitation also appear + // as a file attachment in mail clients like Thunderbird or Evolution. + $message->setAttachments((new Attachment( + $itip_msg, + null, + $contentType . '; name=event.ics', + true + ))); + // send message + $mailService->sendMessage($message); + } else { + // construct symfony mailer message and set required parameters + $message = $this->mailer->createMessage(); + $message->setFrom([$fromEMail => $fromName]); + $message->setTo( + (($recipientName !== null) ? [$recipient => $recipientName] : [$recipient]) + ); + $message->setReplyTo( + (($senderName !== null) ? [$sender => $senderName] : [$sender]) + ); + $message->useTemplate($template); + // Using a different content type because Symfony Mailer/Mime will append the name to + // the content type header and attachInline does not allow null. + $message->attachInline( + $itip_msg, + 'event.ics', + $contentType, + ); + $failed = $this->mailer->send($message); } - $template->addBodyListItem($organizerHTML, $l10n->t('Organizer:'), - $this->getAbsoluteImagePath('caldav/organizer.png'), - $organizerText,'',self::IMIP_INDENT); - } - $attendees = $vevent->select('ATTENDEE'); - if (count($attendees) === 0) { - return; - } - - $attendeesHTML = []; - $attendeesText = []; - foreach ($attendees as $attendee) { - $attendeeURI = $attendee->getNormalizedValue(); - [$scheme,$attendeeEmail] = explode(':',$attendeeURI,2); # strip off scheme mailto: - $attendeeName = isset($attendee['CN']) ? $attendee['CN'] : null; - $attendeeHTML = sprintf('<a href="%s">%s</a>', - htmlspecialchars($attendeeURI), - htmlspecialchars($attendeeName ?: $attendeeEmail)); - $attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail); - if (isset($attendee['PARTSTAT']) - && strcasecmp($attendee['PARTSTAT'], 'ACCEPTED') === 0) { - $attendeeHTML .= ' ✔︎'; - $attendeeText .= ' ✔︎'; + $iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip'; + if (!empty($failed)) { + $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); + $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; } - array_push($attendeesHTML, $attendeeHTML); - array_push($attendeesText, $attendeeText); + } catch (\Exception $ex) { + $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]); + $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; } - - $template->addBodyListItem(implode('<br/>',$attendeesHTML), $l10n->t('Attendees:'), - $this->getAbsoluteImagePath('caldav/attendees.png'), - implode("\n",$attendeesText),'',self::IMIP_INDENT); } /** - * @param IEMailTemplate $template - * @param IL10N $l10n - * @param Message $iTipMessage - * @param int $lastOccurrence + * @return ?VCalendar */ - private function addResponseButtons(IEMailTemplate $template, IL10N $l10n, - Message $iTipMessage, $lastOccurrence) { - $token = $this->createInvitationToken($iTipMessage, $lastOccurrence); - - $template->addBodyButtonGroup( - $l10n->t('Accept'), - $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [ - 'token' => $token, - ]), - $l10n->t('Decline'), - $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [ - 'token' => $token, - ]) - ); - - $moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [ - 'token' => $token, - ]); - $html = vsprintf('<small><a href="%s">%s</a></small>', [ - $moreOptionsURL, $l10n->t('More options …') - ]); - $text = $l10n->t('More options at %s', [$moreOptionsURL]); - - $template->addBodyText($html, $text); + public function getVCalendar(): ?VCalendar { + return $this->vCalendar; } /** - * @param string $path - * @return string + * @param ?VCalendar $vCalendar */ - private function getAbsoluteImagePath($path) { - return $this->urlGenerator->getAbsoluteURL( - $this->urlGenerator->imagePath('core', $path) - ); + public function setVCalendar(?VCalendar $vCalendar): void { + $this->vCalendar = $vCalendar; } - /** - * @param Message $iTipMessage - * @param int $lastOccurrence - * @return string - */ - private function createInvitationToken(Message $iTipMessage, $lastOccurrence):string { - $token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC); - - /** @var VEvent $vevent */ - $vevent = $iTipMessage->message->VEVENT; - $attendee = $iTipMessage->recipient; - $organizer = $iTipMessage->sender; - $sequence = $iTipMessage->sequence; - $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? - $vevent->{'RECURRENCE-ID'}->serialize() : null; - $uid = $vevent->{'UID'}; - - $query = $this->db->getQueryBuilder(); - $query->insert('calendar_invitations') - ->values([ - 'token' => $query->createNamedParameter($token), - 'attendee' => $query->createNamedParameter($attendee), - 'organizer' => $query->createNamedParameter($organizer), - 'sequence' => $query->createNamedParameter($sequence), - 'recurrenceid' => $query->createNamedParameter($recurrenceId), - 'expiration' => $query->createNamedParameter($lastOccurrence), - 'uid' => $query->createNamedParameter($uid) - ]) - ->execute(); - - return $token; - } } diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php new file mode 100644 index 00000000000..54c0bc31849 --- /dev/null +++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php @@ -0,0 +1,1294 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\Schedule; + +use OC\URLGenerator; +use OCA\DAV\CalDAV\EventReader; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\L10N\IFactory as L10NFactory; +use OCP\Mail\IEMailTemplate; +use OCP\Security\ISecureRandom; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; +use Sabre\VObject\DateTimeParser; +use Sabre\VObject\ITip\Message; +use Sabre\VObject\Parameter; +use Sabre\VObject\Property; +use Sabre\VObject\Recur\EventIterator; + +class IMipService { + + private IL10N $l10n; + + /** @var string[] */ + private const STRING_DIFF = [ + 'meeting_title' => 'SUMMARY', + 'meeting_description' => 'DESCRIPTION', + 'meeting_url' => 'URL', + 'meeting_location' => 'LOCATION' + ]; + + public function __construct( + private URLGenerator $urlGenerator, + private IConfig $config, + private IDBConnection $db, + private ISecureRandom $random, + private L10NFactory $l10nFactory, + private ITimeFactory $timeFactory, + ) { + $language = $this->l10nFactory->findGenericLanguage(); + $locale = $this->l10nFactory->findLocale($language); + $this->l10n = $this->l10nFactory->get('dav', $language, $locale); + } + + /** + * @param string|null $senderName + * @param string $default + * @return string + */ + public function getFrom(?string $senderName, string $default): string { + if ($senderName === null) { + return $default; + } + + return $this->l10n->t('%1$s via %2$s', [$senderName, $default]); + } + + public static function readPropertyWithDefault(VEvent $vevent, string $property, string $default) { + if (isset($vevent->$property)) { + $value = $vevent->$property->getValue(); + if (!empty($value)) { + return $value; + } + } + return $default; + } + + private function generateDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string { + $strikethrough = "<span style='text-decoration: line-through'>%s</span><br />%s"; + if (!isset($vevent->$property)) { + return $default; + } + $newstring = $vevent->$property->getValue(); + if (isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newstring) { + $oldstring = $oldVEvent->$property->getValue(); + return sprintf($strikethrough, $oldstring, $newstring); + } + return $newstring; + } + + /** + * Like generateDiffString() but linkifies the property values if they are urls. + */ + private function generateLinkifiedDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string { + if (!isset($vevent->$property)) { + return $default; + } + /** @var string|null $newString */ + $newString = $vevent->$property->getValue(); + $oldString = isset($oldVEvent->$property) ? $oldVEvent->$property->getValue() : null; + if ($oldString !== $newString) { + return sprintf( + "<span style='text-decoration: line-through'>%s</span><br />%s", + $this->linkify($oldString) ?? $oldString ?? '', + $this->linkify($newString) ?? $newString ?? '' + ); + } + return $this->linkify($newString) ?? $newString; + } + + /** + * Convert a given url to a html link element or return null otherwise. + */ + private function linkify(?string $url): ?string { + if ($url === null) { + return null; + } + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + return null; + } + + return sprintf('<a href="%1$s">%1$s</a>', htmlspecialchars($url)); + } + + /** + * @param VEvent $vEvent + * @param VEvent|null $oldVEvent + * @return array + */ + public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array { + + // construct event reader + $eventReaderCurrent = new EventReader($vEvent); + $eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null; + $defaultVal = ''; + $data = []; + $data['meeting_when'] = $this->generateWhenString($eventReaderCurrent); + + foreach (self::STRING_DIFF as $key => $property) { + $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal); + } + + $data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal); + + if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) { + $data['meeting_location_html'] = $locationHtml; + } + + if (!empty($oldVEvent)) { + $oldMeetingWhen = $this->generateWhenString($eventReaderPrevious); + $data['meeting_title_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY', $data['meeting_title']); + $data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION', $data['meeting_description']); + $data['meeting_location_html'] = $this->generateLinkifiedDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']); + + $oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal); + $data['meeting_url_html'] = !empty($oldUrl) && $oldUrl !== $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url']; + + $data['meeting_when_html'] = $oldMeetingWhen !== $data['meeting_when'] ? sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", $oldMeetingWhen, $data['meeting_when']) : $data['meeting_when']; + } + // generate occurring next string + if ($eventReaderCurrent->recurs()) { + $data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent); + } + return $data; + } + + /** + * @param VEvent $vEvent + * @return array + */ + public function buildReplyBodyData(VEvent $vEvent): array { + // construct event reader + $eventReader = new EventReader($vEvent); + $defaultVal = ''; + $data = []; + $data['meeting_when'] = $this->generateWhenString($eventReader); + + foreach (self::STRING_DIFF as $key => $property) { + $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal); + } + + if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) { + $data['meeting_location_html'] = $locationHtml; + } + + $data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : ''; + + // generate occurring next string + if ($eventReader->recurs()) { + $data['meeting_occurring'] = $this->generateOccurringString($eventReader); + } + + return $data; + } + + /** + * generates a when string based on if a event has an recurrence or not + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenString(EventReader $er): string { + return match ($er->recurs()) { + true => $this->generateWhenStringRecurring($er), + false => $this->generateWhenStringSingular($er) + }; + } + + /** + * generates a when string for a non recurring event + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringSingular(EventReader $er): string { + // initialize + $startTime = null; + $endTime = null; + // calculate time difference from now to start of event + $occurring = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate())); + // extract start date + $startDate = $this->l10n->l('date', $er->startDateTime(), ['width' => 'full']); + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order: + // In a minute/hour/day/week/month/year on July 1, 2024 for the entire day + // In a minute/hour/day/week/month/year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto) + // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 for the entire day + // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto) + return match ([$occurring['scale'], $endTime !== null]) { + ['past', false] => $this->l10n->t( + 'In the past on %1$s for the entire day', + [$startDate] + ), + ['minute', false] => $this->l10n->n( + 'In a minute on %1$s for the entire day', + 'In %n minutes on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['hour', false] => $this->l10n->n( + 'In a hour on %1$s for the entire day', + 'In %n hours on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['day', false] => $this->l10n->n( + 'In a day on %1$s for the entire day', + 'In %n days on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['week', false] => $this->l10n->n( + 'In a week on %1$s for the entire day', + 'In %n weeks on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['month', false] => $this->l10n->n( + 'In a month on %1$s for the entire day', + 'In %n months on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['year', false] => $this->l10n->n( + 'In a year on %1$s for the entire day', + 'In %n years on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['past', true] => $this->l10n->t( + 'In the past on %1$s between %2$s - %3$s', + [$startDate, $startTime, $endTime] + ), + ['minute', true] => $this->l10n->n( + 'In a minute on %1$s between %2$s - %3$s', + 'In %n minutes on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + ['hour', true] => $this->l10n->n( + 'In a hour on %1$s between %2$s - %3$s', + 'In %n hours on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + ['day', true] => $this->l10n->n( + 'In a day on %1$s between %2$s - %3$s', + 'In %n days on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + ['week', true] => $this->l10n->n( + 'In a week on %1$s between %2$s - %3$s', + 'In %n weeks on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + ['month', true] => $this->l10n->n( + 'In a month on %1$s between %2$s - %3$s', + 'In %n months on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + ['year', true] => $this->l10n->n( + 'In a year on %1$s between %2$s - %3$s', + 'In %n years on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + default => $this->l10n->t('Could not generate when statement') + }; + } + + /** + * generates a when string based on recurrence precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurring(EventReader $er): string { + return match ($er->recurringPrecision()) { + 'daily' => $this->generateWhenStringRecurringDaily($er), + 'weekly' => $this->generateWhenStringRecurringWeekly($er), + 'monthly' => $this->generateWhenStringRecurringMonthly($er), + 'yearly' => $this->generateWhenStringRecurringYearly($er), + 'fixed' => $this->generateWhenStringRecurringFixed($er), + }; + } + + /** + * generates a when string for a daily precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurringDaily(EventReader $er): string { + + // initialize + $interval = (int)$er->recurringInterval(); + $startTime = null; + $conclusion = null; + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // conclusion + if ($er->recurringConcludes()) { + $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']); + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order: + // Every Day for the entire day + // Every Day for the entire day until July 13, 2024 + // Every Day between 8:00 AM - 9:00 AM (America/Toronto) + // Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024 + // Every 3 Days for the entire day + // Every 3 Days for the entire day until July 13, 2024 + // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) + // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024 + return match ([($interval > 1), $startTime !== null, $conclusion !== null]) { + [false, false, false] => $this->l10n->t('Every Day for the entire day'), + [false, false, true] => $this->l10n->t('Every Day for the entire day until %1$s', [$conclusion]), + [false, true, false] => $this->l10n->t('Every Day between %1$s - %2$s', [$startTime, $endTime]), + [false, true, true] => $this->l10n->t('Every Day between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]), + [true, false, false] => $this->l10n->t('Every %1$d Days for the entire day', [$interval]), + [true, false, true] => $this->l10n->t('Every %1$d Days for the entire day until %2$s', [$interval, $conclusion]), + [true, true, false] => $this->l10n->t('Every %1$d Days between %2$s - %3$s', [$interval, $startTime, $endTime]), + [true, true, true] => $this->l10n->t('Every %1$d Days between %2$s - %3$s until %4$s', [$interval, $startTime, $endTime, $conclusion]), + default => $this->l10n->t('Could not generate event recurrence statement') + }; + + } + + /** + * generates a when string for a weekly precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurringWeekly(EventReader $er): string { + + // initialize + $interval = (int)$er->recurringInterval(); + $startTime = null; + $conclusion = null; + // days of the week + $days = implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed())); + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // conclusion + if ($er->recurringConcludes()) { + $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']); + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order: + // Every Week on Monday, Wednesday, Friday for the entire day + // Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024 + // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) + // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024 + // Every 2 Weeks on Monday, Wednesday, Friday for the entire day + // Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024 + // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) + // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024 + return match ([($interval > 1), $startTime !== null, $conclusion !== null]) { + [false, false, false] => $this->l10n->t('Every Week on %1$s for the entire day', [$days]), + [false, false, true] => $this->l10n->t('Every Week on %1$s for the entire day until %2$s', [$days, $conclusion]), + [false, true, false] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s', [$days, $startTime, $endTime]), + [false, true, true] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]), + [true, false, false] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day', [$interval, $days]), + [true, false, true] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day until %3$s', [$interval, $days, $conclusion]), + [true, true, false] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]), + [true, true, true] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]), + default => $this->l10n->t('Could not generate event recurrence statement') + }; + + } + + /** + * generates a when string for a monthly precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurringMonthly(EventReader $er): string { + + // initialize + $interval = (int)$er->recurringInterval(); + $startTime = null; + $conclusion = null; + // days of month + if ($er->recurringPattern() === 'R') { + $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' ' + . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed())); + } else { + $days = implode(', ', $er->recurringDaysOfMonth()); + } + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // conclusion + if ($er->recurringConcludes()) { + $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']); + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order, output varies depending on if the event is absolute or releative: + // Absolute: Every Month on the 1, 8 for the entire day + // Relative: Every Month on the First Sunday, Saturday for the entire day + // Absolute: Every Month on the 1, 8 for the entire day until December 31, 2024 + // Relative: Every Month on the First Sunday, Saturday for the entire day until December 31, 2024 + // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) + // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) + // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024 + // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024 + // Absolute: Every 2 Months on the 1, 8 for the entire day + // Relative: Every 2 Months on the First Sunday, Saturday for the entire day + // Absolute: Every 2 Months on the 1, 8 for the entire day until December 31, 2024 + // Relative: Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024 + // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) + // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) + // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024 + // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024 + return match ([($interval > 1), $startTime !== null, $conclusion !== null]) { + [false, false, false] => $this->l10n->t('Every Month on the %1$s for the entire day', [$days]), + [false, false, true] => $this->l10n->t('Every Month on the %1$s for the entire day until %2$s', [$days, $conclusion]), + [false, true, false] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s', [$days, $startTime, $endTime]), + [false, true, true] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]), + [true, false, false] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day', [$interval, $days]), + [true, false, true] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day until %3$s', [$interval, $days, $conclusion]), + [true, true, false] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]), + [true, true, true] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]), + default => $this->l10n->t('Could not generate event recurrence statement') + }; + } + + /** + * generates a when string for a yearly precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurringYearly(EventReader $er): string { + + // initialize + $interval = (int)$er->recurringInterval(); + $startTime = null; + $conclusion = null; + // months of year + $months = implode(', ', array_map(function ($value) { return $this->localizeMonthName($value); }, $er->recurringMonthsOfYearNamed())); + // days of month + if ($er->recurringPattern() === 'R') { + $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' ' + . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed())); + } else { + $days = $er->startDateTime()->format('jS'); + } + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // conclusion + if ($er->recurringConcludes()) { + $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']); + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order, output varies depending on if the event is absolute or releative: + // Absolute: Every Year in July on the 1st for the entire day + // Relative: Every Year in July on the First Sunday, Saturday for the entire day + // Absolute: Every Year in July on the 1st for the entire day until July 31, 2026 + // Relative: Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026 + // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) + // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) + // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026 + // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026 + // Absolute: Every 2 Years in July on the 1st for the entire day + // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day + // Absolute: Every 2 Years in July on the 1st for the entire day until July 31, 2026 + // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026 + // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) + // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) + // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026 + // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026 + return match ([($interval > 1), $startTime !== null, $conclusion !== null]) { + [false, false, false] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day', [$months, $days]), + [false, false, true] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day until %3$s', [$months, $days, $conclusion]), + [false, true, false] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s', [$months, $days, $startTime, $endTime]), + [false, true, true] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s until %5$s', [$months, $days, $startTime, $endTime, $conclusion]), + [true, false, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day', [$interval, $months, $days]), + [true, false, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day until %4$s', [$interval, $months, $days, $conclusion]), + [true, true, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s', [$interval, $months, $days, $startTime, $endTime]), + [true, true, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s until %6$s', [$interval, $months, $days, $startTime, $endTime, $conclusion]), + default => $this->l10n->t('Could not generate event recurrence statement') + }; + } + + /** + * generates a when string for a fixed precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurringFixed(EventReader $er): string { + // initialize + $startTime = null; + $conclusion = null; + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // conclusion + $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']); + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order: + // On specific dates for the entire day until July 13, 2024 + // On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024 + return match ($startTime !== null) { + false => $this->l10n->t('On specific dates for the entire day until %1$s', [$conclusion]), + true => $this->l10n->t('On specific dates between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]), + }; + } + + /** + * generates a occurring next string for a recurring event + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateOccurringString(EventReader $er): string { + + // initialize + $occurrence = null; + $occurrence2 = null; + $occurrence3 = null; + // reset to initial occurrence + $er->recurrenceRewind(); + // forward to current date + $er->recurrenceAdvanceTo($this->timeFactory->getDateTime()); + // calculate time difference from now to start of next event occurrence and minimize it + $occurrenceIn = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate())); + // store next occurrence value + $occurrence = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']); + // forward one occurrence + $er->recurrenceAdvance(); + // evaluate if occurrence is valid + if ($er->recurrenceDate() !== null) { + // store following occurrence value + $occurrence2 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']); + // forward one occurrence + $er->recurrenceAdvance(); + // evaluate if occurrence is valid + if ($er->recurrenceDate()) { + // store following occurrence value + $occurrence3 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']); + } + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order: + // In a minute/hour/day/week/month/year on July 1, 2024 + // In a minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024 + // In a minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024 and July 5, 2024 + // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 + // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024 + // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024 and July 5, 2024 + return match ([$occurrenceIn['scale'], $occurrence2 !== null, $occurrence3 !== null]) { + ['past', false, false] => $this->l10n->t( + 'In the past on %1$s', + [$occurrence] + ), + ['minute', false, false] => $this->l10n->n( + 'In a minute on %1$s', + 'In %n minutes on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['hour', false, false] => $this->l10n->n( + 'In a hour on %1$s', + 'In %n hours on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['day', false, false] => $this->l10n->n( + 'In a day on %1$s', + 'In %n days on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['week', false, false] => $this->l10n->n( + 'In a week on %1$s', + 'In %n weeks on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['month', false, false] => $this->l10n->n( + 'In a month on %1$s', + 'In %n months on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['year', false, false] => $this->l10n->n( + 'In a year on %1$s', + 'In %n years on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['past', true, false] => $this->l10n->t( + 'In the past on %1$s then on %2$s', + [$occurrence, $occurrence2] + ), + ['minute', true, false] => $this->l10n->n( + 'In a minute on %1$s then on %2$s', + 'In %n minutes on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['hour', true, false] => $this->l10n->n( + 'In a hour on %1$s then on %2$s', + 'In %n hours on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['day', true, false] => $this->l10n->n( + 'In a day on %1$s then on %2$s', + 'In %n days on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['week', true, false] => $this->l10n->n( + 'In a week on %1$s then on %2$s', + 'In %n weeks on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['month', true, false] => $this->l10n->n( + 'In a month on %1$s then on %2$s', + 'In %n months on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['year', true, false] => $this->l10n->n( + 'In a year on %1$s then on %2$s', + 'In %n years on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['past', true, true] => $this->l10n->t( + 'In the past on %1$s then on %2$s and %3$s', + [$occurrence, $occurrence2, $occurrence3] + ), + ['minute', true, true] => $this->l10n->n( + 'In a minute on %1$s then on %2$s and %3$s', + 'In %n minutes on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + ['hour', true, true] => $this->l10n->n( + 'In a hour on %1$s then on %2$s and %3$s', + 'In %n hours on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + ['day', true, true] => $this->l10n->n( + 'In a day on %1$s then on %2$s and %3$s', + 'In %n days on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + ['week', true, true] => $this->l10n->n( + 'In a week on %1$s then on %2$s and %3$s', + 'In %n weeks on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + ['month', true, true] => $this->l10n->n( + 'In a month on %1$s then on %2$s and %3$s', + 'In %n months on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + ['year', true, true] => $this->l10n->n( + 'In a year on %1$s then on %2$s and %3$s', + 'In %n years on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + default => $this->l10n->t('Could not generate next recurrence statement') + }; + + } + + /** + * @param VEvent $vEvent + * @return array + */ + public function buildCancelledBodyData(VEvent $vEvent): array { + // construct event reader + $eventReaderCurrent = new EventReader($vEvent); + $defaultVal = ''; + $strikethrough = "<span style='text-decoration: line-through'>%s</span>"; + + $newMeetingWhen = $this->generateWhenString($eventReaderCurrent); + $newSummary = isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event'); + $newDescription = isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal; + $newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? sprintf('<a href="%1$s">%1$s</a>', $vEvent->URL) : $defaultVal; + $newLocation = isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal; + $newLocationHtml = $this->linkify($newLocation) ?? $newLocation; + + $data = []; + $data['meeting_when_html'] = $newMeetingWhen === '' ?: sprintf($strikethrough, $newMeetingWhen); + $data['meeting_when'] = $newMeetingWhen; + $data['meeting_title_html'] = sprintf($strikethrough, $newSummary); + $data['meeting_title'] = $newSummary !== '' ? $newSummary: $this->l10n->t('Untitled event'); + $data['meeting_description_html'] = $newDescription !== '' ? sprintf($strikethrough, $newDescription) : ''; + $data['meeting_description'] = $newDescription; + $data['meeting_url_html'] = $newUrl !== '' ? sprintf($strikethrough, $newUrl) : ''; + $data['meeting_url'] = isset($vEvent->URL) ? (string)$vEvent->URL : ''; + $data['meeting_location_html'] = $newLocationHtml !== '' ? sprintf($strikethrough, $newLocationHtml) : ''; + $data['meeting_location'] = $newLocation; + return $data; + } + + /** + * Check if event took place in the past + * + * @param VCalendar $vObject + * @return int + */ + public function getLastOccurrence(VCalendar $vObject) { + /** @var VEvent $component */ + $component = $vObject->VEVENT; + + if (isset($component->RRULE)) { + $it = new EventIterator($vObject, (string)$component->UID); + $maxDate = new \DateTime(IMipPlugin::MAX_DATE); + if ($it->isInfinite()) { + return $maxDate->getTimestamp(); + } + + $end = $it->getDtEnd(); + while ($it->valid() && $end < $maxDate) { + $end = $it->getDtEnd(); + $it->next(); + } + return $end->getTimestamp(); + } + + /** @var Property\ICalendar\DateTime $dtStart */ + $dtStart = $component->DTSTART; + + if (isset($component->DTEND)) { + /** @var Property\ICalendar\DateTime $dtEnd */ + $dtEnd = $component->DTEND; + return $dtEnd->getDateTime()->getTimeStamp(); + } + + if (isset($component->DURATION)) { + /** @var \DateTime $endDate */ + $endDate = clone $dtStart->getDateTime(); + // $component->DTEND->getDateTime() returns DateTimeImmutable + $endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue())); + return $endDate->getTimestamp(); + } + + if (!$dtStart->hasTime()) { + /** @var \DateTime $endDate */ + // $component->DTSTART->getDateTime() returns DateTimeImmutable + $endDate = clone $dtStart->getDateTime(); + $endDate = $endDate->modify('+1 day'); + return $endDate->getTimestamp(); + } + + // No computation of end time possible - return start date + return $dtStart->getDateTime()->getTimeStamp(); + } + + /** + * @param Property|null $attendee + */ + public function setL10n(?Property $attendee = null) { + if ($attendee === null) { + return; + } + + $lang = $attendee->offsetGet('LANGUAGE'); + if ($lang instanceof Parameter) { + $lang = $lang->getValue(); + $this->l10n = $this->l10nFactory->get('dav', $lang); + } + } + + /** + * @param Property|null $attendee + * @return bool + */ + public function getAttendeeRsvpOrReqForParticipant(?Property $attendee = null) { + if ($attendee === null) { + return false; + } + + $rsvp = $attendee->offsetGet('RSVP'); + if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { + return true; + } + $role = $attendee->offsetGet('ROLE'); + // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16 + // Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set + if ($role === null + || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0)) + || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0)) + ) { + return true; + } + + // RFC 5545 3.2.17: default RSVP is false + return false; + } + + /** + * @param IEMailTemplate $template + * @param string $method + * @param string $sender + * @param string $summary + * @param string|null $partstat + * @param bool $isModified + */ + public function addSubjectAndHeading(IEMailTemplate $template, + string $method, string $sender, string $summary, bool $isModified, ?Property $replyingAttendee = null): void { + if ($method === IMipPlugin::METHOD_CANCEL) { + // TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}" + $template->setSubject($this->l10n->t('Cancelled: %1$s', [$summary])); + $template->addHeading($this->l10n->t('"%1$s" has been canceled', [$summary])); + } elseif ($method === IMipPlugin::METHOD_REPLY) { + // TRANSLATORS Subject for email, when an invitation is replied to. Ex: "Re: {{Event Name}}" + $template->setSubject($this->l10n->t('Re: %1$s', [$summary])); + // Build the strings + $partstat = (isset($replyingAttendee)) ? $replyingAttendee->offsetGet('PARTSTAT') : null; + $partstat = ($partstat instanceof Parameter) ? $partstat->getValue() : null; + switch ($partstat) { + case 'ACCEPTED': + $template->addHeading($this->l10n->t('%1$s has accepted your invitation', [$sender])); + break; + case 'TENTATIVE': + $template->addHeading($this->l10n->t('%1$s has tentatively accepted your invitation', [$sender])); + break; + case 'DECLINED': + $template->addHeading($this->l10n->t('%1$s has declined your invitation', [$sender])); + break; + case null: + default: + $template->addHeading($this->l10n->t('%1$s has responded to your invitation', [$sender])); + break; + } + } elseif ($method === IMipPlugin::METHOD_REQUEST && $isModified) { + // TRANSLATORS Subject for email, when an invitation is updated. Ex: "Invitation updated: {{Event Name}}" + $template->setSubject($this->l10n->t('Invitation updated: %1$s', [$summary])); + $template->addHeading($this->l10n->t('%1$s updated the event "%2$s"', [$sender, $summary])); + } else { + // TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}" + $template->setSubject($this->l10n->t('Invitation: %1$s', [$summary])); + $template->addHeading($this->l10n->t('%1$s would like to invite you to "%2$s"', [$sender, $summary])); + } + } + + /** + * @param string $path + * @return string + */ + public function getAbsoluteImagePath($path): string { + return $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath('core', $path) + ); + } + + /** + * addAttendees: add organizer and attendee names/emails to iMip mail. + * + * Enable with DAV setting: invitation_list_attendees (default: no) + * + * The default is 'no', which matches old behavior, and is privacy preserving. + * + * To enable including attendees in invitation emails: + * % php occ config:app:set dav invitation_list_attendees --value yes + * + * @param IEMailTemplate $template + * @param IL10N $this->l10n + * @param VEvent $vevent + * @author brad2014 on github.com + */ + public function addAttendees(IEMailTemplate $template, VEvent $vevent) { + if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') { + return; + } + + if (isset($vevent->ORGANIZER)) { + /** @var Property | Property\ICalendar\CalAddress $organizer */ + $organizer = $vevent->ORGANIZER; + $organizerEmail = substr($organizer->getNormalizedValue(), 7); + /** @var string|null $organizerName */ + $organizerName = isset($organizer->CN) ? $organizer->CN->getValue() : null; + $organizerHTML = sprintf('<a href="%s">%s</a>', + htmlspecialchars($organizer->getNormalizedValue()), + htmlspecialchars($organizerName ?: $organizerEmail)); + $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail); + if (isset($organizer['PARTSTAT'])) { + /** @var Parameter $partstat */ + $partstat = $organizer['PARTSTAT']; + if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { + $organizerHTML .= ' ✔︎'; + $organizerText .= ' ✔︎'; + } + } + $template->addBodyListItem($organizerHTML, $this->l10n->t('Organizer:'), + $this->getAbsoluteImagePath('caldav/organizer.png'), + $organizerText, '', IMipPlugin::IMIP_INDENT); + } + + $attendees = $vevent->select('ATTENDEE'); + if (count($attendees) === 0) { + return; + } + + $attendeesHTML = []; + $attendeesText = []; + foreach ($attendees as $attendee) { + $attendeeEmail = substr($attendee->getNormalizedValue(), 7); + $attendeeName = isset($attendee['CN']) ? $attendee['CN']->getValue() : null; + $attendeeHTML = sprintf('<a href="%s">%s</a>', + htmlspecialchars($attendee->getNormalizedValue()), + htmlspecialchars($attendeeName ?: $attendeeEmail)); + $attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail); + if (isset($attendee['PARTSTAT'])) { + /** @var Parameter $partstat */ + $partstat = $attendee['PARTSTAT']; + if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { + $attendeeHTML .= ' ✔︎'; + $attendeeText .= ' ✔︎'; + } + } + $attendeesHTML[] = $attendeeHTML; + $attendeesText[] = $attendeeText; + } + + $template->addBodyListItem(implode('<br/>', $attendeesHTML), $this->l10n->t('Attendees:'), + $this->getAbsoluteImagePath('caldav/attendees.png'), + implode("\n", $attendeesText), '', IMipPlugin::IMIP_INDENT); + } + + /** + * @param IEMailTemplate $template + * @param VEVENT $vevent + * @param $data + */ + public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) { + $template->addBodyListItem( + $data['meeting_title_html'] ?? $data['meeting_title'], $this->l10n->t('Title:'), + $this->getAbsoluteImagePath('caldav/title.png'), $data['meeting_title'], '', IMipPlugin::IMIP_INDENT); + if ($data['meeting_when'] !== '') { + $template->addBodyListItem($data['meeting_when_html'] ?? $data['meeting_when'], $this->l10n->t('When:'), + $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_when'], '', IMipPlugin::IMIP_INDENT); + } + if ($data['meeting_location'] !== '') { + $template->addBodyListItem($data['meeting_location_html'] ?? $data['meeting_location'], $this->l10n->t('Location:'), + $this->getAbsoluteImagePath('caldav/location.png'), $data['meeting_location'], '', IMipPlugin::IMIP_INDENT); + } + if ($data['meeting_url'] !== '') { + $template->addBodyListItem($data['meeting_url_html'] ?? $data['meeting_url'], $this->l10n->t('Link:'), + $this->getAbsoluteImagePath('caldav/link.png'), $data['meeting_url'], '', IMipPlugin::IMIP_INDENT); + } + if (isset($data['meeting_occurring'])) { + $template->addBodyListItem($data['meeting_occurring_html'] ?? $data['meeting_occurring'], $this->l10n->t('Occurring:'), + $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_occurring'], '', IMipPlugin::IMIP_INDENT); + } + + $this->addAttendees($template, $vevent); + + /* Put description last, like an email body, since it can be arbitrarily long */ + if ($data['meeting_description']) { + $template->addBodyListItem($data['meeting_description_html'] ?? $data['meeting_description'], $this->l10n->t('Description:'), + $this->getAbsoluteImagePath('caldav/description.png'), $data['meeting_description'], '', IMipPlugin::IMIP_INDENT); + } + } + + /** + * @param Message $iTipMessage + * @return null|Property + */ + public function getCurrentAttendee(Message $iTipMessage): ?Property { + /** @var VEvent $vevent */ + $vevent = $iTipMessage->message->VEVENT; + $attendees = $vevent->select('ATTENDEE'); + foreach ($attendees as $attendee) { + if ($iTipMessage->method === 'REPLY' && strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) { + /** @var Property $attendee */ + return $attendee; + } elseif (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) { + /** @var Property $attendee */ + return $attendee; + } + } + return null; + } + + /** + * @param Message $iTipMessage + * @param VEvent $vevent + * @param int $lastOccurrence + * @return string + */ + public function createInvitationToken(Message $iTipMessage, VEvent $vevent, int $lastOccurrence): string { + $token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC); + + $attendee = $iTipMessage->recipient; + $organizer = $iTipMessage->sender; + $sequence = $iTipMessage->sequence; + $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) + ? $vevent->{'RECURRENCE-ID'}->serialize() : null; + $uid = $vevent->{'UID'}?->getValue(); + + $query = $this->db->getQueryBuilder(); + $query->insert('calendar_invitations') + ->values([ + 'token' => $query->createNamedParameter($token), + 'attendee' => $query->createNamedParameter($attendee), + 'organizer' => $query->createNamedParameter($organizer), + 'sequence' => $query->createNamedParameter($sequence), + 'recurrenceid' => $query->createNamedParameter($recurrenceId), + 'expiration' => $query->createNamedParameter($lastOccurrence), + 'uid' => $query->createNamedParameter($uid) + ]) + ->executeStatement(); + + return $token; + } + + /** + * @param IEMailTemplate $template + * @param $token + */ + public function addResponseButtons(IEMailTemplate $template, $token) { + $template->addBodyButtonGroup( + $this->l10n->t('Accept'), + $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [ + 'token' => $token, + ]), + $this->l10n->t('Decline'), + $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [ + 'token' => $token, + ]) + ); + } + + public function addMoreOptionsButton(IEMailTemplate $template, $token) { + $moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [ + 'token' => $token, + ]); + $html = vsprintf('<small><a href="%s">%s</a></small>', [ + $moreOptionsURL, $this->l10n->t('More options …') + ]); + $text = $this->l10n->t('More options at %s', [$moreOptionsURL]); + + $template->addBodyText($html, $text); + } + + public function getReplyingAttendee(Message $iTipMessage): ?Property { + /** @var VEvent $vevent */ + $vevent = $iTipMessage->message->VEVENT; + $attendees = $vevent->select('ATTENDEE'); + foreach ($attendees as $attendee) { + /** @var Property $attendee */ + if (strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) { + return $attendee; + } + } + return null; + } + + public function isRoomOrResource(Property $attendee): bool { + $cuType = $attendee->offsetGet('CUTYPE'); + if (!$cuType instanceof Parameter) { + return false; + } + $type = $cuType->getValue() ?? 'INDIVIDUAL'; + if (\in_array(strtoupper($type), ['RESOURCE', 'ROOM'], true)) { + // Don't send emails to things + return true; + } + return false; + } + + public function isCircle(Property $attendee): bool { + $cuType = $attendee->offsetGet('CUTYPE'); + if (!$cuType instanceof Parameter) { + return false; + } + + $uri = $attendee->getValue(); + if (!$uri) { + return false; + } + + $cuTypeValue = $cuType->getValue(); + return $cuTypeValue === 'GROUP' && str_starts_with($uri, 'mailto:circle+'); + } + + public function minimizeInterval(\DateInterval $dateInterval): array { + // evaluate if time interval is in the past + if ($dateInterval->invert == 1) { + return ['interval' => 1, 'scale' => 'past']; + } + // evaluate interval parts and return smallest time period + if ($dateInterval->y > 0) { + $interval = $dateInterval->y; + $scale = 'year'; + } elseif ($dateInterval->m > 0) { + $interval = $dateInterval->m; + $scale = 'month'; + } elseif ($dateInterval->d >= 7) { + $interval = (int)($dateInterval->d / 7); + $scale = 'week'; + } elseif ($dateInterval->d > 0) { + $interval = $dateInterval->d; + $scale = 'day'; + } elseif ($dateInterval->h > 0) { + $interval = $dateInterval->h; + $scale = 'hour'; + } else { + $interval = $dateInterval->i; + $scale = 'minute'; + } + + return ['interval' => $interval, 'scale' => $scale]; + } + + /** + * Localizes week day names to another language + * + * @param string $value + * + * @return string + */ + public function localizeDayName(string $value): string { + return match ($value) { + 'Monday' => $this->l10n->t('Monday'), + 'Tuesday' => $this->l10n->t('Tuesday'), + 'Wednesday' => $this->l10n->t('Wednesday'), + 'Thursday' => $this->l10n->t('Thursday'), + 'Friday' => $this->l10n->t('Friday'), + 'Saturday' => $this->l10n->t('Saturday'), + 'Sunday' => $this->l10n->t('Sunday'), + }; + } + + /** + * Localizes month names to another language + * + * @param string $value + * + * @return string + */ + public function localizeMonthName(string $value): string { + return match ($value) { + 'January' => $this->l10n->t('January'), + 'February' => $this->l10n->t('February'), + 'March' => $this->l10n->t('March'), + 'April' => $this->l10n->t('April'), + 'May' => $this->l10n->t('May'), + 'June' => $this->l10n->t('June'), + 'July' => $this->l10n->t('July'), + 'August' => $this->l10n->t('August'), + 'September' => $this->l10n->t('September'), + 'October' => $this->l10n->t('October'), + 'November' => $this->l10n->t('November'), + 'December' => $this->l10n->t('December'), + }; + } + + /** + * Localizes relative position names to another language + * + * @param string $value + * + * @return string + */ + public function localizeRelativePositionName(string $value): string { + return match ($value) { + 'First' => $this->l10n->t('First'), + 'Second' => $this->l10n->t('Second'), + 'Third' => $this->l10n->t('Third'), + 'Fourth' => $this->l10n->t('Fourth'), + 'Fifth' => $this->l10n->t('Fifth'), + 'Last' => $this->l10n->t('Last'), + 'Second Last' => $this->l10n->t('Second Last'), + 'Third Last' => $this->l10n->t('Third Last'), + 'Fourth Last' => $this->l10n->t('Fourth Last'), + 'Fifth Last' => $this->l10n->t('Fifth Last'), + }; + } +} diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php index 96bacce4454..a001df8b2a8 100644 --- a/apps/dav/lib/CalDAV/Schedule/Plugin.php +++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php @@ -1,43 +1,30 @@ <?php + /** - * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl> - * @copyright Copyright (c) 2016, Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Schedule; use DateTimeZone; use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Calendar; use OCA\DAV\CalDAV\CalendarHome; +use OCA\DAV\CalDAV\CalendarObject; +use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\CalDAV\TipBroker; use OCP\IConfig; +use Psr\Log\LoggerInterface; use Sabre\CalDAV\ICalendar; +use Sabre\CalDAV\ICalendarObject; +use Sabre\CalDAV\Schedule\ISchedulingObject; +use Sabre\DAV\Exception as DavException; use Sabre\DAV\INode; use Sabre\DAV\IProperties; use Sabre\DAV\PropFind; use Sabre\DAV\Server; use Sabre\DAV\Xml\Property\LocalHref; +use Sabre\DAVACL\IACL; use Sabre\DAVACL\IPrincipal; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; @@ -47,18 +34,14 @@ use Sabre\VObject\Component\VEvent; use Sabre\VObject\DateTimeParser; use Sabre\VObject\FreeBusyGenerator; use Sabre\VObject\ITip; +use Sabre\VObject\ITip\SameOrganizerForAllComponentsException; use Sabre\VObject\Parameter; use Sabre\VObject\Property; use Sabre\VObject\Reader; -use function \Sabre\Uri\split; +use function Sabre\Uri\split; class Plugin extends \Sabre\CalDAV\Schedule\Plugin { - /** - * @var IConfig - */ - private $config; - /** @var ITip\Message[] */ private $schedulingResponses = []; @@ -71,8 +54,11 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { /** * @param IConfig $config */ - public function __construct(IConfig $config) { - $this->config = $config; + public function __construct( + private IConfig $config, + private LoggerInterface $logger, + private DefaultCalendarValidator $defaultCalendarValidator, + ) { } /** @@ -86,6 +72,20 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { $server->on('propFind', [$this, 'propFindDefaultCalendarUrl'], 90); $server->on('afterWriteContent', [$this, 'dispatchSchedulingResponses']); $server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']); + + // We allow mutating the default calendar URL through the CustomPropertiesBackend + // (oc_properties table) + $server->protectedProperties = array_filter( + $server->protectedProperties, + static fn (string $property) => $property !== self::SCHEDULE_DEFAULT_CALENDAR_URL, + ); + } + + /** + * Returns an instance of the iTip\Broker. + */ + protected function createITipBroker(): TipBroker { + return new TipBroker(); } /** @@ -139,6 +139,11 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { $result = []; } + // iterate through items and html decode values + foreach ($result as $key => $value) { + $result[$key] = urldecode($value); + } + return $result; } @@ -156,20 +161,91 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { $this->pathOfCalendarObjectChange = $request->getPath(); } - parent::calendarObjectChange($request, $response, $vCal, $calendarPath, $modified, $isNew); + try { + + // Do not generate iTip and iMip messages if scheduling is disabled for this message + if ($request->getHeader('x-nc-scheduling') === 'false') { + return; + } + + if (!$this->scheduleReply($this->server->httpRequest)) { + return; + } + + /** @var Calendar $calendarNode */ + $calendarNode = $this->server->tree->getNodeForPath($calendarPath); + // extract addresses for owner + $addresses = $this->getAddressesForPrincipal($calendarNode->getOwner()); + // determine if request is from a sharee + if ($calendarNode->isShared()) { + // extract addresses for sharee and add to address collection + $addresses = array_merge( + $addresses, + $this->getAddressesForPrincipal($calendarNode->getPrincipalURI()) + ); + } + // determine if we are updating a calendar event + if (!$isNew) { + // retrieve current calendar event node + /** @var CalendarObject $currentNode */ + $currentNode = $this->server->tree->getNodeForPath($request->getPath()); + // convert calendar event string data to VCalendar object + /** @var \Sabre\VObject\Component\VCalendar $currentObject */ + $currentObject = Reader::read($currentNode->get()); + } else { + $currentObject = null; + } + // process request + $this->processICalendarChange($currentObject, $vCal, $addresses, [], $modified); + + if ($currentObject) { + // Destroy circular references so PHP will GC the object. + $currentObject->destroy(); + } + + } catch (SameOrganizerForAllComponentsException $e) { + $this->handleSameOrganizerException($e, $vCal, $calendarPath); + } + } + + /** + * @inheritDoc + */ + public function beforeUnbind($path): void { + try { + parent::beforeUnbind($path); + } catch (SameOrganizerForAllComponentsException $e) { + $node = $this->server->tree->getNodeForPath($path); + if (!$node instanceof ICalendarObject || $node instanceof ISchedulingObject) { + throw $e; + } + + /** @var VCalendar $vCal */ + $vCal = Reader::read($node->get()); + $this->handleSameOrganizerException($e, $vCal, $path); + } } /** * @inheritDoc */ public function scheduleLocalDelivery(ITip\Message $iTipMessage):void { - parent::scheduleLocalDelivery($iTipMessage); + /** @var VEvent|null $vevent */ + $vevent = $iTipMessage->message->VEVENT ?? null; + + // Strip VALARMs from incoming VEVENT + if ($vevent && isset($vevent->VALARM)) { + $vevent->remove('VALARM'); + } + parent::scheduleLocalDelivery($iTipMessage); // We only care when the message was successfully delivered locally + // Log all possible codes returned from the parent method that mean something went wrong + // 3.7, 3.8, 5.0, 5.2 if ($iTipMessage->scheduleStatus !== '1.2;Message delivered locally') { + $this->logger->debug('Message not delivered locally with status: ' . $iTipMessage->scheduleStatus); return; } - // We only care about request. reply and cancel are properly handled // by parent::scheduleLocalDelivery already if (strcasecmp($iTipMessage->method, 'REQUEST') !== 0) { @@ -178,41 +254,38 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { // If parent::scheduleLocalDelivery set scheduleStatus to 1.2, // it means that it was successfully delivered locally. - // Meaning that the ACL plugin is loaded and that a principial + // Meaning that the ACL plugin is loaded and that a principal // exists for the given recipient id, no need to double check /** @var \Sabre\DAVACL\Plugin $aclPlugin */ $aclPlugin = $this->server->getPlugin('acl'); $principalUri = $aclPlugin->getPrincipalByUri($iTipMessage->recipient); $calendarUserType = $this->getCalendarUserTypeForPrincipal($principalUri); if (strcasecmp($calendarUserType, 'ROOM') !== 0 && strcasecmp($calendarUserType, 'RESOURCE') !== 0) { + $this->logger->debug('Calendar user type is neither room nor resource, not processing further'); return; } $attendee = $this->getCurrentAttendee($iTipMessage); if (!$attendee) { + $this->logger->debug('No attendee set for scheduling message'); return; } // We only respond when a response was actually requested $rsvp = $this->getAttendeeRSVP($attendee); if (!$rsvp) { + $this->logger->debug('No RSVP requested for attendee ' . $attendee->getValue()); return; } - if (!isset($iTipMessage->message)) { - return; - } - - $vcalendar = $iTipMessage->message; - if (!isset($vcalendar->VEVENT)) { + if (!$vevent) { + $this->logger->debug('No VEVENT set to process on scheduling message'); return; } - /** @var Component $vevent */ - $vevent = $vcalendar->VEVENT; - // We don't support autoresponses for recurrencing events for now if (isset($vevent->RRULE) || isset($vevent->RDATE)) { + $this->logger->debug('VEVENT is a recurring event, autoresponding not supported'); return; } @@ -299,12 +372,14 @@ EOF; return null; } - if (strpos($principalUrl, 'principals/users') === 0) { + $isResourceOrRoom = str_starts_with($principalUrl, 'principals/calendar-resources') + || str_starts_with($principalUrl, 'principals/calendar-rooms'); + + if (str_starts_with($principalUrl, 'principals/users')) { [, $userId] = split($principalUrl); $uri = $this->config->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI); $displayName = CalDavBackend::PERSONAL_CALENDAR_NAME; - } elseif (strpos($principalUrl, 'principals/calendar-resources') === 0 || - strpos($principalUrl, 'principals/calendar-rooms') === 0) { + } elseif ($isResourceOrRoom) { $uri = CalDavBackend::RESOURCE_BOOKING_CALENDAR_URI; $displayName = CalDavBackend::RESOURCE_BOOKING_CALENDAR_NAME; } else { @@ -315,10 +390,65 @@ EOF; /** @var CalendarHome $calendarHome */ $calendarHome = $this->server->tree->getNodeForPath($calendarHomePath); - if (!$calendarHome->childExists($uri)) { - $calendarHome->getCalDAVBackend()->createCalendar($principalUrl, $uri, [ - '{DAV:}displayname' => $displayName, - ]); + $currentCalendarDeleted = false; + if (!$calendarHome->childExists($uri) || $currentCalendarDeleted = $this->isCalendarDeleted($calendarHome, $uri)) { + // If the default calendar doesn't exist + if ($isResourceOrRoom) { + // Resources or rooms can't be in the trashbin, so we're fine + $this->createCalendar($calendarHome, $principalUrl, $uri, $displayName); + } else { + // And we're not handling scheduling on resource/room booking + $userCalendars = []; + /** + * If the default calendar of the user isn't set and the + * fallback doesn't match any of the user's calendar + * try to find the first "personal" calendar we can write to + * instead of creating a new one. + * A appropriate personal calendar to receive invites: + * - isn't a calendar subscription + * - user can write to it (no virtual/3rd-party calendars) + * - calendar isn't a share + * - calendar supports VEVENTs + */ + foreach ($calendarHome->getChildren() as $node) { + if (!($node instanceof Calendar)) { + continue; + } + + try { + $this->defaultCalendarValidator->validateScheduleDefaultCalendar($node); + } catch (DavException $e) { + continue; + } + + $userCalendars[] = $node; + } + + if (count($userCalendars) > 0) { + // Calendar backend returns calendar by calendarorder property + $uri = $userCalendars[0]->getName(); + } else { + // Otherwise if we have really nothing, create a new calendar + if ($currentCalendarDeleted) { + // If the calendar exists but is in the trash bin, we try to rename its uri + // so that we can create the new one and still restore the previous one + // otherwise we just purge the calendar by removing it before recreating it + $calendar = $this->getCalendar($calendarHome, $uri); + if ($calendar instanceof Calendar) { + $backend = $calendarHome->getCalDAVBackend(); + if ($backend instanceof CalDavBackend) { + // If the CalDAV backend supports moving calendars + $this->moveCalendar($backend, $principalUrl, $uri, $uri . '-back-' . time()); + } else { + // Otherwise just purge the calendar + $calendar->disableTrashbin(); + $calendar->delete(); + } + } + } + $this->createCalendar($calendarHome, $principalUrl, $uri, $displayName); + } + } } $result = $this->server->getPropertiesForPath($calendarHomePath . '/' . $uri, [], 1); @@ -373,7 +503,7 @@ EOF; * @param Property|null $attendee * @return bool */ - private function getAttendeeRSVP(Property $attendee = null):bool { + private function getAttendeeRSVP(?Property $attendee = null):bool { if ($attendee !== null) { $rsvp = $attendee->offsetGet('RSVP'); if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { @@ -448,7 +578,9 @@ EOF; $calendarTimeZone = new DateTimeZone('UTC'); $homePath = $result[0][200]['{' . self::NS_CALDAV . '}calendar-home-set']->getHref(); + /** @var Calendar $node */ foreach ($this->server->tree->getNodeForPath($homePath)->getChildren() as $node) { + if (!$node instanceof ICalendar) { continue; } @@ -533,7 +665,7 @@ EOF; } // If more than one Free-Busy property was returned, it means that an event - // starts or ends inside this time-range, so it's not availabe and we return false + // starts or ends inside this time-range, so it's not available and we return false if (count($freeBusyProperties) > 1) { return false; } @@ -564,4 +696,63 @@ EOF; return $email; } + + private function getCalendar(CalendarHome $calendarHome, string $uri): INode { + return $calendarHome->getChild($uri); + } + + private function isCalendarDeleted(CalendarHome $calendarHome, string $uri): bool { + $calendar = $this->getCalendar($calendarHome, $uri); + return $calendar instanceof Calendar && $calendar->isDeleted(); + } + + private function createCalendar(CalendarHome $calendarHome, string $principalUri, string $uri, string $displayName): void { + $calendarHome->getCalDAVBackend()->createCalendar($principalUri, $uri, [ + '{DAV:}displayname' => $displayName, + ]); + } + + private function moveCalendar(CalDavBackend $calDavBackend, string $principalUri, string $oldUri, string $newUri): void { + $calDavBackend->moveCalendar($oldUri, $principalUri, $principalUri, $newUri); + } + + /** + * Try to handle the given exception gracefully or throw it if necessary. + * + * @throws SameOrganizerForAllComponentsException If the exception should not be ignored + */ + private function handleSameOrganizerException( + SameOrganizerForAllComponentsException $e, + VCalendar $vCal, + string $calendarPath, + ): void { + // This is very hacky! However, we want to allow saving events with multiple + // organizers. Those events are not RFC compliant, but sometimes imported from major + // external calendar services (e.g. Google). If the current user is not an organizer of + // the event we ignore the exception as no scheduling messages will be sent anyway. + + // It would be cleaner to patch Sabre to validate organizers *after* checking if + // scheduling messages are necessary. Currently, organizers are validated first and + // afterwards the broker checks if messages should be scheduled. So the code will throw + // even if the organizers are not relevant. This is to ensure compliance with RFCs but + // a bit too strict for real world usage. + + if (!isset($vCal->VEVENT)) { + throw $e; + } + + $calendarNode = $this->server->tree->getNodeForPath($calendarPath); + if (!($calendarNode instanceof IACL)) { + // Should always be an instance of IACL but just to be sure + throw $e; + } + + $addresses = $this->getAddressesForPrincipal($calendarNode->getOwner()); + foreach ($vCal->VEVENT as $vevent) { + if (in_array($vevent->ORGANIZER->getNormalizedValue(), $addresses, true)) { + // User is an organizer => throw the exception + throw $e; + } + } + } } diff --git a/apps/dav/lib/CalDAV/Search/SearchPlugin.php b/apps/dav/lib/CalDAV/Search/SearchPlugin.php index d08a5749ab2..27e39a76305 100644 --- a/apps/dav/lib/CalDAV/Search/SearchPlugin.php +++ b/apps/dav/lib/CalDAV/Search/SearchPlugin.php @@ -1,33 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search; use OCA\DAV\CalDAV\CalendarHome; use OCA\DAV\CalDAV\Search\Xml\Request\CalendarSearchReport; +use OCP\AppFramework\Http; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; @@ -81,8 +62,8 @@ class SearchPlugin extends ServerPlugin { $server->on('report', [$this, 'report']); - $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search'] = - CalendarSearchReport::class; + $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search'] + = CalendarSearchReport::class; } /** @@ -129,7 +110,7 @@ class SearchPlugin extends ServerPlugin { * This report is used by clients to request calendar objects based on * complex conditions. * - * @param Xml\Request\CalendarSearchReport $report + * @param CalendarSearchReport $report * @return void */ private function calendarSearch($report) { @@ -154,7 +135,7 @@ class SearchPlugin extends ServerPlugin { $prefer = $this->server->getHTTPPrefer(); - $this->server->httpResponse->setStatus(207); + $this->server->httpResponse->setStatus(Http::STATUS_MULTI_STATUS); $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer'); diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php index d5b7c834e36..21a4fff1caf 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php index 2c435ba3650..a98b325397b 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php index a6f41d09161..ef438aa0258 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php index c25450a0c94..0c31f32348a 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php index 990b0ebf730..251120e35cc 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php index 06fe39a463b..6d6bf958496 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php index 98efe36ee43..6ece88fa87b 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Search\Xml\Request; diff --git a/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php b/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php new file mode 100644 index 00000000000..311157994e2 --- /dev/null +++ b/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\Security; + +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\Connector\Sabre\Exception\TooManyRequests; +use OCP\IAppConfig; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; +use Sabre\DAV; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ServerPlugin; +use function count; +use function explode; + +class RateLimitingPlugin extends ServerPlugin { + + private Limiter $limiter; + + public function __construct( + Limiter $limiter, + private IUserManager $userManager, + private CalDavBackend $calDavBackend, + private LoggerInterface $logger, + private IAppConfig $config, + private ?string $userId, + ) { + $this->limiter = $limiter; + } + + public function initialize(DAV\Server $server): void { + $server->on('beforeBind', [$this, 'beforeBind'], 1); + } + + public function beforeBind(string $path): void { + if ($this->userId === null) { + // We only care about authenticated users here + return; + } + $user = $this->userManager->get($this->userId); + if ($user === null) { + // We only care about authenticated users here + return; + } + + $pathParts = explode('/', $path); + if (count($pathParts) === 3 && $pathParts[0] === 'calendars') { + // Path looks like calendars/username/calendarname so a new calendar or subscription is created + try { + $this->limiter->registerUserRequest( + 'caldav-create-calendar', + $this->config->getValueInt('dav', 'rateLimitCalendarCreation', 10), + $this->config->getValueInt('dav', 'rateLimitPeriodCalendarCreation', 3600), + $user + ); + } catch (RateLimitExceededException $e) { + throw new TooManyRequests('Too many calendars created', 0, $e); + } + + $calendarLimit = $this->config->getValueInt('dav', 'maximumCalendarsSubscriptions', 30); + if ($calendarLimit === -1) { + return; + } + $numCalendars = $this->calDavBackend->getCalendarsForUserCount('principals/users/' . $user->getUID()); + $numSubscriptions = $this->calDavBackend->getSubscriptionsForUserCount('principals/users/' . $user->getUID()); + + if (($numCalendars + $numSubscriptions) >= $calendarLimit) { + $this->logger->warning('Maximum number of calendars/subscriptions reached', [ + 'calendars' => $numCalendars, + 'subscription' => $numSubscriptions, + 'limit' => $calendarLimit, + ]); + throw new Forbidden('Calendar limit reached', 0); + } + } + } + +} diff --git a/apps/dav/lib/CalDAV/Sharing/Backend.php b/apps/dav/lib/CalDAV/Sharing/Backend.php new file mode 100644 index 00000000000..fc5d65b5994 --- /dev/null +++ b/apps/dav/lib/CalDAV/Sharing/Backend.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\Sharing; + +use OCA\DAV\Connector\Sabre\Principal; +use OCA\DAV\DAV\Sharing\Backend as SharingBackend; +use OCP\ICacheFactory; +use OCP\IGroupManager; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class Backend extends SharingBackend { + + public function __construct( + private IUserManager $userManager, + private IGroupManager $groupManager, + private Principal $principalBackend, + private ICacheFactory $cacheFactory, + private Service $service, + private LoggerInterface $logger, + ) { + parent::__construct($this->userManager, $this->groupManager, $this->principalBackend, $this->cacheFactory, $this->service, $this->logger); + } +} diff --git a/apps/dav/lib/CalDAV/Sharing/Service.php b/apps/dav/lib/CalDAV/Sharing/Service.php new file mode 100644 index 00000000000..4f0554f09bd --- /dev/null +++ b/apps/dav/lib/CalDAV/Sharing/Service.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\Sharing; + +use OCA\DAV\DAV\Sharing\SharingMapper; +use OCA\DAV\DAV\Sharing\SharingService; + +class Service extends SharingService { + protected string $resourceType = 'calendar'; + public function __construct( + protected SharingMapper $mapper, + ) { + parent::__construct($mapper); + } +} diff --git a/apps/dav/lib/CalDAV/Status/StatusService.php b/apps/dav/lib/CalDAV/Status/StatusService.php new file mode 100644 index 00000000000..9ee0e9bf356 --- /dev/null +++ b/apps/dav/lib/CalDAV/Status/StatusService.php @@ -0,0 +1,186 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Status; + +use DateTimeImmutable; +use OC\Calendar\CalendarQuery; +use OCA\DAV\CalDAV\CalendarImpl; +use OCA\UserStatus\Service\StatusService as UserStatusService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Calendar\IManager; +use OCP\DB\Exception; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IUser as User; +use OCP\IUserManager; +use OCP\User\IAvailabilityCoordinator; +use OCP\UserStatus\IUserStatus; +use Psr\Log\LoggerInterface; +use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp; + +class StatusService { + private ICache $cache; + public function __construct( + private ITimeFactory $timeFactory, + private IManager $calendarManager, + private IUserManager $userManager, + private UserStatusService $userStatusService, + private IAvailabilityCoordinator $availabilityCoordinator, + private ICacheFactory $cacheFactory, + private LoggerInterface $logger, + ) { + $this->cache = $cacheFactory->createLocal('CalendarStatusService'); + } + + public function processCalendarStatus(string $userId): void { + $user = $this->userManager->get($userId); + if ($user === null) { + return; + } + + $availability = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user); + if ($availability !== null && $this->availabilityCoordinator->isInEffect($availability)) { + $this->logger->debug('An Absence is in effect, skipping calendar status check', ['user' => $userId]); + return; + } + + $calendarEvents = $this->cache->get($userId); + if ($calendarEvents === null) { + $calendarEvents = $this->getCalendarEvents($user); + $this->cache->set($userId, $calendarEvents, 300); + } + + if (empty($calendarEvents)) { + try { + $this->userStatusService->revertUserStatus($userId, IUserStatus::MESSAGE_CALENDAR_BUSY); + } catch (Exception $e) { + if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + // A different process might have written another status + // update to the DB while we're processing our stuff. + // We cannot safely restore the status as we don't know which one is valid at this point + // So let's silently log this one and exit + $this->logger->debug('Unique constraint violation for live user status', ['exception' => $e]); + return; + } + } + $this->logger->debug('No calendar events found for status check', ['user' => $userId]); + return; + } + + try { + $currentStatus = $this->userStatusService->findByUserId($userId); + // Was the status set by anything other than the calendar automation? + $userStatusTimestamp = $currentStatus->getIsUserDefined() && $currentStatus->getMessageId() !== IUserStatus::MESSAGE_CALENDAR_BUSY ? $currentStatus->getStatusTimestamp() : null; + } catch (DoesNotExistException) { + $userStatusTimestamp = null; + $currentStatus = null; + } + + if (($currentStatus !== null && $currentStatus->getMessageId() === IUserStatus::MESSAGE_CALL) + || ($currentStatus !== null && $currentStatus->getStatus() === IUserStatus::DND) + || ($currentStatus !== null && $currentStatus->getStatus() === IUserStatus::INVISIBLE)) { + // We don't overwrite the call status, DND status or Invisible status + $this->logger->debug('Higher priority status detected, skipping calendar status change', ['user' => $userId]); + return; + } + + // Filter events to see if we have any that apply to the calendar status + $applicableEvents = array_filter($calendarEvents, static function (array $calendarEvent) use ($userStatusTimestamp): bool { + if (empty($calendarEvent['objects'])) { + return false; + } + $component = $calendarEvent['objects'][0]; + if (isset($component['X-NEXTCLOUD-OUT-OF-OFFICE'])) { + return false; + } + if (isset($component['DTSTART']) && $userStatusTimestamp !== null) { + /** @var DateTimeImmutable $dateTime */ + $dateTime = $component['DTSTART'][0]; + if ($dateTime instanceof DateTimeImmutable && $userStatusTimestamp > $dateTime->getTimestamp()) { + return false; + } + } + // Ignore events that are transparent + if (isset($component['TRANSP']) && strcasecmp($component['TRANSP'][0], 'TRANSPARENT') === 0) { + return false; + } + return true; + }); + + if (empty($applicableEvents)) { + try { + $this->userStatusService->revertUserStatus($userId, IUserStatus::MESSAGE_CALENDAR_BUSY); + } catch (Exception $e) { + if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + // A different process might have written another status + // update to the DB while we're processing our stuff. + // We cannot safely restore the status as we don't know which one is valid at this point + // So let's silently log this one and exit + $this->logger->debug('Unique constraint violation for live user status', ['exception' => $e]); + return; + } + } + $this->logger->debug('No status relevant events found, skipping calendar status change', ['user' => $userId]); + return; + } + + // Only update the status if it's neccesary otherwise we mess up the timestamp + if ($currentStatus === null || $currentStatus->getMessageId() !== IUserStatus::MESSAGE_CALENDAR_BUSY) { + // One event that fulfills all status conditions is enough + // 1. Not an OOO event + // 2. Current user status (that is not a calendar status) was not set after the start of this event + // 3. Event is not set to be transparent + $count = count($applicableEvents); + $this->logger->debug("Found $count applicable event(s), changing user status", ['user' => $userId]); + $this->userStatusService->setUserStatus( + $userId, + IUserStatus::BUSY, + IUserStatus::MESSAGE_CALENDAR_BUSY, + true + ); + } + } + + private function getCalendarEvents(User $user): array { + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $user->getUID()); + if (empty($calendars)) { + return []; + } + + $query = $this->calendarManager->newQuery('principals/users/' . $user->getUID()); + foreach ($calendars as $calendarObject) { + // We can only work with a calendar if it exposes its scheduling information + if (!$calendarObject instanceof CalendarImpl) { + continue; + } + + $sct = $calendarObject->getSchedulingTransparency(); + if ($sct !== null && strtolower($sct->getValue()) == ScheduleCalendarTransp::TRANSPARENT) { + // If a calendar is marked as 'transparent', it means we must + // ignore it for free-busy purposes. + continue; + } + $query->addSearchCalendar($calendarObject->getUri()); + } + + $dtStart = DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime()); + $dtEnd = DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime('+5 minutes')); + + // Only query the calendars when there's any to search + if ($query instanceof CalendarQuery && !empty($query->getCalendarUris())) { + // Query the next hour + $query->setTimerangeStart($dtStart); + $query->setTimerangeEnd($dtEnd); + return $this->calendarManager->searchForPrincipal($query); + } + + return []; + } +} diff --git a/apps/dav/lib/CalDAV/TimeZoneFactory.php b/apps/dav/lib/CalDAV/TimeZoneFactory.php new file mode 100644 index 00000000000..36a2c97be82 --- /dev/null +++ b/apps/dav/lib/CalDAV/TimeZoneFactory.php @@ -0,0 +1,213 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use DateTimeZone; + +/** + * Class to generate DateTimeZone object with automated Microsoft and IANA handling + * + * @since 31.0.0 + */ +class TimeZoneFactory { + + /** + * conversion table of Microsoft time zones to IANA time zones + * + * @var array<string,string> MS2IANA + */ + private const MS2IANA = [ + 'AUS Central Standard Time' => 'Australia/Darwin', + 'Aus Central W. Standard Time' => 'Australia/Eucla', + 'AUS Eastern Standard Time' => 'Australia/Sydney', + 'Afghanistan Standard Time' => 'Asia/Kabul', + 'Alaskan Standard Time' => 'America/Anchorage', + 'Aleutian Standard Time' => 'America/Adak', + 'Altai Standard Time' => 'Asia/Barnaul', + 'Arab Standard Time' => 'Asia/Riyadh', + 'Arabian Standard Time' => 'Asia/Dubai', + 'Arabic Standard Time' => 'Asia/Baghdad', + 'Argentina Standard Time' => 'America/Buenos_Aires', + 'Astrakhan Standard Time' => 'Europe/Astrakhan', + 'Atlantic Standard Time' => 'America/Halifax', + 'Azerbaijan Standard Time' => 'Asia/Baku', + 'Azores Standard Time' => 'Atlantic/Azores', + 'Bahia Standard Time' => 'America/Bahia', + 'Bangladesh Standard Time' => 'Asia/Dhaka', + 'Belarus Standard Time' => 'Europe/Minsk', + 'Bougainville Standard Time' => 'Pacific/Bougainville', + 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde', + 'Canada Central Standard Time' => 'America/Regina', + 'Caucasus Standard Time' => 'Asia/Yerevan', + 'Cen. Australia Standard Time' => 'Australia/Adelaide', + 'Central America Standard Time' => 'America/Guatemala', + 'Central Asia Standard Time' => 'Asia/Almaty', + 'Central Brazilian Standard Time' => 'America/Cuiaba', + 'Central Europe Standard Time' => 'Europe/Budapest', + 'Central European Standard Time' => 'Europe/Warsaw', + 'Central Pacific Standard Time' => 'Pacific/Guadalcanal', + 'Central Standard Time' => 'America/Chicago', + 'Central Standard Time (Mexico)' => 'America/Mexico_City', + 'Chatham Islands Standard Time' => 'Pacific/Chatham', + 'China Standard Time' => 'Asia/Shanghai', + 'Coordinated Universal Time' => 'UTC', + 'Cuba Standard Time' => 'America/Havana', + 'Dateline Standard Time' => 'Etc/GMT+12', + 'E. Africa Standard Time' => 'Africa/Nairobi', + 'E. Australia Standard Time' => 'Australia/Brisbane', + 'E. Europe Standard Time' => 'Europe/Chisinau', + 'E. South America Standard Time' => 'America/Sao_Paulo', + 'Easter Island Standard Time' => 'Pacific/Easter', + 'Eastern Standard Time' => 'America/Toronto', + 'Eastern Standard Time (Mexico)' => 'America/Cancun', + 'Egypt Standard Time' => 'Africa/Cairo', + 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg', + 'FLE Standard Time' => 'Europe/Kiev', + 'Fiji Standard Time' => 'Pacific/Fiji', + 'GMT Standard Time' => 'Europe/London', + 'GTB Standard Time' => 'Europe/Bucharest', + 'Georgian Standard Time' => 'Asia/Tbilisi', + 'Greenland Standard Time' => 'America/Godthab', + 'Greenland (Danmarkshavn)' => 'America/Godthab', + 'Greenwich Standard Time' => 'Atlantic/Reykjavik', + 'Haiti Standard Time' => 'America/Port-au-Prince', + 'Hawaiian Standard Time' => 'Pacific/Honolulu', + 'India Standard Time' => 'Asia/Kolkata', + 'Iran Standard Time' => 'Asia/Tehran', + 'Israel Standard Time' => 'Asia/Jerusalem', + 'Jordan Standard Time' => 'Asia/Amman', + 'Kaliningrad Standard Time' => 'Europe/Kaliningrad', + 'Kamchatka Standard Time' => 'Asia/Kamchatka', + 'Korea Standard Time' => 'Asia/Seoul', + 'Libya Standard Time' => 'Africa/Tripoli', + 'Line Islands Standard Time' => 'Pacific/Kiritimati', + 'Lord Howe Standard Time' => 'Australia/Lord_Howe', + 'Magadan Standard Time' => 'Asia/Magadan', + 'Magallanes Standard Time' => 'America/Punta_Arenas', + 'Malaysia Standard Time' => 'Asia/Kuala_Lumpur', + 'Marquesas Standard Time' => 'Pacific/Marquesas', + 'Mauritius Standard Time' => 'Indian/Mauritius', + 'Mid-Atlantic Standard Time' => 'Atlantic/South_Georgia', + 'Middle East Standard Time' => 'Asia/Beirut', + 'Montevideo Standard Time' => 'America/Montevideo', + 'Morocco Standard Time' => 'Africa/Casablanca', + 'Mountain Standard Time' => 'America/Denver', + 'Mountain Standard Time (Mexico)' => 'America/Chihuahua', + 'Myanmar Standard Time' => 'Asia/Rangoon', + 'N. Central Asia Standard Time' => 'Asia/Novosibirsk', + 'Namibia Standard Time' => 'Africa/Windhoek', + 'Nepal Standard Time' => 'Asia/Kathmandu', + 'New Zealand Standard Time' => 'Pacific/Auckland', + 'Newfoundland Standard Time' => 'America/St_Johns', + 'Norfolk Standard Time' => 'Pacific/Norfolk', + 'North Asia East Standard Time' => 'Asia/Irkutsk', + 'North Asia Standard Time' => 'Asia/Krasnoyarsk', + 'North Korea Standard Time' => 'Asia/Pyongyang', + 'Omsk Standard Time' => 'Asia/Omsk', + 'Pacific SA Standard Time' => 'America/Santiago', + 'Pacific Standard Time' => 'America/Los_Angeles', + 'Pacific Standard Time (Mexico)' => 'America/Tijuana', + 'Pakistan Standard Time' => 'Asia/Karachi', + 'Paraguay Standard Time' => 'America/Asuncion', + 'Qyzylorda Standard Time' => 'Asia/Qyzylorda', + 'Romance Standard Time' => 'Europe/Paris', + 'Russian Standard Time' => 'Europe/Moscow', + 'Russia Time Zone 10' => 'Asia/Srednekolymsk', + 'Russia Time Zone 3' => 'Europe/Samara', + 'SA Eastern Standard Time' => 'America/Cayenne', + 'SA Pacific Standard Time' => 'America/Bogota', + 'SA Western Standard Time' => 'America/La_Paz', + 'SE Asia Standard Time' => 'Asia/Bangkok', + 'Saint Pierre Standard Time' => 'America/Miquelon', + 'Sakhalin Standard Time' => 'Asia/Sakhalin', + 'Samoa Standard Time' => 'Pacific/Apia', + 'Sao Tome Standard Time' => 'Africa/Sao_Tome', + 'Saratov Standard Time' => 'Europe/Saratov', + 'Singapore Standard Time' => 'Asia/Singapore', + 'South Africa Standard Time' => 'Africa/Johannesburg', + 'South Sudan Standard Time' => 'Africa/Juba', + 'Sri Lanka Standard Time' => 'Asia/Colombo', + 'Sudan Standard Time' => 'Africa/Khartoum', + 'Syria Standard Time' => 'Asia/Damascus', + 'Taipei Standard Time' => 'Asia/Taipei', + 'Tasmania Standard Time' => 'Australia/Hobart', + 'Tocantins Standard Time' => 'America/Araguaina', + 'Tokyo Standard Time' => 'Asia/Tokyo', + 'Tomsk Standard Time' => 'Asia/Tomsk', + 'Tonga Standard Time' => 'Pacific/Tongatapu', + 'Transbaikal Standard Time' => 'Asia/Chita', + 'Turkey Standard Time' => 'Europe/Istanbul', + 'Turks And Caicos Standard Time' => 'America/Grand_Turk', + 'US Eastern Standard Time' => 'America/Indianapolis', + 'US Mountain Standard Time' => 'America/Phoenix', + 'UTC' => 'Etc/GMT', + 'UTC+13' => 'Etc/GMT-13', + 'UTC+12' => 'Etc/GMT-12', + 'UTC-02' => 'Etc/GMT+2', + 'UTC-09' => 'Etc/GMT+9', + 'UTC-11' => 'Etc/GMT+11', + 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar', + 'Venezuela Standard Time' => 'America/Caracas', + 'Vladivostok Standard Time' => 'Asia/Vladivostok', + 'Volgograd Standard Time' => 'Europe/Volgograd', + 'W. Australia Standard Time' => 'Australia/Perth', + 'W. Central Africa Standard Time' => 'Africa/Lagos', + 'W. Europe Standard Time' => 'Europe/Berlin', + 'W. Mongolia Standard Time' => 'Asia/Hovd', + 'West Asia Standard Time' => 'Asia/Tashkent', + 'West Bank Standard Time' => 'Asia/Hebron', + 'West Pacific Standard Time' => 'Pacific/Port_Moresby', + 'West Samoa Standard Time' => 'Pacific/Apia', + 'Yakutsk Standard Time' => 'Asia/Yakutsk', + 'Yukon Standard Time' => 'America/Whitehorse', + 'Yekaterinburg Standard Time' => 'Asia/Yekaterinburg', + ]; + + /** + * Determines if given time zone name is a Microsoft time zone + * + * @since 31.0.0 + * + * @param string $name time zone name + * + * @return bool + */ + public static function isMS(string $name): bool { + return isset(self::MS2IANA[$name]); + } + + /** + * Converts Microsoft time zone name to IANA time zone name + * + * @since 31.0.0 + * + * @param string $name microsoft time zone + * + * @return string|null valid IANA time zone name on success, or null on failure + */ + public static function toIANA(string $name): ?string { + return isset(self::MS2IANA[$name]) ? self::MS2IANA[$name] : null; + } + + /** + * Generates DateTimeZone object for given time zone name + * + * @since 31.0.0 + * + * @param string $name time zone name + * + * @return DateTimeZone|null + */ + public function fromName(string $name): ?DateTimeZone { + // if zone name is MS convert to IANA, otherwise just assume the zone is IANA + $zone = @timezone_open(self::toIANA($name) ?? $name); + return ($zone instanceof DateTimeZone) ? $zone : null; + } +} diff --git a/apps/dav/lib/CalDAV/TimezoneService.php b/apps/dav/lib/CalDAV/TimezoneService.php new file mode 100644 index 00000000000..a7709bde0f9 --- /dev/null +++ b/apps/dav/lib/CalDAV/TimezoneService.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use OCA\DAV\Db\PropertyMapper; +use OCP\Calendar\ICalendar; +use OCP\Calendar\IManager; +use OCP\IConfig; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VTimeZone; +use Sabre\VObject\Reader; +use function array_reduce; + +class TimezoneService { + + public function __construct( + private IConfig $config, + private PropertyMapper $propertyMapper, + private IManager $calendarManager, + ) { + } + + public function getUserTimezone(string $userId): ?string { + $fromConfig = $this->config->getUserValue( + $userId, + 'core', + 'timezone', + ); + if ($fromConfig !== '') { + return $fromConfig; + } + + $availabilityPropPath = 'calendars/' . $userId . '/inbox'; + $availabilityProp = '{' . Plugin::NS_CALDAV . '}calendar-availability'; + $availabilities = $this->propertyMapper->findPropertyByPathAndName($userId, $availabilityPropPath, $availabilityProp); + if (!empty($availabilities)) { + $availability = $availabilities[0]->getPropertyvalue(); + /** @var VCalendar $vCalendar */ + $vCalendar = Reader::read($availability); + /** @var VTimeZone $vTimezone */ + $vTimezone = $vCalendar->VTIMEZONE; + // Sabre has a fallback to date_default_timezone_get + return $vTimezone->getTimeZone()->getName(); + } + + $principal = 'principals/users/' . $userId; + $uri = $this->config->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI); + $calendars = $this->calendarManager->getCalendarsForPrincipal($principal); + + /** @var ?VTimeZone $personalCalendarTimezone */ + $personalCalendarTimezone = array_reduce($calendars, function (?VTimeZone $acc, ICalendar $calendar) use ($uri) { + if ($acc !== null) { + return $acc; + } + if ($calendar->getUri() === $uri && !$calendar->isDeleted() && $calendar instanceof CalendarImpl) { + return $calendar->getSchedulingTimezone(); + } + return null; + }); + if ($personalCalendarTimezone !== null) { + return $personalCalendarTimezone->getTimeZone()->getName(); + } + + // No timezone in the personalCalendarTimezone calendar or no personalCalendarTimezone calendar + // Loop through all calendars until we find a timezone. + /** @var ?VTimeZone $firstTimezone */ + $firstTimezone = array_reduce($calendars, function (?VTimeZone $acc, ICalendar $calendar) { + if ($acc !== null) { + return $acc; + } + if (!$calendar->isDeleted() && $calendar instanceof CalendarImpl) { + return $calendar->getSchedulingTimezone(); + } + return null; + }); + if ($firstTimezone !== null) { + return $firstTimezone->getTimeZone()->getName(); + } + return null; + } + + public function getDefaultTimezone(): string { + return $this->config->getSystemValueString('default_timezone', 'UTC'); + } + +} diff --git a/apps/dav/lib/CalDAV/TipBroker.php b/apps/dav/lib/CalDAV/TipBroker.php new file mode 100644 index 00000000000..16e68fde1f0 --- /dev/null +++ b/apps/dav/lib/CalDAV/TipBroker.php @@ -0,0 +1,187 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\ITip\Broker; +use Sabre\VObject\ITip\Message; + +class TipBroker extends Broker { + + public $significantChangeProperties = [ + 'DTSTART', + 'DTEND', + 'DURATION', + 'DUE', + 'RRULE', + 'RDATE', + 'EXDATE', + 'STATUS', + 'SUMMARY', + 'DESCRIPTION', + 'LOCATION', + + ]; + + /** + * This method is used in cases where an event got updated, and we + * potentially need to send emails to attendees to let them know of updates + * in the events. + * + * We will detect which attendees got added, which got removed and create + * specific messages for these situations. + * + * @return array + */ + protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo) { + // Merging attendee lists. + $attendees = []; + foreach ($oldEventInfo['attendees'] as $attendee) { + $attendees[$attendee['href']] = [ + 'href' => $attendee['href'], + 'oldInstances' => $attendee['instances'], + 'newInstances' => [], + 'name' => $attendee['name'], + 'forceSend' => null, + ]; + } + foreach ($eventInfo['attendees'] as $attendee) { + if (isset($attendees[$attendee['href']])) { + $attendees[$attendee['href']]['name'] = $attendee['name']; + $attendees[$attendee['href']]['newInstances'] = $attendee['instances']; + $attendees[$attendee['href']]['forceSend'] = $attendee['forceSend']; + } else { + $attendees[$attendee['href']] = [ + 'href' => $attendee['href'], + 'oldInstances' => [], + 'newInstances' => $attendee['instances'], + 'name' => $attendee['name'], + 'forceSend' => $attendee['forceSend'], + ]; + } + } + + $messages = []; + + foreach ($attendees as $attendee) { + // An organizer can also be an attendee. We should not generate any + // messages for those. + if ($attendee['href'] === $eventInfo['organizer']) { + continue; + } + + $message = new Message(); + $message->uid = $eventInfo['uid']; + $message->component = 'VEVENT'; + $message->sequence = $eventInfo['sequence']; + $message->sender = $eventInfo['organizer']; + $message->senderName = $eventInfo['organizerName']; + $message->recipient = $attendee['href']; + $message->recipientName = $attendee['name']; + + // Creating the new iCalendar body. + $icalMsg = new VCalendar(); + + foreach ($calendar->select('VTIMEZONE') as $timezone) { + $icalMsg->add(clone $timezone); + } + // If there are no instances the attendee is a part of, it means + // the attendee was removed and we need to send them a CANCEL message. + // Also If the meeting STATUS property was changed to CANCELLED + // we need to send the attendee a CANCEL message. + if (!$attendee['newInstances'] || $eventInfo['status'] === 'CANCELLED') { + + $message->method = $icalMsg->METHOD = 'CANCEL'; + $message->significantChange = true; + // clone base event + $event = clone $eventInfo['instances']['master']; + // alter some properties + unset($event->ATTENDEE); + $event->add('ATTENDEE', $attendee['href'], ['CN' => $attendee['name'],]); + $event->DTSTAMP = gmdate('Ymd\\THis\\Z'); + $event->SEQUENCE = $message->sequence; + $icalMsg->add($event); + + } else { + // The attendee gets the updated event body + $message->method = $icalMsg->METHOD = 'REQUEST'; + + // We need to find out that this change is significant. If it's + // not, systems may opt to not send messages. + // + // We do this based on the 'significantChangeHash' which is + // some value that changes if there's a certain set of + // properties changed in the event, or simply if there's a + // difference in instances that the attendee is invited to. + + $oldAttendeeInstances = array_keys($attendee['oldInstances']); + $newAttendeeInstances = array_keys($attendee['newInstances']); + + $message->significantChange + = $attendee['forceSend'] === 'REQUEST' + || count($oldAttendeeInstances) !== count($newAttendeeInstances) + || count(array_diff($oldAttendeeInstances, $newAttendeeInstances)) > 0 + || $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash']; + + foreach ($attendee['newInstances'] as $instanceId => $instanceInfo) { + $currentEvent = clone $eventInfo['instances'][$instanceId]; + if ($instanceId === 'master') { + // We need to find a list of events that the attendee + // is not a part of to add to the list of exceptions. + $exceptions = []; + foreach ($eventInfo['instances'] as $instanceId => $vevent) { + if (!isset($attendee['newInstances'][$instanceId])) { + $exceptions[] = $instanceId; + } + } + + // If there were exceptions, we need to add it to an + // existing EXDATE property, if it exists. + if ($exceptions) { + if (isset($currentEvent->EXDATE)) { + $currentEvent->EXDATE->setParts(array_merge( + $currentEvent->EXDATE->getParts(), + $exceptions + )); + } else { + $currentEvent->EXDATE = $exceptions; + } + } + + // Cleaning up any scheduling information that + // shouldn't be sent along. + unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']); + unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']); + + foreach ($currentEvent->ATTENDEE as $attendee) { + unset($attendee['SCHEDULE-FORCE-SEND']); + unset($attendee['SCHEDULE-STATUS']); + + // We're adding PARTSTAT=NEEDS-ACTION to ensure that + // iOS shows an "Inbox Item" + if (!isset($attendee['PARTSTAT'])) { + $attendee['PARTSTAT'] = 'NEEDS-ACTION'; + } + } + } + + $currentEvent->DTSTAMP = gmdate('Ymd\\THis\\Z'); + $icalMsg->add($currentEvent); + } + } + + $message->message = $icalMsg; + $messages[] = $message; + } + + return $messages; + } + +} diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php index 5730b7a1002..d8c429f2056 100644 --- a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php +++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Trashbin; @@ -35,26 +18,13 @@ use Sabre\DAVACL\IACL; class DeletedCalendarObject implements IACL, ICalendarObject, IRestorable { use ACLTrait; - /** @var string */ - private $name; - - /** @var mixed[] */ - private $objectData; - - /** @var string */ - private $principalUri; - - /** @var CalDavBackend */ - private $calDavBackend; - - public function __construct(string $name, - array $objectData, - string $principalUri, - CalDavBackend $calDavBackend) { - $this->name = $name; - $this->objectData = $objectData; - $this->calDavBackend = $calDavBackend; - $this->principalUri = $principalUri; + public function __construct( + private string $name, + /** @var mixed[] */ + private array $objectData, + private string $principalUri, + private CalDavBackend $calDavBackend, + ) { } public function delete() { @@ -89,7 +59,7 @@ class DeletedCalendarObject implements IACL, ICalendarObject, IRestorable { public function getContentType() { $mime = 'text/calendar; charset=utf-8'; if (isset($this->objectData['component']) && $this->objectData['component']) { - $mime .= '; component='.$this->objectData['component']; + $mime .= '; component=' . $this->objectData['component']; } return $mime; @@ -100,7 +70,7 @@ class DeletedCalendarObject implements IACL, ICalendarObject, IRestorable { } public function getSize() { - return (int) $this->objectData['size']; + return (int)$this->objectData['size']; } public function restore(): void { @@ -108,7 +78,7 @@ class DeletedCalendarObject implements IACL, ICalendarObject, IRestorable { } public function getDeletedAt(): ?int { - return $this->objectData['deleted_at'] ? (int) $this->objectData['deleted_at'] : null; + return $this->objectData['deleted_at'] ? (int)$this->objectData['deleted_at'] : null; } public function getCalendarUri(): string { diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php index 20d05c047b1..f75e19689f1 100644 --- a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php +++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Trashbin; @@ -31,23 +14,22 @@ use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\Exception\NotImplemented; +use Sabre\DAVACL\ACLTrait; +use Sabre\DAVACL\IACL; use function array_map; use function implode; use function preg_match; -class DeletedCalendarObjectsCollection implements ICalendarObjectContainer { - public const NAME = 'objects'; - - /** @var CalDavBackend */ - protected $caldavBackend; +class DeletedCalendarObjectsCollection implements ICalendarObjectContainer, IACL { + use ACLTrait; - /** @var mixed[] */ - private $principalInfo; + public const NAME = 'objects'; - public function __construct(CalDavBackend $caldavBackend, - array $principalInfo) { - $this->caldavBackend = $caldavBackend; - $this->principalInfo = $principalInfo; + public function __construct( + protected CalDavBackend $caldavBackend, + /** @var mixed[] */ + private array $principalInfo, + ) { } /** @@ -64,7 +46,7 @@ class DeletedCalendarObjectsCollection implements ICalendarObjectContainer { $data = $this->caldavBackend->getCalendarObjectById( $this->principalInfo['uri'], - (int) $matches[1], + (int)$matches[1], ); // If the object hasn't been deleted yet then we don't want to find it here @@ -129,4 +111,23 @@ class DeletedCalendarObjectsCollection implements ICalendarObjectContainer { [$calendarInfo['id'], 'ics'], ); } + + public function getOwner() { + return $this->principalInfo['uri']; + } + + public function getACL(): array { + return [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}unbind', + 'principal' => '{DAV:}owner', + 'protected' => true, + ] + ]; + } } diff --git a/apps/dav/lib/CalDAV/Trashbin/Plugin.php b/apps/dav/lib/CalDAV/Trashbin/Plugin.php index 58ff76beca1..6f58b1f3110 100644 --- a/apps/dav/lib/CalDAV/Trashbin/Plugin.php +++ b/apps/dav/lib/CalDAV/Trashbin/Plugin.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Trashbin; @@ -49,16 +32,14 @@ class Plugin extends ServerPlugin { /** @var bool */ private $disableTrashbin; - /** @var RetentionService */ - private $retentionService; - /** @var Server */ private $server; - public function __construct(IRequest $request, - RetentionService $retentionService) { + public function __construct( + IRequest $request, + private RetentionService $retentionService, + ) { $this->disableTrashbin = $request->getHeader('X-NC-CalDAV-No-Trashbin') === '1'; - $this->retentionService = $retentionService; } public function initialize(Server $server): void { diff --git a/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php b/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php index 31331957c49..6641148de2b 100644 --- a/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php +++ b/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Trashbin; diff --git a/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php b/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php index 34d11e51eb3..1c76bd2295d 100644 --- a/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php +++ b/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Trashbin; @@ -43,16 +26,10 @@ class TrashbinHome implements IACL, ICollection, IProperties { public const NAME = 'trashbin'; - /** @var CalDavBackend */ - private $caldavBackend; - - /** @var array */ - private $principalInfo; - - public function __construct(CalDavBackend $caldavBackend, - array $principalInfo) { - $this->caldavBackend = $caldavBackend; - $this->principalInfo = $principalInfo; + public function __construct( + private CalDavBackend $caldavBackend, + private array $principalInfo, + ) { } public function getOwner(): string { diff --git a/apps/dav/lib/CalDAV/UpcomingEvent.php b/apps/dav/lib/CalDAV/UpcomingEvent.php new file mode 100644 index 00000000000..e8b604f460a --- /dev/null +++ b/apps/dav/lib/CalDAV/UpcomingEvent.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use JsonSerializable; +use OCA\DAV\ResponseDefinitions; + +class UpcomingEvent implements JsonSerializable { + public function __construct( + private string $uri, + private ?int $recurrenceId, + private string $calendarUri, + private ?int $start, + private ?string $summary, + private ?string $location, + private ?string $calendarAppUrl, + ) { + } + + public function getUri(): string { + return $this->uri; + } + + public function getRecurrenceId(): ?int { + return $this->recurrenceId; + } + + public function getCalendarUri(): string { + return $this->calendarUri; + } + + public function getStart(): ?int { + return $this->start; + } + + public function getSummary(): ?string { + return $this->summary; + } + + public function getLocation(): ?string { + return $this->location; + } + + public function getCalendarAppUrl(): ?string { + return $this->calendarAppUrl; + } + + /** + * @see ResponseDefinitions + */ + public function jsonSerialize(): array { + return [ + 'uri' => $this->uri, + 'recurrenceId' => $this->recurrenceId, + 'calendarUri' => $this->calendarUri, + 'start' => $this->start, + 'summary' => $this->summary, + 'location' => $this->location, + 'calendarAppUrl' => $this->calendarAppUrl, + ]; + } +} diff --git a/apps/dav/lib/CalDAV/UpcomingEventsService.php b/apps/dav/lib/CalDAV/UpcomingEventsService.php new file mode 100644 index 00000000000..1a8aed5bd71 --- /dev/null +++ b/apps/dav/lib/CalDAV/UpcomingEventsService.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use OCP\App\IAppManager; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Calendar\IManager; +use OCP\IURLGenerator; +use OCP\IUserManager; +use function array_map; + +class UpcomingEventsService { + public function __construct( + private IManager $calendarManager, + private ITimeFactory $timeFactory, + private IUserManager $userManager, + private IAppManager $appManager, + private IURLGenerator $urlGenerator, + ) { + } + + /** + * @return UpcomingEvent[] + */ + public function getEvents(string $userId, ?string $location = null): array { + $searchQuery = $this->calendarManager->newQuery('principals/users/' . $userId); + if ($location !== null) { + $searchQuery->addSearchProperty('LOCATION'); + $searchQuery->setSearchPattern($location); + } + $searchQuery->addType('VEVENT'); + $searchQuery->setLimit(3); + $now = $this->timeFactory->now(); + $searchQuery->setTimerangeStart($now->modify('-1 minute')); + $searchQuery->setTimerangeEnd($now->modify('+1 month')); + + $events = $this->calendarManager->searchForPrincipal($searchQuery); + $calendarAppEnabled = $this->appManager->isEnabledForUser( + 'calendar', + $this->userManager->get($userId), + ); + + return array_filter(array_map(function (array $event) use ($userId, $calendarAppEnabled) { + $calendarAppUrl = null; + + if ($calendarAppEnabled) { + $arguments = [ + 'objectId' => base64_encode($this->urlGenerator->getWebroot() . '/remote.php/dav/calendars/' . $userId . '/' . $event['calendar-uri'] . '/' . $event['uri']), + ]; + + if (isset($event['RECURRENCE-ID'])) { + $arguments['recurrenceId'] = $event['RECURRENCE-ID'][0]; + } + /** + * TODO: create a named, deep route in calendar (it's a code smell to just assume this route exists, find an abstraction) + * When changing, also adjust for: + * - spreed/lib/Service/CalendarIntegrationService.php#getDashboardEvents + * - spreed/lib/Service/CalendarIntegrationService.php#getMutualEvents + */ + $calendarAppUrl = $this->urlGenerator->linkToRouteAbsolute('calendar.view.indexdirect.edit', $arguments); + } + + if (isset($event['objects'][0]['STATUS']) && $event['objects'][0]['STATUS'][0] === 'CANCELLED') { + return false; + } + + return new UpcomingEvent( + $event['uri'], + ($event['objects'][0]['RECURRENCE-ID'][0] ?? null)?->getTimeStamp(), + $event['calendar-uri'], + $event['objects'][0]['DTSTART'][0]?->getTimestamp(), + $event['objects'][0]['SUMMARY'][0] ?? null, + $event['objects'][0]['LOCATION'][0] ?? null, + $calendarAppUrl, + ); + }, $events)); + } + +} diff --git a/apps/dav/lib/CalDAV/Validation/CalDavValidatePlugin.php b/apps/dav/lib/CalDAV/Validation/CalDavValidatePlugin.php new file mode 100644 index 00000000000..b647e63e67b --- /dev/null +++ b/apps/dav/lib/CalDAV/Validation/CalDavValidatePlugin.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/* + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Validation; + +use OCA\DAV\AppInfo\Application; +use OCP\IAppConfig; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class CalDavValidatePlugin extends ServerPlugin { + + public function __construct( + private IAppConfig $config, + ) { + } + + public function initialize(Server $server): void { + $server->on('beforeMethod:PUT', [$this, 'beforePut']); + } + + public function beforePut(RequestInterface $request, ResponseInterface $response): bool { + // evaluate if card size exceeds defined limit + $eventSizeLimit = $this->config->getValueInt(Application::APP_ID, 'event_size_limit', 10485760); + if ((int)$request->getRawServerValue('CONTENT_LENGTH') > $eventSizeLimit) { + throw new Forbidden("VEvent or VTodo object exceeds $eventSizeLimit bytes"); + } + // all tests passed return true + return true; + } + +} diff --git a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php new file mode 100644 index 00000000000..3d12c92c49a --- /dev/null +++ b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\WebcalCaching; + +use Exception; +use GuzzleHttp\RequestOptions; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\LocalServerException; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use Sabre\VObject\Reader; + +class Connection { + public function __construct( + private IClientService $clientService, + private IAppConfig $config, + private LoggerInterface $logger, + ) { + } + + /** + * gets webcal feed from remote server + */ + public function queryWebcalFeed(array $subscription): ?string { + $subscriptionId = $subscription['id']; + $url = $this->cleanURL($subscription['source']); + if ($url === null) { + return null; + } + + $allowLocalAccess = $this->config->getValueString('dav', 'webcalAllowLocalAccess', 'no'); + + $params = [ + 'nextcloud' => [ + 'allow_local_address' => $allowLocalAccess === 'yes', + ], + RequestOptions::HEADERS => [ + 'User-Agent' => 'Nextcloud Webcal Service', + 'Accept' => 'text/calendar, application/calendar+json, application/calendar+xml', + ], + ]; + + $user = parse_url($subscription['source'], PHP_URL_USER); + $pass = parse_url($subscription['source'], PHP_URL_PASS); + if ($user !== null && $pass !== null) { + $params[RequestOptions::AUTH] = [$user, $pass]; + } + + try { + $client = $this->clientService->newClient(); + $response = $client->get($url, $params); + } catch (LocalServerException $ex) { + $this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules", [ + 'exception' => $ex, + ]); + return null; + } catch (Exception $ex) { + $this->logger->warning("Subscription $subscriptionId could not be refreshed due to a network error", [ + 'exception' => $ex, + ]); + return null; + } + + $body = $response->getBody(); + + $contentType = $response->getHeader('Content-Type'); + $contentType = explode(';', $contentType, 2)[0]; + switch ($contentType) { + case 'application/calendar+json': + try { + $jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING); + } catch (Exception $ex) { + // In case of a parsing error return null + $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); + return null; + } + return $jCalendar->serialize(); + + case 'application/calendar+xml': + try { + $xCalendar = Reader::readXML($body); + } catch (Exception $ex) { + // In case of a parsing error return null + $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); + return null; + } + return $xCalendar->serialize(); + + case 'text/calendar': + default: + try { + $vCalendar = Reader::read($body); + } catch (Exception $ex) { + // In case of a parsing error return null + $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); + return null; + } + return $vCalendar->serialize(); + } + } + + /** + * This method will strip authentication information and replace the + * 'webcal' or 'webcals' protocol scheme + * + * @param string $url + * @return string|null + */ + private function cleanURL(string $url): ?string { + $parsed = parse_url($url); + if ($parsed === false) { + return null; + } + + if (isset($parsed['scheme']) && $parsed['scheme'] === 'http') { + $scheme = 'http'; + } else { + $scheme = 'https'; + } + + $host = $parsed['host'] ?? ''; + $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + $path = $parsed['path'] ?? ''; + $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; + $fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : ''; + + $cleanURL = "$scheme://$host$port$path$query$fragment"; + // parse_url is giving some weird results if no url and no :// is given, + // so let's test the url again + $parsedClean = parse_url($cleanURL); + if ($parsedClean === false || !isset($parsedClean['host'])) { + return null; + } + + return $cleanURL; + } +} diff --git a/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php b/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php index 3dd8a7c81e5..e07be39c7b4 100644 --- a/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php +++ b/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php @@ -3,30 +3,12 @@ declare(strict_types=1); /** - * @copyright 2018 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\WebcalCaching; -use OCA\DAV\CalDAV\CalendarHome; +use OCA\DAV\CalDAV\CalendarRoot; use OCP\IRequest; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\Server; @@ -41,10 +23,14 @@ class Plugin extends ServerPlugin { * that do not support subscriptions on their own * * /^MSFT-WIN-3/ - Windows 10 Calendar + * /Evolution/ - Gnome Calendar/Evolution + * /KIO/ - KDE PIM/Akonadi * @var string[] */ public const ENABLE_FOR_CLIENTS = [ - "/^MSFT-WIN-3/" + '/^MSFT-WIN-3/', + '/Evolution/', + '/KIO/' ]; /** @@ -71,6 +57,11 @@ class Plugin extends ServerPlugin { if ($magicHeader === 'On') { $this->enabled = true; } + + $isExportRequest = $request->getMethod() === 'GET' && array_key_exists('export', $request->getParams()); + if ($isExportRequest) { + $this->enabled = true; + } } /** @@ -85,7 +76,7 @@ class Plugin extends ServerPlugin { */ public function initialize(Server $server) { $this->server = $server; - $server->on('beforeMethod:*', [$this, 'beforeMethod']); + $server->on('beforeMethod:*', [$this, 'beforeMethod'], 15); } /** @@ -98,21 +89,20 @@ class Plugin extends ServerPlugin { } $path = $request->getPath(); + if (!str_starts_with($path, 'calendars/')) { + return; + } + $pathParts = explode('/', ltrim($path, '/')); if (\count($pathParts) < 2) { return; } - // $calendarHomePath will look like: calendars/username - $calendarHomePath = $pathParts[0] . '/' . $pathParts[1]; try { - $calendarHome = $this->server->tree->getNodeForPath($calendarHomePath); - if (!($calendarHome instanceof CalendarHome)) { - //how did we end up here? - return; + $calendarRoot = $this->server->tree->getNodeForPath($pathParts[0]); + if ($calendarRoot instanceof CalendarRoot) { + $calendarRoot->enableReturnCachedSubscriptions($pathParts[1]); } - - $calendarHome->enableCachedSubscriptionsForThisRequest(); } catch (NotFound $ex) { return; } diff --git a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php index 543d15e0179..a0981e6dec1 100644 --- a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php +++ b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php @@ -3,94 +3,42 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Thomas Citharel <nextcloud@tcit.fr> - * @copyright Copyright (c) 2020, leith abdulla (<online-nextcloud@eleith.com>) - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author eleith <online+github@eleith.com> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\WebcalCaching; -use Exception; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; use OCA\DAV\CalDAV\CalDavBackend; -use OCP\Http\Client\IClientService; -use OCP\Http\Client\LocalServerException; -use OCP\IConfig; -use OCP\ILogger; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; +use OCP\AppFramework\Utility\ITimeFactory; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\PropPatch; -use Sabre\DAV\Xml\Property\Href; use Sabre\VObject\Component; use Sabre\VObject\DateTimeParser; use Sabre\VObject\InvalidDataException; -use Sabre\VObject\Recur\NoInstancesException; use Sabre\VObject\ParseException; use Sabre\VObject\Reader; +use Sabre\VObject\Recur\NoInstancesException; use Sabre\VObject\Splitter\ICalendar; use Sabre\VObject\UUIDUtil; use function count; class RefreshWebcalService { - /** @var CalDavBackend */ - private $calDavBackend; - - /** @var IClientService */ - private $clientService; - - /** @var IConfig */ - private $config; - - /** @var ILogger */ - private $logger; - public const REFRESH_RATE = '{http://apple.com/ns/ical/}refreshrate'; public const STRIP_ALARMS = '{http://calendarserver.org/ns/}subscribed-strip-alarms'; public const STRIP_ATTACHMENTS = '{http://calendarserver.org/ns/}subscribed-strip-attachments'; public const STRIP_TODOS = '{http://calendarserver.org/ns/}subscribed-strip-todos'; - /** - * RefreshWebcalJob constructor. - * - * @param CalDavBackend $calDavBackend - * @param IClientService $clientService - * @param IConfig $config - * @param ILogger $logger - */ - public function __construct(CalDavBackend $calDavBackend, IClientService $clientService, IConfig $config, ILogger $logger) { - $this->calDavBackend = $calDavBackend; - $this->clientService = $clientService; - $this->config = $config; - $this->logger = $logger; + public function __construct( + private CalDavBackend $calDavBackend, + private LoggerInterface $logger, + private Connection $connection, + private ITimeFactory $time, + ) { } - /** - * @param string $principalUri - * @param string $uri - */ public function refreshSubscription(string $principalUri, string $uri) { $subscription = $this->getSubscription($principalUri, $uri); $mutations = []; @@ -98,11 +46,25 @@ class RefreshWebcalService { return; } - $webcalData = $this->queryWebcalFeed($subscription, $mutations); + // Check the refresh rate if there is any + if (!empty($subscription['{http://apple.com/ns/ical/}refreshrate'])) { + // add the refresh interval to the lastmodified timestamp + $refreshInterval = new \DateInterval($subscription['{http://apple.com/ns/ical/}refreshrate']); + $updateTime = $this->time->getDateTime(); + $updateTime->setTimestamp($subscription['lastmodified'])->add($refreshInterval); + if ($updateTime->getTimestamp() > $this->time->getTime()) { + return; + } + } + + + $webcalData = $this->connection->queryWebcalFeed($subscription); if (!$webcalData) { return; } + $localData = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + $stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1; $stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1; $stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1; @@ -110,14 +72,10 @@ class RefreshWebcalService { try { $splitter = new ICalendar($webcalData, Reader::OPTION_FORGIVING); - // we wait with deleting all outdated events till we parsed the new ones - // in case the new calendar is broken and `new ICalendar` throws a ParseException - // the user will still see the old data - $this->calDavBackend->purgeAllCachedEventsForSubscription($subscription['id']); - while ($vObject = $splitter->getNext()) { /** @var Component $vObject */ $compName = null; + $uid = null; foreach ($vObject->getComponents() as $component) { if ($component->name === 'VTIMEZONE') { @@ -132,19 +90,66 @@ class RefreshWebcalService { if ($stripAttachments) { unset($component->{'ATTACH'}); } + + $uid = $component->{ 'UID' }->getValue(); } if ($stripTodos && $compName === 'VTODO') { continue; } - $uri = $this->getRandomCalendarObjectUri(); - $calendarData = $vObject->serialize(); + if (!isset($uid)) { + continue; + } + try { - $this->calDavBackend->createCalendarObject($subscription['id'], $uri, $calendarData, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); - } catch (NoInstancesException | BadRequest $ex) { - $this->logger->logException($ex); + $denormalized = $this->calDavBackend->getDenormalizedData($vObject->serialize()); + } catch (InvalidDataException|Forbidden $ex) { + $this->logger->warning('Unable to denormalize calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]); + continue; + } + + // Find all identical sets and remove them from the update + if (isset($localData[$uid]) && $denormalized['etag'] === $localData[$uid]['etag']) { + unset($localData[$uid]); + continue; + } + + $vObjectCopy = clone $vObject; + $identical = isset($localData[$uid]) && $this->compareWithoutDtstamp($vObjectCopy, $localData[$uid]); + if ($identical) { + unset($localData[$uid]); + continue; + } + + // Find all modified sets and update them + if (isset($localData[$uid]) && $denormalized['etag'] !== $localData[$uid]['etag']) { + $this->calDavBackend->updateCalendarObject($subscription['id'], $localData[$uid]['uri'], $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + unset($localData[$uid]); + continue; } + + // Only entirely new events get created here + try { + $objectUri = $this->getRandomCalendarObjectUri(); + $this->calDavBackend->createCalendarObject($subscription['id'], $objectUri, $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + } catch (NoInstancesException|BadRequest $ex) { + $this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]); + } + } + + $ids = array_map(static function ($dataSet): int { + return (int)$dataSet['id']; + }, $localData); + $uris = array_map(static function ($dataSet): string { + return $dataSet['uri']; + }, $localData); + + if (!empty($ids) && !empty($uris)) { + // Clean up on aisle 5 + // The only events left over in the $localData array should be those that don't exist upstream + // All deleted VObjects from upstream are removed + $this->calDavBackend->purgeCachedEventsForSubscription($subscription['id'], $ids, $uris); } $newRefreshRate = $this->checkWebcalDataForRefreshRate($subscription, $webcalData); @@ -154,21 +159,14 @@ class RefreshWebcalService { $this->updateSubscription($subscription, $mutations); } catch (ParseException $ex) { - $subscriptionId = $subscription['id']; - - $this->logger->logException($ex); - $this->logger->warning("Subscription $subscriptionId could not be refreshed due to a parsing error"); + $this->logger->error('Subscription {subscriptionId} could not be refreshed due to a parsing error', ['exception' => $ex, 'subscriptionId' => $subscription['id']]); } } /** * loads subscription from backend - * - * @param string $principalUri - * @param string $uri - * @return array|null */ - public function getSubscription(string $principalUri, string $uri) { + public function getSubscription(string $principalUri, string $uri): ?array { $subscriptions = array_values(array_filter( $this->calDavBackend->getSubscriptionsForUser($principalUri), function ($sub) use ($uri) { @@ -183,117 +181,6 @@ class RefreshWebcalService { return $subscriptions[0]; } - /** - * gets webcal feed from remote server - * - * @param array $subscription - * @param array &$mutations - * @return null|string - */ - private function queryWebcalFeed(array $subscription, array &$mutations) { - $client = $this->clientService->newClient(); - - $didBreak301Chain = false; - $latestLocation = null; - - $handlerStack = HandlerStack::create(); - $handlerStack->push(Middleware::mapRequest(function (RequestInterface $request) { - return $request - ->withHeader('Accept', 'text/calendar, application/calendar+json, application/calendar+xml') - ->withHeader('User-Agent', 'Nextcloud Webcal Crawler'); - })); - $handlerStack->push(Middleware::mapResponse(function (ResponseInterface $response) use (&$didBreak301Chain, &$latestLocation) { - if (!$didBreak301Chain) { - if ($response->getStatusCode() !== 301) { - $didBreak301Chain = true; - } else { - $latestLocation = $response->getHeader('Location'); - } - } - return $response; - })); - - $allowLocalAccess = $this->config->getAppValue('dav', 'webcalAllowLocalAccess', 'no'); - $subscriptionId = $subscription['id']; - $url = $this->cleanURL($subscription['source']); - if ($url === null) { - return null; - } - - try { - $params = [ - 'allow_redirects' => [ - 'redirects' => 10 - ], - 'handler' => $handlerStack, - 'nextcloud' => [ - 'allow_local_address' => $allowLocalAccess === 'yes', - ] - ]; - - $user = parse_url($subscription['source'], PHP_URL_USER); - $pass = parse_url($subscription['source'], PHP_URL_PASS); - if ($user !== null && $pass !== null) { - $params['auth'] = [$user, $pass]; - } - - $response = $client->get($url, $params); - $body = $response->getBody(); - - if ($latestLocation) { - $mutations['{http://calendarserver.org/ns/}source'] = new Href($latestLocation); - } - - $contentType = $response->getHeader('Content-Type'); - $contentType = explode(';', $contentType, 2)[0]; - switch ($contentType) { - case 'application/calendar+json': - try { - $jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->debug("Subscription $subscriptionId could not be parsed"); - return null; - } - return $jCalendar->serialize(); - - case 'application/calendar+xml': - try { - $xCalendar = Reader::readXML($body); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->debug("Subscription $subscriptionId could not be parsed"); - return null; - } - return $xCalendar->serialize(); - - case 'text/calendar': - default: - try { - $vCalendar = Reader::read($body); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->debug("Subscription $subscriptionId could not be parsed"); - return null; - } - return $vCalendar->serialize(); - } - } catch (LocalServerException $ex) { - $this->logger->logException($ex, [ - 'message' => "Subscription $subscriptionId was not refreshed because it violates local access rules", - 'level' => ILogger::WARN, - ]); - - return null; - } catch (Exception $ex) { - $this->logger->logException($ex, [ - 'message' => "Subscription $subscriptionId could not be refreshed due to a network error", - 'level' => ILogger::WARN, - ]); - - return null; - } - } /** * check if: @@ -301,11 +188,8 @@ class RefreshWebcalService { * - the webcal feed suggests a refreshrate * - return suggested refreshrate if user didn't set a custom one * - * @param array $subscription - * @param string $webcalData - * @return string|null */ - private function checkWebcalDataForRefreshRate($subscription, $webcalData) { + private function checkWebcalDataForRefreshRate(array $subscription, string $webcalData): ?string { // if there is no refreshrate stored in the database, check the webcal feed // whether it suggests any refresh rate and store that in the database if (isset($subscription[self::REFRESH_RATE]) && $subscription[self::REFRESH_RATE] !== null) { @@ -357,47 +241,24 @@ class RefreshWebcalService { } /** - * This method will strip authentication information and replace the - * 'webcal' or 'webcals' protocol scheme + * Returns a random uri for a calendar-object * - * @param string $url - * @return string|null + * @return string */ - private function cleanURL(string $url) { - $parsed = parse_url($url); - if ($parsed === false) { - return null; - } + public function getRandomCalendarObjectUri():string { + return UUIDUtil::getUUID() . '.ics'; + } - if (isset($parsed['scheme']) && $parsed['scheme'] === 'http') { - $scheme = 'http'; - } else { - $scheme = 'https'; + private function compareWithoutDtstamp(Component $vObject, array $calendarObject): bool { + foreach ($vObject->getComponents() as $component) { + unset($component->{'DTSTAMP'}); } - $host = $parsed['host'] ?? ''; - $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; - $path = $parsed['path'] ?? ''; - $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; - $fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : ''; - - $cleanURL = "$scheme://$host$port$path$query$fragment"; - // parse_url is giving some weird results if no url and no :// is given, - // so let's test the url again - $parsedClean = parse_url($cleanURL); - if ($parsedClean === false || !isset($parsedClean['host'])) { - return null; + $localVobject = Reader::read($calendarObject['calendardata']); + foreach ($localVobject->getComponents() as $component) { + unset($component->{'DTSTAMP'}); } - return $cleanURL; - } - - /** - * Returns a random uri for a calendar-object - * - * @return string - */ - public function getRandomCalendarObjectUri():string { - return UUIDUtil::getUUID() . '.ics'; + return strcasecmp($localVobject->serialize(), $vObject->serialize()) === 0; } } diff --git a/apps/dav/lib/Capabilities.php b/apps/dav/lib/Capabilities.php index 41d1b983587..f9bad25bf31 100644 --- a/apps/dav/lib/Capabilities.php +++ b/apps/dav/lib/Capabilities.php @@ -1,37 +1,39 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud GmbH - * - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Louis Chemineau <louis@chmn.me> - * - * @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 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV; use OCP\Capabilities\ICapability; +use OCP\IConfig; +use OCP\User\IAvailabilityCoordinator; class Capabilities implements ICapability { + public function __construct( + private IConfig $config, + private IAvailabilityCoordinator $coordinator, + ) { + } + + /** + * @return array{dav: array{chunking: string, public_shares_chunking: bool, bulkupload?: string, absence-supported?: bool, absence-replacement?: bool}} + */ public function getCapabilities() { - return [ + $capabilities = [ 'dav' => [ 'chunking' => '1.0', - // disabled because of https://github.com/nextcloud/desktop/issues/4243 - // 'bulkupload' => '1.0', + 'public_shares_chunking' => true, ] ]; + if ($this->config->getSystemValueBool('bulkupload.enabled', true)) { + $capabilities['dav']['bulkupload'] = '1.0'; + } + if ($this->coordinator->isEnabled()) { + $capabilities['dav']['absence-supported'] = true; + $capabilities['dav']['absence-replacement'] = true; + } + return $capabilities; } } diff --git a/apps/dav/lib/CardDAV/Activity/Backend.php b/apps/dav/lib/CardDAV/Activity/Backend.php index b713284e182..b08414d3b02 100644 --- a/apps/dav/lib/CardDAV/Activity/Backend.php +++ b/apps/dav/lib/CardDAV/Activity/Backend.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV\Activity; @@ -32,32 +15,20 @@ use OCP\App\IAppManager; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; +use OCP\IUserManager; use OCP\IUserSession; use Sabre\CardDAV\Plugin; use Sabre\VObject\Reader; class Backend { - /** @var IActivityManager */ - protected $activityManager; - - /** @var IGroupManager */ - protected $groupManager; - - /** @var IUserSession */ - protected $userSession; - - /** @var IAppManager */ - protected $appManager; - - public function __construct(IActivityManager $activityManager, - IGroupManager $groupManager, - IUserSession $userSession, - IAppManager $appManager) { - $this->activityManager = $activityManager; - $this->groupManager = $groupManager; - $this->userSession = $userSession; - $this->appManager = $appManager; + public function __construct( + protected IActivityManager $activityManager, + protected IGroupManager $groupManager, + protected IUserSession $userSession, + protected IAppManager $appManager, + protected IUserManager $userManager, + ) { } /** @@ -103,7 +74,14 @@ class Backend { return; } - $principal = explode('/', $addressbookData['principaluri']); + $principalUri = $addressbookData['principaluri']; + + // We are not interested in changes from the system addressbook + if ($principalUri === 'principals/system/system') { + return; + } + + $principal = explode('/', $principalUri); $owner = array_pop($principal); $currentUser = $this->userSession->getUser(); @@ -115,8 +93,8 @@ class Backend { $event = $this->activityManager->generateEvent(); $event->setApp('dav') - ->setObject('addressbook', (int) $addressbookData['id']) - ->setType('addressbook') + ->setObject('addressbook', (int)$addressbookData['id']) + ->setType('contacts') ->setAuthor($currentUser); $changedVisibleInformation = array_intersect([ @@ -132,13 +110,18 @@ class Backend { } foreach ($users as $user) { + if ($action === Addressbook::SUBJECT_DELETE && !$this->userManager->userExists($user)) { + // Avoid creating addressbook_delete activities for deleted users + continue; + } + $event->setAffectedUser($user) ->setSubject( $user === $currentUser ? $action . '_self' : $action, [ 'actor' => $currentUser, 'addressbook' => [ - 'id' => (int) $addressbookData['id'], + 'id' => (int)$addressbookData['id'], 'uri' => $addressbookData['uri'], 'name' => $addressbookData['{DAV:}displayname'], ], @@ -169,8 +152,8 @@ class Backend { $event = $this->activityManager->generateEvent(); $event->setApp('dav') - ->setObject('addressbook', (int) $addressbookData['id']) - ->setType('addressbook') + ->setObject('addressbook', (int)$addressbookData['id']) + ->setType('contacts') ->setAuthor($currentUser); foreach ($remove as $principal) { @@ -194,7 +177,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'addressbook' => [ - 'id' => (int) $addressbookData['id'], + 'id' => (int)$addressbookData['id'], 'uri' => $addressbookData['uri'], 'name' => $addressbookData['{DAV:}displayname'], ], @@ -223,7 +206,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'addressbook' => [ - 'id' => (int) $addressbookData['id'], + 'id' => (int)$addressbookData['id'], 'uri' => $addressbookData['uri'], 'name' => $addressbookData['{DAV:}displayname'], ], @@ -265,7 +248,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'addressbook' => [ - 'id' => (int) $addressbookData['id'], + 'id' => (int)$addressbookData['id'], 'uri' => $addressbookData['uri'], 'name' => $addressbookData['{DAV:}displayname'], ], @@ -292,7 +275,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'addressbook' => [ - 'id' => (int) $addressbookData['id'], + 'id' => (int)$addressbookData['id'], 'uri' => $addressbookData['uri'], 'name' => $addressbookData['{DAV:}displayname'], ], @@ -370,7 +353,7 @@ class Backend { [ 'actor' => $event->getAuthor(), 'addressbook' => [ - 'id' => (int) $properties['id'], + 'id' => (int)$properties['id'], 'uri' => $properties['uri'], 'name' => $properties['{DAV:}displayname'], ], @@ -393,7 +376,14 @@ class Backend { return; } - $principal = explode('/', $addressbookData['principaluri']); + $principalUri = $addressbookData['principaluri']; + + // We are not interested in changes from the system addressbook + if ($principalUri === 'principals/system/system') { + return; + } + + $principal = explode('/', $principalUri); $owner = array_pop($principal); $currentUser = $this->userSession->getUser(); @@ -407,8 +397,8 @@ class Backend { $event = $this->activityManager->generateEvent(); $event->setApp('dav') - ->setObject('addressbook', (int) $addressbookData['id']) - ->setType('card') + ->setObject('addressbook', (int)$addressbookData['id']) + ->setType('contacts') ->setAuthor($currentUser); $users = $this->getUsersForShares($shares); @@ -419,7 +409,7 @@ class Backend { $params = [ 'actor' => $event->getAuthor(), 'addressbook' => [ - 'id' => (int) $addressbookData['id'], + 'id' => (int)$addressbookData['id'], 'uri' => $addressbookData['uri'], 'name' => $addressbookData['{DAV:}displayname'], ], @@ -446,7 +436,7 @@ class Backend { */ protected function getCardNameAndId(array $cardData): array { $vObject = Reader::read($cardData['carddata']); - return ['id' => (string) $vObject->UID, 'name' => (string) ($vObject->FN ?? '')]; + return ['id' => (string)$vObject->UID, 'name' => (string)($vObject->FN ?? '')]; } /** diff --git a/apps/dav/lib/CardDAV/Activity/Filter.php b/apps/dav/lib/CardDAV/Activity/Filter.php index 3ca4c3367d5..8b221a29ff0 100644 --- a/apps/dav/lib/CardDAV/Activity/Filter.php +++ b/apps/dav/lib/CardDAV/Activity/Filter.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2021 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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV\Activity; @@ -28,15 +12,10 @@ use OCP\IURLGenerator; class Filter 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, + ) { } /** @@ -55,8 +34,8 @@ class Filter 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. */ public function getPriority(): int { return 40; diff --git a/apps/dav/lib/CardDAV/Activity/Provider/Addressbook.php b/apps/dav/lib/CardDAV/Activity/Provider/Addressbook.php index be18fba96cc..cdb9769401f 100644 --- a/apps/dav/lib/CardDAV/Activity/Provider/Addressbook.php +++ b/apps/dav/lib/CardDAV/Activity/Provider/Addressbook.php @@ -3,28 +3,12 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV\Activity\Provider; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; use OCP\Activity\IEventMerger; use OCP\Activity\IManager; @@ -43,25 +27,15 @@ class Addressbook extends Base { public const SUBJECT_UNSHARE_USER = 'addressbook_user_unshare'; public const SUBJECT_UNSHARE_GROUP = 'addressbook_group_unshare'; - /** @var IFactory */ - protected $languageFactory; - - /** @var IManager */ - protected $activityManager; - - /** @var IEventMerger */ - protected $eventMerger; - - public function __construct(IFactory $languageFactory, - IURLGenerator $url, - IManager $activityManager, - IUserManager $userManager, - IGroupManager $groupManager, - IEventMerger $eventMerger) { + public function __construct( + protected IFactory $languageFactory, + IURLGenerator $url, + protected IManager $activityManager, + IUserManager $userManager, + IGroupManager $groupManager, + protected IEventMerger $eventMerger, + ) { parent::__construct($userManager, $groupManager, $url); - $this->languageFactory = $languageFactory; - $this->activityManager = $activityManager; - $this->eventMerger = $eventMerger; } /** @@ -69,11 +43,11 @@ class Addressbook extends Base { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException */ - public function parse($language, IEvent $event, IEvent $previousEvent = null): IEvent { - if ($event->getApp() !== 'dav' || $event->getType() !== 'addressbook') { - throw new \InvalidArgumentException(); + public function parse($language, IEvent $event, ?IEvent $previousEvent = null): IEvent { + if ($event->getApp() !== 'dav' || $event->getType() !== 'contacts') { + throw new UnknownActivityException(); } $l = $this->languageFactory->get('dav', $language); @@ -119,7 +93,7 @@ class Addressbook extends Base { } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_by') { $subject = $l->t('{actor} unshared address book {addressbook} from group {group}'); } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $parsedParameters = $this->getParameters($event, $l); diff --git a/apps/dav/lib/CardDAV/Activity/Provider/Base.php b/apps/dav/lib/CardDAV/Activity/Provider/Base.php index 2f6de31de15..ea7680aed60 100644 --- a/apps/dav/lib/CardDAV/Activity/Provider/Base.php +++ b/apps/dav/lib/CardDAV/Activity/Provider/Base.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV\Activity\Provider; @@ -32,48 +15,24 @@ use OCP\IGroup; use OCP\IGroupManager; use OCP\IL10N; use OCP\IURLGenerator; -use OCP\IUser; use OCP\IUserManager; abstract class Base implements IProvider { - - /** @var IUserManager */ - protected $userManager; - - /** @var string[] */ + /** @var string[] */ protected $userDisplayNames = []; - /** @var IGroupManager */ - protected $groupManager; - /** @var string[] */ protected $groupDisplayNames = []; - /** @var IURLGenerator */ - protected $url; - - public function __construct(IUserManager $userManager, - IGroupManager $groupManager, - IURLGenerator $urlGenerator) { - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->url = $urlGenerator; + public function __construct( + protected IUserManager $userManager, + protected IGroupManager $groupManager, + protected IURLGenerator $url, + ) { } - /** - * @param IEvent $event - * @param string $subject - * @param array $parameters - */ protected function setSubjects(IEvent $event, string $subject, array $parameters): void { - $placeholders = $replacements = []; - foreach ($parameters as $placeholder => $parameter) { - $placeholders[] = '{' . $placeholder . '}'; - $replacements[] = $parameter['name']; - } - - $event->setParsedSubject(str_replace($placeholders, $replacements, $subject)) - ->setRichSubject($subject, $parameters); + $event->setRichSubject($subject, $parameters); } /** @@ -82,51 +41,31 @@ abstract class Base implements IProvider { * @return array */ protected function generateAddressbookParameter(array $data, IL10N $l): array { - if ($data['uri'] === CardDavBackend::PERSONAL_ADDRESSBOOK_URI && - $data['name'] === CardDavBackend::PERSONAL_ADDRESSBOOK_NAME) { + if ($data['uri'] === CardDavBackend::PERSONAL_ADDRESSBOOK_URI + && $data['name'] === CardDavBackend::PERSONAL_ADDRESSBOOK_NAME) { return [ 'type' => 'addressbook', - 'id' => $data['id'], + 'id' => (string)$data['id'], 'name' => $l->t('Personal'), ]; } return [ 'type' => 'addressbook', - 'id' => $data['id'], + 'id' => (string)$data['id'], 'name' => $data['name'], ]; } - /** - * @param string $uid - * @return array - */ protected function generateUserParameter(string $uid): array { - if (!isset($this->userDisplayNames[$uid])) { - $this->userDisplayNames[$uid] = $this->getUserDisplayName($uid); - } - return [ 'type' => 'user', 'id' => $uid, - 'name' => $this->userDisplayNames[$uid], + 'name' => $this->userManager->getDisplayName($uid) ?? $uid, ]; } /** - * @param string $uid - * @return string - */ - protected function getUserDisplayName(string $uid): string { - $user = $this->userManager->get($uid); - if ($user instanceof IUser) { - return $user->getDisplayName(); - } - return $uid; - } - - /** * @param string $gid * @return array */ diff --git a/apps/dav/lib/CardDAV/Activity/Provider/Card.php b/apps/dav/lib/CardDAV/Activity/Provider/Card.php index 9c909ae9bcd..acf23c00531 100644 --- a/apps/dav/lib/CardDAV/Activity/Provider/Card.php +++ b/apps/dav/lib/CardDAV/Activity/Provider/Card.php @@ -3,28 +3,12 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV\Activity\Provider; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; use OCP\Activity\IEventMerger; use OCP\Activity\IManager; @@ -40,30 +24,16 @@ class Card extends Base { public const SUBJECT_UPDATE = 'card_update'; public const SUBJECT_DELETE = 'card_delete'; - /** @var IFactory */ - protected $languageFactory; - - /** @var IManager */ - protected $activityManager; - - /** @var IEventMerger */ - protected $eventMerger; - - /** @var IAppManager */ - protected $appManager; - - public function __construct(IFactory $languageFactory, - IURLGenerator $url, - IManager $activityManager, - IUserManager $userManager, - IGroupManager $groupManager, - IEventMerger $eventMerger, - IAppManager $appManager) { + public function __construct( + protected IFactory $languageFactory, + IURLGenerator $url, + protected IManager $activityManager, + IUserManager $userManager, + IGroupManager $groupManager, + protected IEventMerger $eventMerger, + protected IAppManager $appManager, + ) { parent::__construct($userManager, $groupManager, $url); - $this->languageFactory = $languageFactory; - $this->activityManager = $activityManager; - $this->eventMerger = $eventMerger; - $this->appManager = $appManager; } /** @@ -71,11 +41,11 @@ class Card extends Base { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException */ - public function parse($language, IEvent $event, IEvent $previousEvent = null): IEvent { - if ($event->getApp() !== 'dav' || $event->getType() !== 'card') { - throw new \InvalidArgumentException(); + public function parse($language, IEvent $event, ?IEvent $previousEvent = null): IEvent { + if ($event->getApp() !== 'dav' || $event->getType() !== 'contacts') { + throw new UnknownActivityException(); } $l = $this->languageFactory->get('dav', $language); @@ -99,7 +69,7 @@ class Card extends Base { } elseif ($event->getSubject() === self::SUBJECT_UPDATE . '_self') { $subject = $l->t('You updated contact {card} in address book {addressbook}'); } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $parsedParameters = $this->getParameters($event, $l); diff --git a/apps/dav/lib/CardDAV/Activity/Setting.php b/apps/dav/lib/CardDAV/Activity/Setting.php index a8a83111dde..cc68cf87c83 100644 --- a/apps/dav/lib/CardDAV/Activity/Setting.php +++ b/apps/dav/lib/CardDAV/Activity/Setting.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV\Activity; @@ -44,8 +27,8 @@ class Setting extends CalDAVSetting { /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. */ public function getPriority(): int { return 50; diff --git a/apps/dav/lib/CardDAV/AddressBook.php b/apps/dav/lib/CardDAV/AddressBook.php index 9bd24bedbac..4d30d507a7d 100644 --- a/apps/dav/lib/CardDAV/AddressBook.php +++ b/apps/dav/lib/CardDAV/AddressBook.php @@ -1,47 +1,31 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CardDAV; use OCA\DAV\DAV\Sharing\IShareable; -use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException; +use OCP\DB\Exception; use OCP\IL10N; +use OCP\Server; +use Psr\Log\LoggerInterface; use Sabre\CardDAV\Backend\BackendInterface; -use Sabre\CardDAV\Card; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\IMoveTarget; +use Sabre\DAV\INode; use Sabre\DAV\PropPatch; /** * Class AddressBook * * @package OCA\DAV\CardDAV - * @property BackendInterface|CardDavBackend $carddavBackend + * @property CardDavBackend $carddavBackend */ -class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { - +class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMoveTarget { /** * AddressBook constructor. * @@ -52,8 +36,9 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { public function __construct(BackendInterface $carddavBackend, array $addressBookInfo, IL10N $l10n) { parent::__construct($carddavBackend, $addressBookInfo); - if ($this->addressBookInfo['{DAV:}displayname'] === CardDavBackend::PERSONAL_ADDRESSBOOK_NAME && - $this->getName() === CardDavBackend::PERSONAL_ADDRESSBOOK_URI) { + + if ($this->addressBookInfo['{DAV:}displayname'] === CardDavBackend::PERSONAL_ADDRESSBOOK_NAME + && $this->getName() === CardDavBackend::PERSONAL_ADDRESSBOOK_URI) { $this->addressBookInfo['{DAV:}displayname'] = $l10n->t('Contacts'); } } @@ -67,17 +52,15 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { * 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 + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove * @throws Forbidden */ - public function updateShares(array $add, array $remove) { + public function updateShares(array $add, array $remove): void { if ($this->isShared()) { throw new Forbidden(); } @@ -92,11 +75,10 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { * * commonName - Optional, for example a first + last name * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. * * 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 []; } @@ -113,7 +95,12 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { 'privilege' => '{DAV:}write', 'principal' => $this->getOwner(), 'protected' => true, - ] + ], + [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner(), + 'protected' => true, + ], ]; if ($this->getOwner() === 'principals/system/system') { @@ -122,6 +109,11 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { 'principal' => '{DAV:}authenticated', 'protected' => true, ]; + $acl[] = [ + 'privilege' => '{DAV:}write-properties', + 'principal' => '{DAV:}authenticated', + 'protected' => true, + ]; } if (!$this->isShared()) { @@ -144,7 +136,7 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { } $acl = $this->carddavBackend->applyShareAcl($this->getResourceId(), $acl); - $allowedPrincipals = [$this->getOwner(), parent::getOwner(), 'principals/system/system']; + $allowedPrincipals = [$this->getOwner(), parent::getOwner(), 'principals/system/system', '{DAV:}authenticated']; return array_filter($acl, function ($rule) use ($allowedPrincipals) { return \in_array($rule['principal'], $allowedPrincipals, true); }); @@ -163,14 +155,33 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { return new Card($this->carddavBackend, $this->addressBookInfo, $obj); } - /** - * @return int - */ - public function getResourceId() { + public function getChildren() { + $objs = $this->carddavBackend->getCards($this->addressBookInfo['id']); + $children = []; + foreach ($objs as $obj) { + $obj['acl'] = $this->getChildACL(); + $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + + return $children; + } + + public function getMultipleChildren(array $paths) { + $objs = $this->carddavBackend->getMultipleCards($this->addressBookInfo['id'], $paths); + $children = []; + foreach ($objs as $obj) { + $obj['acl'] = $this->getChildACL(); + $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + + return $children; + } + + public function getResourceId(): int { return $this->addressBookInfo['id']; } - public function getOwner() { + public function getOwner(): ?string { if (isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { return $this->addressBookInfo['{http://owncloud.org/ns}owner-principal']; } @@ -197,17 +208,16 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { } public function propPatch(PropPatch $propPatch) { - if (isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { - throw new Forbidden(); + if (!isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { + parent::propPatch($propPatch); } - parent::propPatch($propPatch); } public function getContactsGroups() { return $this->carddavBackend->collectCardProperties($this->getResourceId(), 'CATEGORIES'); } - private function isShared() { + private function isShared(): bool { if (!isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { return false; } @@ -215,7 +225,7 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { return $this->addressBookInfo['{http://owncloud.org/ns}owner-principal'] !== $this->addressBookInfo['principaluri']; } - private function canWrite() { + private function canWrite(): bool { if (isset($this->addressBookInfo['{http://owncloud.org/ns}read-only'])) { return !$this->addressBookInfo['{http://owncloud.org/ns}read-only']; } @@ -223,10 +233,29 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { } public function getChanges($syncToken, $syncLevel, $limit = null) { - if (!$syncToken && $limit) { - throw new UnsupportedLimitOnInitialSyncException(); - } return parent::getChanges($syncToken, $syncLevel, $limit); } + + /** + * @inheritDoc + */ + public function moveInto($targetName, $sourcePath, INode $sourceNode) { + if (!($sourceNode instanceof Card)) { + return false; + } + + try { + return $this->carddavBackend->moveCard( + $sourceNode->getAddressbookId(), + $sourceNode->getUri(), + $this->getResourceId(), + $targetName, + ); + } catch (Exception $e) { + // Avoid injecting LoggerInterface everywhere + Server::get(LoggerInterface::class)->error('Could not move calendar object: ' . $e->getMessage(), ['exception' => $e]); + return false; + } + } } diff --git a/apps/dav/lib/CardDAV/AddressBookImpl.php b/apps/dav/lib/CardDAV/AddressBookImpl.php index 3db20cb4220..ae77498539b 100644 --- a/apps/dav/lib/CardDAV/AddressBookImpl.php +++ b/apps/dav/lib/CardDAV/AddressBookImpl.php @@ -1,57 +1,22 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arne Hamann <kontakt+github@arne.email> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Björn Schießle <bjoern@schiessle.org> - * @author call-me-matt <nextcloud@matthiasheinisch.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @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\CardDAV; +use OCA\DAV\Db\PropertyMapper; use OCP\Constants; -use OCP\IAddressBook; +use OCP\IAddressBookEnabled; use OCP\IURLGenerator; use Sabre\VObject\Component\VCard; use Sabre\VObject\Property; use Sabre\VObject\Reader; use Sabre\VObject\UUIDUtil; -class AddressBookImpl implements IAddressBook { - - /** @var CardDavBackend */ - private $backend; - - /** @var array */ - private $addressBookInfo; - - /** @var AddressBook */ - private $addressBook; - - /** @var IURLGenerator */ - private $urlGenerator; +class AddressBookImpl implements IAddressBookEnabled { /** * AddressBookImpl constructor. @@ -62,14 +27,13 @@ class AddressBookImpl implements IAddressBook { * @param IUrlGenerator $urlGenerator */ public function __construct( - AddressBook $addressBook, - array $addressBookInfo, - CardDavBackend $backend, - IURLGenerator $urlGenerator) { - $this->addressBook = $addressBook; - $this->addressBookInfo = $addressBookInfo; - $this->backend = $backend; - $this->urlGenerator = $urlGenerator; + private AddressBook $addressBook, + private array $addressBookInfo, + private CardDavBackend $backend, + private IURLGenerator $urlGenerator, + private PropertyMapper $propertyMapper, + private ?string $userId, + ) { } /** @@ -102,19 +66,19 @@ class AddressBookImpl implements IAddressBook { * @param string $pattern which should match within the $searchProperties * @param array $searchProperties defines the properties within the query pattern should match * @param array $options Options to define the output format and search behavior - * - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array - * example: ['id' => 5, 'FN' => 'Thomas Tanghus', 'EMAIL' => ['type => 'HOME', 'value' => 'g@h.i']] - * - 'escape_like_param' - If set to false wildcards _ and % are not escaped - * - 'limit' - Set a numeric limit for the search results - * - 'offset' - Set the offset for the limited search results - * - 'wildcard' - Whether the search should use wildcards + * - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array + * example: ['id' => 5, 'FN' => 'Thomas Tanghus', 'EMAIL' => ['type => 'HOME', 'value' => 'g@h.i']] + * - 'escape_like_param' - If set to false wildcards _ and % are not escaped + * - 'limit' - Set a numeric limit for the search results + * - 'offset' - Set the offset for the limited search results + * - 'wildcard' - Whether the search should use wildcards * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options * @return array an array of contacts which are arrays of key-value-pairs - * example result: - * [ - * ['id' => 0, 'FN' => 'Thomas Müller', 'EMAIL' => 'a@b.c', 'GEO' => '37.386013;-122.082932'], - * ['id' => 5, 'FN' => 'Thomas Tanghus', 'EMAIL' => ['d@e.f', 'g@h.i']] - * ] + * example result: + * [ + * ['id' => 0, 'FN' => 'Thomas Müller', 'EMAIL' => 'a@b.c', 'GEO' => '37.386013;-122.082932'], + * ['id' => 5, 'FN' => 'Thomas Tanghus', 'EMAIL' => ['d@e.f', 'g@h.i']] + * ] * @since 5.0.0 */ public function search($pattern, $searchProperties, $options) { @@ -155,13 +119,13 @@ class AddressBookImpl implements IAddressBook { if (is_string($entry)) { $property = $vCard->createProperty($key, $entry); } else { - if (($key === "ADR" || $key === "PHOTO") && is_string($entry["value"])) { - $entry["value"] = stripslashes($entry["value"]); - $entry["value"] = explode(';', $entry["value"]); + if (($key === 'ADR' || $key === 'PHOTO') && is_string($entry['value'])) { + $entry['value'] = stripslashes($entry['value']); + $entry['value'] = explode(';', $entry['value']); } - $property = $vCard->createProperty($key, $entry["value"]); - if (isset($entry["type"])) { - $property->add('TYPE', $entry["type"]); + $property = $vCard->createProperty($key, $entry['value']); + if (isset($entry['type'])) { + $property->add('TYPE', $entry['type']); } } $vCard->add($property); @@ -188,6 +152,10 @@ class AddressBookImpl implements IAddressBook { $permissions = $this->addressBook->getACL(); $result = 0; foreach ($permissions as $permission) { + if ($this->addressBookInfo['principaluri'] !== $permission['principal']) { + continue; + } + switch ($permission['privilege']) { case '{DAV:}read': $result |= Constants::PERMISSION_READ; @@ -206,7 +174,7 @@ class AddressBookImpl implements IAddressBook { } /** - * @param object $id the unique identifier to a contact + * @param int $id the unique identifier to a contact * @return bool successful or not * @since 5.0.0 */ @@ -272,7 +240,7 @@ class AddressBookImpl implements IAddressBook { ]; foreach ($vCard->children() as $property) { - if ($property->name === 'PHOTO' && $property->getValueType() === 'BINARY') { + if ($property->name === 'PHOTO' && in_array($property->getValueType(), ['BINARY', 'URI'])) { $url = $this->urlGenerator->getAbsoluteURL( $this->urlGenerator->linkTo('', 'remote.php') . '/dav/'); $url .= implode('/', [ @@ -343,8 +311,29 @@ class AddressBookImpl implements IAddressBook { */ public function isSystemAddressBook(): bool { return $this->addressBookInfo['principaluri'] === 'principals/system/system' && ( - $this->addressBookInfo['uri'] === 'system' || - $this->addressBookInfo['{DAV:}displayname'] === $this->urlGenerator->getBaseUrl() + $this->addressBookInfo['uri'] === 'system' + || $this->addressBookInfo['{DAV:}displayname'] === $this->urlGenerator->getBaseUrl() ); } + + public function isEnabled(): bool { + if (!$this->userId) { + return true; + } + + if ($this->isSystemAddressBook()) { + $user = $this->userId ; + $uri = 'z-server-generated--system'; + } else { + $user = str_replace('principals/users/', '', $this->addressBookInfo['principaluri']); + $uri = $this->addressBookInfo['uri']; + } + + $path = 'addressbooks/users/' . $user . '/' . $uri; + $properties = $this->propertyMapper->findPropertyByPathAndName($user, $path, '{http://owncloud.org/ns}enabled'); + if (count($properties) > 0) { + return (bool)$properties[0]->getPropertyvalue(); + } + return true; + } } diff --git a/apps/dav/lib/CardDAV/AddressBookRoot.php b/apps/dav/lib/CardDAV/AddressBookRoot.php index 897ed819071..5679a03545e 100644 --- a/apps/dav/lib/CardDAV/AddressBookRoot.php +++ b/apps/dav/lib/CardDAV/AddressBookRoot.php @@ -1,46 +1,32 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.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: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CardDAV; use OCA\DAV\AppInfo\PluginManager; +use OCP\IGroupManager; +use OCP\IUser; class AddressBookRoot extends \Sabre\CardDAV\AddressBookRoot { - /** @var PluginManager */ - private $pluginManager; - /** * @param \Sabre\DAVACL\PrincipalBackend\BackendInterface $principalBackend * @param \Sabre\CardDAV\Backend\BackendInterface $carddavBackend * @param string $principalPrefix */ - public function __construct(\Sabre\DAVACL\PrincipalBackend\BackendInterface $principalBackend, - \Sabre\CardDAV\Backend\BackendInterface $carddavBackend, - PluginManager $pluginManager, - $principalPrefix = 'principals') { + public function __construct( + \Sabre\DAVACL\PrincipalBackend\BackendInterface $principalBackend, + \Sabre\CardDAV\Backend\BackendInterface $carddavBackend, + private PluginManager $pluginManager, + private ?IUser $user, + private ?IGroupManager $groupManager, + string $principalPrefix = 'principals', + ) { parent::__construct($principalBackend, $carddavBackend, $principalPrefix); - $this->pluginManager = $pluginManager; } /** @@ -55,7 +41,7 @@ class AddressBookRoot extends \Sabre\CardDAV\AddressBookRoot { * @return \Sabre\DAV\INode */ public function getChildForPrincipal(array $principal) { - return new UserAddressBooks($this->carddavBackend, $principal['uri'], $this->pluginManager); + return new UserAddressBooks($this->carddavBackend, $principal['uri'], $this->pluginManager, $this->user, $this->groupManager); } public function getName() { diff --git a/apps/dav/lib/CardDAV/Card.php b/apps/dav/lib/CardDAV/Card.php new file mode 100644 index 00000000000..8cd4fd7e5ee --- /dev/null +++ b/apps/dav/lib/CardDAV/Card.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\CardDAV; + +class Card extends \Sabre\CardDAV\Card { + public function getId(): int { + return (int)$this->cardData['id']; + } + + public function getUri(): string { + return $this->cardData['uri']; + } + + protected function isShared(): bool { + if (!isset($this->cardData['{http://owncloud.org/ns}owner-principal'])) { + return false; + } + + return $this->cardData['{http://owncloud.org/ns}owner-principal'] !== $this->cardData['principaluri']; + } + + public function getAddressbookId(): int { + return (int)$this->cardData['addressbookid']; + } + + public function getPrincipalUri(): string { + return $this->addressBookInfo['principaluri']; + } + + public function getOwner(): ?string { + if (isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { + return $this->addressBookInfo['{http://owncloud.org/ns}owner-principal']; + } + return parent::getOwner(); + } +} diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php index 1c1754ff752..a78686eb61d 100644 --- a/apps/dav/lib/CardDAV/CardDavBackend.php +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -1,40 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arne Hamann <kontakt+github@arne.email> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Chih-Hsuan Yen <yan12125@gmail.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author matt <34400929+call-me-matt@users.noreply.github.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Stefan Weil <sw@weilnetz.de> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CardDAV; +use OC\Search\Filter\DateTimeFilter; use OCA\DAV\Connector\Sabre\Principal; use OCA\DAV\DAV\Sharing\Backend; use OCA\DAV\DAV\Sharing\IShareable; @@ -44,12 +17,14 @@ use OCA\DAV\Events\AddressBookShareUpdatedEvent; use OCA\DAV\Events\AddressBookUpdatedEvent; use OCA\DAV\Events\CardCreatedEvent; use OCA\DAV\Events\CardDeletedEvent; +use OCA\DAV\Events\CardMovedEvent; use OCA\DAV\Events\CardUpdatedEvent; +use OCP\AppFramework\Db\TTransactional; +use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; use OCP\IDBConnection; -use OCP\IGroupManager; -use OCP\IUser; use OCP\IUserManager; use PDO; use Sabre\CardDAV\Backend\BackendInterface; @@ -58,30 +33,17 @@ use Sabre\CardDAV\Plugin; use Sabre\DAV\Exception\BadRequest; use Sabre\VObject\Component\VCard; use Sabre\VObject\Reader; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\EventDispatcher\GenericEvent; class CardDavBackend implements BackendInterface, SyncSupport { + use TTransactional; public const PERSONAL_ADDRESSBOOK_URI = 'contacts'; public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts'; - /** @var Principal */ - private $principalBackend; - - /** @var string */ - private $dbCardsTable = 'cards'; - - /** @var string */ - private $dbCardsPropertiesTable = 'cards_properties'; - - /** @var IDBConnection */ - private $db; - - /** @var Backend */ - private $sharingBackend; + private string $dbCardsTable = 'cards'; + private string $dbCardsPropertiesTable = 'cards_properties'; /** @var array properties to index */ - public static $indexProperties = [ + public static array $indexProperties = [ 'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME', 'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO', 'CLOUD', 'X-SOCIALPROFILE']; @@ -89,41 +51,17 @@ class CardDavBackend implements BackendInterface, SyncSupport { /** * @var string[] Map of uid => display name */ - protected $userDisplayNames; - - /** @var IUserManager */ - private $userManager; - - /** @var IEventDispatcher */ - private $dispatcher; - - /** @var EventDispatcherInterface */ - private $legacyDispatcher; - - private $etagCache = []; - - /** - * CardDavBackend constructor. - * - * @param IDBConnection $db - * @param Principal $principalBackend - * @param IUserManager $userManager - * @param IGroupManager $groupManager - * @param IEventDispatcher $dispatcher - * @param EventDispatcherInterface $legacyDispatcher - */ - public function __construct(IDBConnection $db, - Principal $principalBackend, - IUserManager $userManager, - IGroupManager $groupManager, - IEventDispatcher $dispatcher, - EventDispatcherInterface $legacyDispatcher) { - $this->db = $db; - $this->principalBackend = $principalBackend; - $this->userManager = $userManager; - $this->dispatcher = $dispatcher; - $this->legacyDispatcher = $legacyDispatcher; - $this->sharingBackend = new Backend($this->db, $this->userManager, $groupManager, $principalBackend, 'addressbook'); + protected array $userDisplayNames; + private array $etagCache = []; + + public function __construct( + private IDBConnection $db, + private Principal $principalBackend, + private IUserManager $userManager, + private IEventDispatcher $dispatcher, + private Sharing\Backend $sharingBackend, + private IConfig $config, + ) { } /** @@ -140,7 +78,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); $result = $query->executeQuery(); - $column = (int) $result->fetchOne(); + $column = (int)$result->fetchOne(); $result->closeCursor(); return $column; } @@ -163,87 +101,95 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @return array */ public function getAddressBooksForUser($principalUri) { - $principalUriOriginal = $principalUri; - $principalUri = $this->convertPrincipal($principalUri, true); - $query = $this->db->getQueryBuilder(); - $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) - ->from('addressbooks') - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); + return $this->atomic(function () use ($principalUri) { + $principalUriOriginal = $principalUri; + $principalUri = $this->convertPrincipal($principalUri, true); + $select = $this->db->getQueryBuilder(); + $select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) + ->from('addressbooks') + ->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri))); - $addressBooks = []; + $addressBooks = []; - $result = $query->execute(); - while ($row = $result->fetch()) { - $addressBooks[$row['id']] = [ - 'id' => $row['id'], - 'uri' => $row['uri'], - 'principaluri' => $this->convertPrincipal($row['principaluri'], false), - '{DAV:}displayname' => $row['displayname'], - '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], - '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], - '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', - ]; + $result = $select->executeQuery(); + while ($row = $result->fetch()) { + $addressBooks[$row['id']] = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'principaluri' => $this->convertPrincipal($row['principaluri'], false), + '{DAV:}displayname' => $row['displayname'], + '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], + '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + ]; + + $this->addOwnerPrincipal($addressBooks[$row['id']]); + } + $result->closeCursor(); - $this->addOwnerPrincipal($addressBooks[$row['id']]); - } - $result->closeCursor(); + // query for shared addressbooks + $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); - // query for shared addressbooks - $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); - $principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUriOriginal)); + $principals[] = $principalUri; - $principals[] = $principalUri; + $select = $this->db->getQueryBuilder(); + $subSelect = $this->db->getQueryBuilder(); - $query = $this->db->getQueryBuilder(); - $result = $query->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access']) - ->from('dav_shares', 's') - ->join('s', 'addressbooks', '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', 'addressbook') - ->setParameter('principaluri', $principals, IQueryBuilder::PARAM_STR_ARRAY) - ->execute(); - - $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'; - while ($row = $result->fetch()) { - if ($row['principaluri'] === $principalUri) { - continue; - } + $subSelect->select('id') + ->from('dav_shares', 'd') + ->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\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)); - $readOnly = (int)$row['access'] === Backend::ACCESS_READ; - if (isset($addressBooks[$row['id']])) { - if ($readOnly) { - // New share can not have more permissions then the old one. - continue; - } - if (isset($addressBooks[$row['id']][$readOnlyPropertyName]) && - $addressBooks[$row['id']][$readOnlyPropertyName] === 0) { - // Old share is already read-write, no more permissions can be gained + + $select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access']) + ->from('dav_shares', 's') + ->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id')) + ->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR))) + ->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY)); + $result = $select->executeQuery(); + + $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'; + while ($row = $result->fetch()) { + if ($row['principaluri'] === $principalUri) { continue; } - } - [, $name] = \Sabre\Uri\split($row['principaluri']); - $uri = $row['uri'] . '_shared_by_' . $name; - $displayName = $row['displayname'] . ' (' . $this->getUserDisplayName($name) . ')'; - - $addressBooks[$row['id']] = [ - 'id' => $row['id'], - 'uri' => $uri, - 'principaluri' => $principalUriOriginal, - '{DAV:}displayname' => $displayName, - '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], - '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], - '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'], - $readOnlyPropertyName => $readOnly, - ]; + $readOnly = (int)$row['access'] === Backend::ACCESS_READ; + if (isset($addressBooks[$row['id']])) { + if ($readOnly) { + // New share can not have more permissions then the old one. + continue; + } + if (isset($addressBooks[$row['id']][$readOnlyPropertyName]) + && $addressBooks[$row['id']][$readOnlyPropertyName] === 0) { + // Old share is already read-write, no more permissions can be gained + continue; + } + } - $this->addOwnerPrincipal($addressBooks[$row['id']]); - } - $result->closeCursor(); + [, $name] = \Sabre\Uri\split($row['principaluri']); + $uri = $row['uri'] . '_shared_by_' . $name; + $displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')'; + + $addressBooks[$row['id']] = [ + 'id' => $row['id'], + 'uri' => $uri, + 'principaluri' => $principalUriOriginal, + '{DAV:}displayname' => $displayName, + '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], + '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'], + $readOnlyPropertyName => $readOnly, + ]; + + $this->addOwnerPrincipal($addressBooks[$row['id']]); + } + $result->closeCursor(); - return array_values($addressBooks); + return array_values($addressBooks); + }, $this->db); } public function getUsersOwnAddressBooks($principalUri) { @@ -255,7 +201,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { $addressBooks = []; - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { $addressBooks[$row['id']] = [ 'id' => $row['id'], @@ -274,33 +220,18 @@ class CardDavBackend implements BackendInterface, SyncSupport { return array_values($addressBooks); } - 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]; - } - /** * @param int $addressBookId */ - public function getAddressBookById($addressBookId) { + public function getAddressBookById(int $addressBookId): ?array { $query = $this->db->getQueryBuilder(); $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) ->from('addressbooks') - ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))) - ->execute(); - + ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT))) + ->executeQuery(); $row = $result->fetch(); $result->closeCursor(); - if ($row === false) { + if (!$row) { return null; } @@ -319,18 +250,14 @@ class CardDavBackend implements BackendInterface, SyncSupport { return $addressBook; } - /** - * @param $addressBookUri - * @return array|null - */ - public function getAddressBooksByUri($principal, $addressBookUri) { + public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array { $query = $this->db->getQueryBuilder(); $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) ->from('addressbooks') ->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri))) ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal))) ->setMaxResults(1) - ->execute(); + ->executeQuery(); $row = $result->fetch(); $result->closeCursor(); @@ -346,8 +273,15 @@ class CardDavBackend implements BackendInterface, SyncSupport { '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + ]; + // system address books are always read only + if ($principal === 'principals/system/system') { + $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri']; + $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true; + } + $this->addOwnerPrincipal($addressBook); return $addressBook; @@ -387,19 +321,23 @@ class CardDavBackend implements BackendInterface, SyncSupport { break; } } - $query = $this->db->getQueryBuilder(); - $query->update('addressbooks'); + [$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) { + $query = $this->db->getQueryBuilder(); + $query->update('addressbooks'); - foreach ($updates as $key => $value) { - $query->set($key, $query->createNamedParameter($value)); - } - $query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))) - ->execute(); + foreach ($updates as $key => $value) { + $query->set($key, $query->createNamedParameter($value)); + } + $query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))) + ->executeStatement(); - $this->addChange($addressBookId, "", 2); + $this->addChange($addressBookId, '', 2); + + $addressBookRow = $this->getAddressBookById((int)$addressBookId); + $shares = $this->getShares((int)$addressBookId); + return [$addressBookRow, $shares]; + }, $this->db); - $addressBookRow = $this->getAddressBookById((int)$addressBookId); - $shares = $this->getShares($addressBookId); $this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations)); return true; @@ -414,8 +352,13 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @param array $properties * @return int * @throws BadRequest + * @throws Exception */ public function createAddressBook($principalUri, $url, array $properties) { + if (strlen($url) > 255) { + throw new BadRequest('URI too long. Address book not created'); + } + $values = [ 'displayname' => null, 'description' => null, @@ -443,21 +386,27 @@ class CardDavBackend implements BackendInterface, SyncSupport { $values['displayname'] = $url; } - $query = $this->db->getQueryBuilder(); - $query->insert('addressbooks') - ->values([ - 'uri' => $query->createParameter('uri'), - 'displayname' => $query->createParameter('displayname'), - 'description' => $query->createParameter('description'), - 'principaluri' => $query->createParameter('principaluri'), - 'synctoken' => $query->createParameter('synctoken'), - ]) - ->setParameters($values) - ->execute(); - - $addressBookId = $query->getLastInsertId(); - $addressBookRow = $this->getAddressBookById($addressBookId); - $this->dispatcher->dispatchTyped(new AddressBookCreatedEvent((int)$addressBookId, $addressBookRow)); + [$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) { + $query = $this->db->getQueryBuilder(); + $query->insert('addressbooks') + ->values([ + 'uri' => $query->createParameter('uri'), + 'displayname' => $query->createParameter('displayname'), + 'description' => $query->createParameter('description'), + 'principaluri' => $query->createParameter('principaluri'), + 'synctoken' => $query->createParameter('synctoken'), + ]) + ->setParameters($values) + ->executeStatement(); + + $addressBookId = $query->getLastInsertId(); + return [ + $addressBookId, + $this->getAddressBookById($addressBookId), + ]; + }, $this->db); + + $this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow)); return $addressBookId; } @@ -469,34 +418,40 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @return void */ public function deleteAddressBook($addressBookId) { - $addressBookData = $this->getAddressBookById($addressBookId); - $shares = $this->getShares($addressBookId); + $this->atomic(function () use ($addressBookId): void { + $addressBookId = (int)$addressBookId; + $addressBookData = $this->getAddressBookById($addressBookId); + $shares = $this->getShares($addressBookId); - $query = $this->db->getQueryBuilder(); - $query->delete($this->dbCardsTable) - ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) - ->setParameter('addressbookid', $addressBookId) - ->execute(); + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbCardsTable) + ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) + ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT) + ->executeStatement(); - $query->delete('addressbookchanges') - ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) - ->setParameter('addressbookid', $addressBookId) - ->execute(); + $query = $this->db->getQueryBuilder(); + $query->delete('addressbookchanges') + ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) + ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT) + ->executeStatement(); - $query->delete('addressbooks') - ->where($query->expr()->eq('id', $query->createParameter('id'))) - ->setParameter('id', $addressBookId) - ->execute(); + $query = $this->db->getQueryBuilder(); + $query->delete('addressbooks') + ->where($query->expr()->eq('id', $query->createParameter('id'))) + ->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT) + ->executeStatement(); - $this->sharingBackend->deleteAllShares($addressBookId); + $this->sharingBackend->deleteAllShares($addressBookId); - $query->delete($this->dbCardsPropertiesTable) - ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) - ->execute(); + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbCardsPropertiesTable) + ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); - if ($addressBookData) { - $this->dispatcher->dispatchTyped(new AddressBookDeletedEvent((int) $addressBookId, $addressBookData, $shares)); - } + if ($addressBookData) { + $this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares)); + } + }, $this->db); } /** @@ -512,21 +467,21 @@ class CardDavBackend implements BackendInterface, SyncSupport { * * size - The size of the card in bytes. * * If these last two properties are provided, less time will be spent - * calculating them. If they are specified, you can also ommit carddata. + * calculating them. If they are specified, you can also omit carddata. * This may speed up certain requests, especially with large cards. * - * @param mixed $addressBookId + * @param mixed $addressbookId * @return array */ - public function getCards($addressBookId) { + public function getCards($addressbookId) { $query = $this->db->getQueryBuilder(); - $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) + $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) ->from($this->dbCardsTable) - ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); + ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId))); $cards = []; - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { $row['etag'] = '"' . $row['etag'] . '"'; @@ -557,13 +512,13 @@ class CardDavBackend implements BackendInterface, SyncSupport { */ public function getCard($addressBookId, $cardUri) { $query = $this->db->getQueryBuilder(); - $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) + $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) ->from($this->dbCardsTable) ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) ->setMaxResults(1); - $result = $query->execute(); + $result = $query->executeQuery(); $row = $result->fetch(); if (!$row) { return false; @@ -588,7 +543,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { * If the backend supports this, it may allow for some speed-ups. * * @param mixed $addressBookId - * @param string[] $uris + * @param array $uris * @return array */ public function getMultipleCards($addressBookId, array $uris) { @@ -600,14 +555,14 @@ class CardDavBackend implements BackendInterface, SyncSupport { $cards = []; $query = $this->db->getQueryBuilder(); - $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) + $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid']) ->from($this->dbCardsTable) ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) ->andWhere($query->expr()->in('uri', $query->createParameter('uri'))); foreach ($chunks as $uris) { $query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY); - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { $row['etag'] = '"' . $row['etag'] . '"'; @@ -648,55 +603,54 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @param mixed $addressBookId * @param string $cardUri * @param string $cardData + * @param bool $checkAlreadyExists * @return string */ - public function createCard($addressBookId, $cardUri, $cardData) { + public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) { $etag = md5($cardData); $uid = $this->getUID($cardData); + return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) { + if ($checkAlreadyExists) { + $q = $this->db->getQueryBuilder(); + $q->select('uid') + ->from($this->dbCardsTable) + ->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId))) + ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid))) + ->setMaxResults(1); + $result = $q->executeQuery(); + $count = (bool)$result->fetchOne(); + $result->closeCursor(); + if ($count) { + throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.'); + } + } - $q = $this->db->getQueryBuilder(); - $q->select('uid') - ->from($this->dbCardsTable) - ->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId))) - ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid))) - ->setMaxResults(1); - $result = $q->execute(); - $count = (bool)$result->fetchOne(); - $result->closeCursor(); - if ($count) { - throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.'); - } + $query = $this->db->getQueryBuilder(); + $query->insert('cards') + ->values([ + 'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB), + 'uri' => $query->createNamedParameter($cardUri), + 'lastmodified' => $query->createNamedParameter(time()), + 'addressbookid' => $query->createNamedParameter($addressBookId), + 'size' => $query->createNamedParameter(strlen($cardData)), + 'etag' => $query->createNamedParameter($etag), + 'uid' => $query->createNamedParameter($uid), + ]) + ->executeStatement(); - $query = $this->db->getQueryBuilder(); - $query->insert('cards') - ->values([ - 'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB), - 'uri' => $query->createNamedParameter($cardUri), - 'lastmodified' => $query->createNamedParameter(time()), - 'addressbookid' => $query->createNamedParameter($addressBookId), - 'size' => $query->createNamedParameter(strlen($cardData)), - 'etag' => $query->createNamedParameter($etag), - 'uid' => $query->createNamedParameter($uid), - ]) - ->execute(); - - $etagCacheKey = "$addressBookId#$cardUri"; - $this->etagCache[$etagCacheKey] = $etag; - - $this->addChange($addressBookId, $cardUri, 1); - $this->updateProperties($addressBookId, $cardUri, $cardData); - - $addressBookData = $this->getAddressBookById($addressBookId); - $shares = $this->getShares($addressBookId); - $objectRow = $this->getCard($addressBookId, $cardUri); - $this->dispatcher->dispatchTyped(new CardCreatedEvent((int)$addressBookId, $addressBookData, $shares, $objectRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CardDAV\CardDavBackend::createCard', - new GenericEvent(null, [ - 'addressBookId' => $addressBookId, - 'cardUri' => $cardUri, - 'cardData' => $cardData])); - - return '"' . $etag . '"'; + $etagCacheKey = "$addressBookId#$cardUri"; + $this->etagCache[$etagCacheKey] = $etag; + + $this->addChange($addressBookId, $cardUri, 1); + $this->updateProperties($addressBookId, $cardUri, $cardData); + + $addressBookData = $this->getAddressBookById($addressBookId); + $shares = $this->getShares($addressBookId); + $objectRow = $this->getCard($addressBookId, $cardUri); + $this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow)); + + return '"' . $etag . '"'; + }, $this->db); } /** @@ -727,40 +681,81 @@ class CardDavBackend implements BackendInterface, SyncSupport { public function updateCard($addressBookId, $cardUri, $cardData) { $uid = $this->getUID($cardData); $etag = md5($cardData); - $query = $this->db->getQueryBuilder(); - // check for recently stored etag and stop if it is the same - $etagCacheKey = "$addressBookId#$cardUri"; - if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) { + return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) { + $query = $this->db->getQueryBuilder(); + + // check for recently stored etag and stop if it is the same + $etagCacheKey = "$addressBookId#$cardUri"; + if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) { + return '"' . $etag . '"'; + } + + $query->update($this->dbCardsTable) + ->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB)) + ->set('lastmodified', $query->createNamedParameter(time())) + ->set('size', $query->createNamedParameter(strlen($cardData))) + ->set('etag', $query->createNamedParameter($etag)) + ->set('uid', $query->createNamedParameter($uid)) + ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) + ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) + ->executeStatement(); + + $this->etagCache[$etagCacheKey] = $etag; + + $this->addChange($addressBookId, $cardUri, 2); + $this->updateProperties($addressBookId, $cardUri, $cardData); + + $addressBookData = $this->getAddressBookById($addressBookId); + $shares = $this->getShares($addressBookId); + $objectRow = $this->getCard($addressBookId, $cardUri); + $this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow)); return '"' . $etag . '"'; - } + }, $this->db); + } - $query->update($this->dbCardsTable) - ->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB)) - ->set('lastmodified', $query->createNamedParameter(time())) - ->set('size', $query->createNamedParameter(strlen($cardData))) - ->set('etag', $query->createNamedParameter($etag)) - ->set('uid', $query->createNamedParameter($uid)) - ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) - ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) - ->execute(); - - $this->etagCache[$etagCacheKey] = $etag; - - $this->addChange($addressBookId, $cardUri, 2); - $this->updateProperties($addressBookId, $cardUri, $cardData); - - $addressBookData = $this->getAddressBookById($addressBookId); - $shares = $this->getShares($addressBookId); - $objectRow = $this->getCard($addressBookId, $cardUri); - $this->dispatcher->dispatchTyped(new CardUpdatedEvent((int)$addressBookId, $addressBookData, $shares, $objectRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CardDAV\CardDavBackend::updateCard', - new GenericEvent(null, [ - 'addressBookId' => $addressBookId, - 'cardUri' => $cardUri, - 'cardData' => $cardData])); - - return '"' . $etag . '"'; + /** + * @throws Exception + */ + public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool { + return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) { + $card = $this->getCard($sourceAddressBookId, $sourceObjectUri); + if (empty($card)) { + return false; + } + $sourceObjectId = (int)$card['id']; + + $query = $this->db->getQueryBuilder(); + $query->update('cards') + ->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT)) + ->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR)) + ->where($query->expr()->eq('uri', $query->createNamedParameter($sourceObjectUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)) + ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->executeStatement(); + + $this->purgeProperties($sourceAddressBookId, $sourceObjectId); + $this->updateProperties($targetAddressBookId, $tragetObjectUri, $card['carddata']); + + $this->addChange($sourceAddressBookId, $sourceObjectUri, 3); + $this->addChange($targetAddressBookId, $tragetObjectUri, 1); + + $card = $this->getCard($targetAddressBookId, $tragetObjectUri); + // Card wasn't found - possibly because it was deleted in the meantime by a different client + if (empty($card)) { + return false; + } + $targetAddressBookRow = $this->getAddressBookById($targetAddressBookId); + // the address book this card is being moved to does not exist any longer + if (empty($targetAddressBookRow)) { + return false; + } + + $sourceShares = $this->getShares($sourceAddressBookId); + $targetShares = $this->getShares($targetAddressBookId); + $sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId); + $this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $card)); + return true; + }, $this->db); } /** @@ -771,37 +766,34 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @return bool */ public function deleteCard($addressBookId, $cardUri) { - $addressBookData = $this->getAddressBookById($addressBookId); - $shares = $this->getShares($addressBookId); - $objectRow = $this->getCard($addressBookId, $cardUri); - - try { - $cardId = $this->getCardId($addressBookId, $cardUri); - } catch (\InvalidArgumentException $e) { - $cardId = null; - } - $query = $this->db->getQueryBuilder(); - $ret = $query->delete($this->dbCardsTable) - ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) - ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) - ->execute(); + return $this->atomic(function () use ($addressBookId, $cardUri) { + $addressBookData = $this->getAddressBookById($addressBookId); + $shares = $this->getShares($addressBookId); + $objectRow = $this->getCard($addressBookId, $cardUri); - $this->addChange($addressBookId, $cardUri, 3); + try { + $cardId = $this->getCardId($addressBookId, $cardUri); + } catch (\InvalidArgumentException $e) { + $cardId = null; + } + $query = $this->db->getQueryBuilder(); + $ret = $query->delete($this->dbCardsTable) + ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) + ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) + ->executeStatement(); - if ($ret === 1) { - if ($cardId !== null) { - $this->dispatcher->dispatchTyped(new CardDeletedEvent((int)$addressBookId, $addressBookData, $shares, $objectRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CardDAV\CardDavBackend::deleteCard', - new GenericEvent(null, [ - 'addressBookId' => $addressBookId, - 'cardUri' => $cardUri])); + $this->addChange($addressBookId, $cardUri, 3); - $this->purgeProperties($addressBookId, $cardId); + if ($ret === 1) { + if ($cardId !== null) { + $this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow)); + $this->purgeProperties($addressBookId, $cardId); + } + return true; } - return true; - } - return false; + return false; + }, $this->db); } /** @@ -861,82 +853,147 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @return array */ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) { + $maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500); + $limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit); // Current synctoken - $qb = $this->db->getQueryBuilder(); - $qb->select('synctoken') - ->from('addressbooks') - ->where( - $qb->expr()->eq('id', $qb->createNamedParameter($addressBookId)) - ); - $stmt = $qb->execute(); - $currentToken = $stmt->fetchOne(); - $stmt->closeCursor(); - - if (is_null($currentToken)) { - return null; - } - - $result = [ - 'syncToken' => $currentToken, - 'added' => [], - 'modified' => [], - 'deleted' => [], - ]; - - if ($syncToken) { + return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) { $qb = $this->db->getQueryBuilder(); - $qb->select('uri', 'operation') - ->from('addressbookchanges') + $qb->select('synctoken') + ->from('addressbooks') ->where( - $qb->expr()->andX( - $qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)), - $qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)), - $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)) - ) - )->orderBy('synctoken'); + $qb->expr()->eq('id', $qb->createNamedParameter($addressBookId)) + ); + $stmt = $qb->executeQuery(); + $currentToken = $stmt->fetchOne(); + $stmt->closeCursor(); - if (is_int($limit) && $limit > 0) { - $qb->setMaxResults($limit); + if (is_null($currentToken)) { + return []; } - // Fetching all changes - $stmt = $qb->execute(); + $result = [ + 'syncToken' => $currentToken, + 'added' => [], + 'modified' => [], + 'deleted' => [], + ]; + if (str_starts_with($syncToken, 'init_')) { + $syncValues = explode('_', $syncToken); + $lastID = $syncValues[1]; + $initialSyncToken = $syncValues[2]; + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'uri') + ->from('cards') + ->where( + $qb->expr()->andX( + $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)), + $qb->expr()->gt('id', $qb->createNamedParameter($lastID))) + )->orderBy('id') + ->setMaxResults($limit); + $stmt = $qb->executeQuery(); + $values = $stmt->fetchAll(\PDO::FETCH_ASSOC); + $stmt->closeCursor(); + if (count($values) === 0) { + $result['syncToken'] = $initialSyncToken; + $result['result_truncated'] = false; + $result['added'] = []; + } else { + $lastID = $values[array_key_last($values)]['id']; + $result['added'] = array_column($values, 'uri'); + $result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ; + $result['result_truncated'] = count($result['added']) >= $limit; + } + } elseif ($syncToken) { + $qb = $this->db->getQueryBuilder(); + $qb->select('uri', 'operation', 'synctoken') + ->from('addressbookchanges') + ->where( + $qb->expr()->andX( + $qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)), + $qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)), + $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)) + ) + )->orderBy('synctoken'); + + if ($limit > 0) { + $qb->setMaxResults($limit); + } - $changes = []; + // Fetching all changes + $stmt = $qb->executeQuery(); + $rowCount = $stmt->rowCount(); - // 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']; - } - $stmt->closeCursor(); + $changes = []; + $highestSyncToken = 0; - 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; + // 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']; + $highestSyncToken = $row['synctoken']; + } + + $stmt->closeCursor(); + + // No changes found, use current token + if (empty($changes)) { + $result['syncToken'] = $currentToken; + } + + 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; + } + } + + /* + * The synctoken in oc_addressbooks is always the highest synctoken in oc_addressbookchanges for a given addressbook plus one (see addChange). + * + * For truncated results, it is expected that we return the highest token from the response, so the client can continue from the latest change. + * + * For non-truncated results, it is expected to return the currentToken. If we return the highest token, as with truncated results, the client will always think it is one change behind. + * + * Therefore, we differentiate between truncated and non-truncated results when returning the synctoken. + */ + if ($rowCount === $limit && $highestSyncToken < $currentToken) { + $result['syncToken'] = $highestSyncToken; + $result['result_truncated'] = true; + } + } else { + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'uri') + ->from('cards') + ->where( + $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)) + ); + // No synctoken supplied, this is the initial sync. + $qb->setMaxResults($limit); + $stmt = $qb->executeQuery(); + $values = $stmt->fetchAll(\PDO::FETCH_ASSOC); + if (empty($values)) { + $result['added'] = []; + return $result; } + $lastID = $values[array_key_last($values)]['id']; + if (count($values) >= $limit) { + $result['syncToken'] = 'init_' . $lastID . '_' . $currentToken; + $result['result_truncated'] = true; + } + + $result['added'] = array_column($values, 'uri'); + + $stmt->closeCursor(); } - } else { - $qb = $this->db->getQueryBuilder(); - $qb->select('uri') - ->from('cards') - ->where( - $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)) - ); - // No synctoken supplied, this is the initial sync. - $stmt = $qb->execute(); - $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); - $stmt->closeCursor(); - } - return $result; + return $result; + }, $this->db); } /** @@ -947,19 +1004,33 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @param int $operation 1 = add, 2 = modify, 3 = delete * @return void */ - protected function addChange($addressBookId, $objectUri, $operation) { - $sql = 'INSERT INTO `*PREFIX*addressbookchanges`(`uri`, `synctoken`, `addressbookid`, `operation`) SELECT ?, `synctoken`, ?, ? FROM `*PREFIX*addressbooks` WHERE `id` = ?'; - $stmt = $this->db->prepare($sql); - $stmt->execute([ - $objectUri, - $addressBookId, - $operation, - $addressBookId - ]); - $stmt = $this->db->prepare('UPDATE `*PREFIX*addressbooks` SET `synctoken` = `synctoken` + 1 WHERE `id` = ?'); - $stmt->execute([ - $addressBookId - ]); + protected function addChange(int $addressBookId, string $objectUri, int $operation): void { + $this->atomic(function () use ($addressBookId, $objectUri, $operation): void { + $query = $this->db->getQueryBuilder(); + $query->select('synctoken') + ->from('addressbooks') + ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))); + $result = $query->executeQuery(); + $syncToken = (int)$result->fetchOne(); + $result->closeCursor(); + + $query = $this->db->getQueryBuilder(); + $query->insert('addressbookchanges') + ->values([ + 'uri' => $query->createNamedParameter($objectUri), + 'synctoken' => $query->createNamedParameter($syncToken), + 'addressbookid' => $query->createNamedParameter($addressBookId), + 'operation' => $query->createNamedParameter($operation), + 'created_at' => time(), + ]) + ->executeStatement(); + + $query = $this->db->getQueryBuilder(); + $query->update('addressbooks') + ->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))) + ->executeStatement(); + }, $this->db); } /** @@ -972,13 +1043,19 @@ class CardDavBackend implements BackendInterface, SyncSupport { $cardData = stream_get_contents($cardData); } + // Micro optimisation + // don't loop through + if (str_starts_with($cardData, 'PHOTO:data:')) { + return $cardData; + } + $cardDataArray = explode("\r\n", $cardData); $cardDataFiltered = []; $removingPhoto = false; foreach ($cardDataArray as $line) { - if (strpos($line, 'PHOTO:data:') === 0 - && strpos($line, 'PHOTO:data:image/') !== 0) { + if (str_starts_with($line, 'PHOTO:data:') + && !str_starts_with($line, 'PHOTO:data:image/')) { // Filter out PHOTO data of non-images $removingPhoto = true; $modified = true; @@ -986,7 +1063,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { } if ($removingPhoto) { - if (strpos($line, ' ') === 0) { + if (str_starts_with($line, ' ')) { continue; } // No leading space means this is a new property @@ -995,23 +1072,23 @@ class CardDavBackend implements BackendInterface, SyncSupport { $cardDataFiltered[] = $line; } - return implode("\r\n", $cardDataFiltered); } /** - * @param IShareable $shareable - * @param string[] $add - * @param string[] $remove + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove */ - public function updateShares(IShareable $shareable, $add, $remove) { - $addressBookId = $shareable->getResourceId(); - $addressBookData = $this->getAddressBookById($addressBookId); - $oldShares = $this->getShares($addressBookId); + public function updateShares(IShareable $shareable, array $add, array $remove): void { + $this->atomic(function () use ($shareable, $add, $remove): void { + $addressBookId = $shareable->getResourceId(); + $addressBookData = $this->getAddressBookById($addressBookId); + $oldShares = $this->getShares($addressBookId); - $this->sharingBackend->updateShares($shareable, $add, $remove); + $this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares); - $this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove)); + $this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove)); + }, $this->db); } /** @@ -1021,15 +1098,18 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @param string $pattern which should match within the $searchProperties * @param array $searchProperties defines the properties within the query pattern should match * @param array $options = array() to define the search behavior - * - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are - * - 'limit' - Set a numeric limit for the search results - * - 'offset' - Set the offset for the limited search results - * - 'wildcard' - Whether the search should use wildcards - * @psalm-param array{escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options + * - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array + * - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are + * - 'limit' - Set a numeric limit for the search results + * - 'offset' - Set the offset for the limited search results + * - 'wildcard' - Whether the search should use wildcards + * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options * @return array an array of contacts which are arrays of key-value-pairs */ public function search($addressBookId, $pattern, $searchProperties, $options = []): array { - return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options); + return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) { + return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options); + }, $this->db); } /** @@ -1042,71 +1122,74 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @return array */ public function searchPrincipalUri(string $principalUri, - string $pattern, - array $searchProperties, - array $options = []): array { - $addressBookIds = array_map(static function ($row):int { - return (int) $row['id']; - }, $this->getAddressBooksForUser($principalUri)); - - return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options); + string $pattern, + array $searchProperties, + array $options = []): array { + return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) { + $addressBookIds = array_map(static function ($row):int { + return (int)$row['id']; + }, $this->getAddressBooksForUser($principalUri)); + + return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options); + }, $this->db); } /** - * @param array $addressBookIds + * @param int[] $addressBookIds * @param string $pattern * @param array $searchProperties * @param array $options - * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options + * @psalm-param array{ + * types?: bool, + * escape_like_param?: bool, + * limit?: int, + * offset?: int, + * wildcard?: bool, + * since?: DateTimeFilter|null, + * until?: DateTimeFilter|null, + * person?: string + * } $options * @return array */ private function searchByAddressBookIds(array $addressBookIds, - string $pattern, - array $searchProperties, - array $options = []): array { - $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false; - $useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false; - - $query2 = $this->db->getQueryBuilder(); - - $addressBookOr = $query2->expr()->orX(); - foreach ($addressBookIds as $addressBookId) { - $addressBookOr->add($query2->expr()->eq('cp.addressbookid', $query2->createNamedParameter($addressBookId))); - } - - if ($addressBookOr->count() === 0) { + string $pattern, + array $searchProperties, + array $options = []): array { + if (empty($addressBookIds)) { return []; } + $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false; + $useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false; - $propertyOr = $query2->expr()->orX(); - foreach ($searchProperties as $property) { - if ($escapePattern) { - if ($property === 'EMAIL' && strpos($pattern, ' ') !== false) { + if ($escapePattern) { + $searchProperties = array_filter($searchProperties, function ($property) use ($pattern) { + if ($property === 'EMAIL' && str_contains($pattern, ' ')) { // There can be no spaces in emails - continue; + return false; } if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) { // There can be no chars in cloud ids which are not valid for user ids plus :/ // worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/ - continue; + return false; } - } - $propertyOr->add($query2->expr()->eq('cp.name', $query2->createNamedParameter($property))); + return true; + }); } - if ($propertyOr->count() === 0) { + if (empty($searchProperties)) { return []; } + $query2 = $this->db->getQueryBuilder(); $query2->selectDistinct('cp.cardid') ->from($this->dbCardsPropertiesTable, 'cp') - ->andWhere($addressBookOr) - ->andWhere($propertyOr); + ->where($query2->expr()->in('cp.addressbookid', $query2->createNamedParameter($addressBookIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY)) + ->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY))); // No need for like when the pattern is empty - if ('' !== $pattern) { + if ($pattern !== '') { if (!$useWildcards) { $query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern))); } elseif (!$escapePattern) { @@ -1115,7 +1198,6 @@ class CardDavBackend implements BackendInterface, SyncSupport { $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%'))); } } - if (isset($options['limit'])) { $query2->setMaxResults($options['limit']); } @@ -1123,7 +1205,33 @@ class CardDavBackend implements BackendInterface, SyncSupport { $query2->setFirstResult($options['offset']); } - $result = $query2->execute(); + if (isset($options['person'])) { + $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%'))); + } + if (isset($options['since']) || isset($options['until'])) { + $query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid'); + $query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY'))); + /** + * FIXME Find a way to match only 4 last digits + * BDAY can be --1018 without year or 20001019 with it + * $bDayOr = []; + * if ($options['since'] instanceof DateTimeFilter) { + * $bDayOr[] = + * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)', + * $query2->createNamedParameter($options['since']->get()->format('md')) + * ); + * } + * if ($options['until'] instanceof DateTimeFilter) { + * $bDayOr[] = + * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)', + * $query2->createNamedParameter($options['until']->get()->format('md')) + * ); + * } + * $query2->andWhere($query2->expr()->orX(...$bDayOr)); + */ + } + + $result = $query2->executeQuery(); $matches = $result->fetchAll(); $result->closeCursor(); $matches = array_map(function ($match) { @@ -1144,7 +1252,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { } return array_map(function ($array) { - $array['addressbookid'] = (int) $array['addressbookid']; + $array['addressbookid'] = (int)$array['addressbookid']; $modified = false; $array['carddata'] = $this->readBlob($array['carddata'], $modified); if ($modified) { @@ -1165,7 +1273,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { ->from($this->dbCardsPropertiesTable) ->where($query->expr()->eq('name', $query->createNamedParameter($name))) ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId))) - ->execute(); + ->executeQuery(); $all = $result->fetchAll(PDO::FETCH_COLUMN); $result->closeCursor(); @@ -1185,7 +1293,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { ->where($query->expr()->eq('id', $query->createParameter('id'))) ->setParameter('id', $id); - $result = $query->execute(); + $result = $query->executeQuery(); $uri = $result->fetch(); $result->closeCursor(); @@ -1209,7 +1317,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { $query->select('*')->from($this->dbCardsTable) ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); - $queryResult = $query->execute(); + $queryResult = $query->executeQuery(); $contact = $queryResult->fetch(); $queryResult->closeCursor(); @@ -1235,11 +1343,10 @@ class CardDavBackend implements BackendInterface, SyncSupport { * * commonName - Optional, for example a first + last name * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. * * 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($addressBookId) { + public function getShares(int $addressBookId): array { return $this->sharingBackend->getShares($addressBookId); } @@ -1251,39 +1358,41 @@ class CardDavBackend implements BackendInterface, SyncSupport { * @param string $vCardSerialized */ protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) { - $cardId = $this->getCardId($addressBookId, $cardUri); - $vCard = $this->readCard($vCardSerialized); + $this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void { + $cardId = $this->getCardId($addressBookId, $cardUri); + $vCard = $this->readCard($vCardSerialized); - $this->purgeProperties($addressBookId, $cardId); + $this->purgeProperties($addressBookId, $cardId); - $query = $this->db->getQueryBuilder(); - $query->insert($this->dbCardsPropertiesTable) - ->values( - [ - 'addressbookid' => $query->createNamedParameter($addressBookId), - 'cardid' => $query->createNamedParameter($cardId), - 'name' => $query->createParameter('name'), - 'value' => $query->createParameter('value'), - 'preferred' => $query->createParameter('preferred') - ] - ); + $query = $this->db->getQueryBuilder(); + $query->insert($this->dbCardsPropertiesTable) + ->values( + [ + 'addressbookid' => $query->createNamedParameter($addressBookId), + 'cardid' => $query->createNamedParameter($cardId), + 'name' => $query->createParameter('name'), + 'value' => $query->createParameter('value'), + 'preferred' => $query->createParameter('preferred') + ] + ); - foreach ($vCard->children() as $property) { - if (!in_array($property->name, self::$indexProperties)) { - continue; - } - $preferred = 0; - foreach ($property->parameters as $parameter) { - if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') { - $preferred = 1; - break; + foreach ($vCard->children() as $property) { + if (!in_array($property->name, self::$indexProperties)) { + continue; + } + $preferred = 0; + foreach ($property->parameters as $parameter) { + if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') { + $preferred = 1; + break; + } } + $query->setParameter('name', $property->name); + $query->setParameter('value', mb_strcut($property->getValue(), 0, 254)); + $query->setParameter('preferred', $preferred); + $query->executeStatement(); } - $query->setParameter('name', $property->name); - $query->setParameter('value', mb_strcut($property->getValue(), 0, 254)); - $query->setParameter('preferred', $preferred); - $query->execute(); - } + }, $this->db); } /** @@ -1307,23 +1416,19 @@ class CardDavBackend implements BackendInterface, SyncSupport { $query->delete($this->dbCardsPropertiesTable) ->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId))) ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); - $query->execute(); + $query->executeStatement(); } /** - * get ID from a given contact - * - * @param int $addressBookId - * @param string $uri - * @return int + * Get ID from a given contact */ - protected function getCardId($addressBookId, $uri) { + protected function getCardId(int $addressBookId, string $uri): int { $query = $this->db->getQueryBuilder(); $query->select('id')->from($this->dbCardsTable) ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); - $result = $query->execute(); + $result = $query->executeQuery(); $cardIds = $result->fetch(); $result->closeCursor(); @@ -1337,15 +1442,44 @@ class CardDavBackend implements BackendInterface, SyncSupport { /** * For shared address books the sharee is set in the ACL of the address book * - * @param $addressBookId - * @param $acl - * @return array + * @param int $addressBookId + * @param list<array{privilege: string, principal: string, protected: bool}> $acl + * @return list<array{privilege: string, principal: string, protected: bool}> */ - public function applyShareAcl($addressBookId, $acl) { - return $this->sharingBackend->applyShareAcl($addressBookId, $acl); + public function applyShareAcl(int $addressBookId, array $acl): array { + $shares = $this->sharingBackend->getShares($addressBookId); + return $this->sharingBackend->applyShareAcl($shares, $acl); + } + + /** + * @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('addressbookchanges'); + + $result = $query->executeQuery(); + $maxId = (int)$result->fetchOne(); + $result->closeCursor(); + if (!$maxId || $maxId < $keep) { + return 0; + } + + $query = $this->db->getQueryBuilder(); + $query->delete('addressbookchanges') + ->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(); } - private function convertPrincipal($principalUri, $toV2) { + private function convertPrincipal(string $principalUri, bool $toV2): string { if ($this->principalBackend->getPrincipalPrefix() === 'principals') { [, $name] = \Sabre\Uri\split($principalUri); if ($toV2 === true) { @@ -1356,7 +1490,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { return $principalUri; } - private function addOwnerPrincipal(&$addressbookInfo) { + private function addOwnerPrincipal(array &$addressbookInfo): void { $ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'; $displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname'; if (isset($addressbookInfo[$ownerPrincipalKey])) { @@ -1376,10 +1510,10 @@ class CardDavBackend implements BackendInterface, SyncSupport { * * @param string $cardData the vcard raw data * @return string the uid - * @throws BadRequest if no UID is available + * @throws BadRequest if no UID is available or vcard is empty */ - private function getUID($cardData) { - if ($cardData != '') { + private function getUID(string $cardData): string { + if ($cardData !== '') { $vCard = Reader::read($cardData); if ($vCard->UID) { $uid = $vCard->UID->getValue(); diff --git a/apps/dav/lib/CardDAV/ContactsManager.php b/apps/dav/lib/CardDAV/ContactsManager.php index bed1e676337..b35137c902d 100644 --- a/apps/dav/lib/CardDAV/ContactsManager.php +++ b/apps/dav/lib/CardDAV/ContactsManager.php @@ -1,50 +1,29 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tobia De Koninck <tobia@ledfan.be> - * - * @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\CardDAV; +use OCA\DAV\Db\PropertyMapper; use OCP\Contacts\IManager; use OCP\IL10N; use OCP\IURLGenerator; class ContactsManager { - /** @var CardDavBackend */ - private $backend; - - /** @var IL10N */ - private $l10n; - /** * ContactsManager constructor. * * @param CardDavBackend $backend * @param IL10N $l10n */ - public function __construct(CardDavBackend $backend, IL10N $l10n) { - $this->backend = $backend; - $this->l10n = $l10n; + public function __construct( + private CardDavBackend $backend, + private IL10N $l10n, + private PropertyMapper $propertyMapper, + ) { } /** @@ -54,33 +33,37 @@ class ContactsManager { */ public function setupContactsProvider(IManager $cm, $userId, IURLGenerator $urlGenerator) { $addressBooks = $this->backend->getAddressBooksForUser("principals/users/$userId"); - $this->register($cm, $addressBooks, $urlGenerator); - $this->setupSystemContactsProvider($cm, $urlGenerator); + $this->register($cm, $addressBooks, $urlGenerator, $userId); + $this->setupSystemContactsProvider($cm, $userId, $urlGenerator); } /** * @param IManager $cm + * @param ?string $userId * @param IURLGenerator $urlGenerator */ - public function setupSystemContactsProvider(IManager $cm, IURLGenerator $urlGenerator) { - $addressBooks = $this->backend->getAddressBooksForUser("principals/system/system"); - $this->register($cm, $addressBooks, $urlGenerator); + public function setupSystemContactsProvider(IManager $cm, ?string $userId, IURLGenerator $urlGenerator) { + $addressBooks = $this->backend->getAddressBooksForUser('principals/system/system'); + $this->register($cm, $addressBooks, $urlGenerator, $userId); } /** * @param IManager $cm * @param $addressBooks * @param IURLGenerator $urlGenerator + * @param ?string $userId */ - private function register(IManager $cm, $addressBooks, $urlGenerator) { + private function register(IManager $cm, $addressBooks, $urlGenerator, ?string $userId) { foreach ($addressBooks as $addressBookInfo) { - $addressBook = new \OCA\DAV\CardDAV\AddressBook($this->backend, $addressBookInfo, $this->l10n); + $addressBook = new AddressBook($this->backend, $addressBookInfo, $this->l10n); $cm->registerAddressBook( new AddressBookImpl( $addressBook, $addressBookInfo, $this->backend, - $urlGenerator + $urlGenerator, + $this->propertyMapper, + $userId, ) ); } diff --git a/apps/dav/lib/CardDAV/Converter.php b/apps/dav/lib/CardDAV/Converter.php index 340e3127f0a..30dba99839e 100644 --- a/apps/dav/lib/CardDAV/Converter.php +++ b/apps/dav/lib/CardDAV/Converter.php @@ -1,50 +1,35 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CardDAV; +use DateTimeImmutable; use Exception; use OCP\Accounts\IAccountManager; use OCP\IImage; +use OCP\IURLGenerator; use OCP\IUser; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; use Sabre\VObject\Component\VCard; use Sabre\VObject\Property\Text; +use Sabre\VObject\Property\VCard\Date; class Converter { - - /** @var IAccountManager */ - private $accountManager; - - public function __construct(IAccountManager $accountManager) { - $this->accountManager = $accountManager; + public function __construct( + private IAccountManager $accountManager, + private IUserManager $userManager, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + ) { } public function createCardFromUser(IUser $user): ?VCard { - $userProperties = $this->accountManager->getAccount($user)->getProperties(); + $userProperties = $this->accountManager->getAccount($user)->getAllProperties(); $uid = $user->getUID(); $cloudId = $user->getCloudId(); @@ -57,40 +42,108 @@ class Converter { $publish = false; foreach ($userProperties as $property) { - $shareWithTrustedServers = - $property->getScope() === IAccountManager::SCOPE_FEDERATED || - $property->getScope() === IAccountManager::SCOPE_PUBLISHED; - - $emptyValue = $property->getValue() === ''; - - if ($shareWithTrustedServers && !$emptyValue) { - $publish = true; - switch ($property->getName()) { - case IAccountManager::PROPERTY_DISPLAYNAME: - $vCard->add(new Text($vCard, 'FN', $property->getValue())); - $vCard->add(new Text($vCard, 'N', $this->splitFullName($property->getValue()))); - break; - case IAccountManager::PROPERTY_AVATAR: - if ($image !== null) { - $vCard->add('PHOTO', $image->data(), ['ENCODING' => 'b', 'TYPE' => $image->mimeType()]); - } - break; - case IAccountManager::PROPERTY_EMAIL: - $vCard->add(new Text($vCard, 'EMAIL', $property->getValue(), ['TYPE' => 'OTHER'])); - break; - case IAccountManager::PROPERTY_WEBSITE: - $vCard->add(new Text($vCard, 'URL', $property->getValue())); - break; - case IAccountManager::PROPERTY_PHONE: - $vCard->add(new Text($vCard, 'TEL', $property->getValue(), ['TYPE' => 'OTHER'])); - break; - case IAccountManager::PROPERTY_ADDRESS: - $vCard->add(new Text($vCard, 'ADR', $property->getValue(), ['TYPE' => 'OTHER'])); - break; - case IAccountManager::PROPERTY_TWITTER: - $vCard->add(new Text($vCard, 'X-SOCIALPROFILE', $property->getValue(), ['TYPE' => 'TWITTER'])); + if ($property->getName() !== IAccountManager::PROPERTY_AVATAR && empty($property->getValue())) { + continue; + } + + $scope = $property->getScope(); + // Do not write private data to the system address book at all + if ($scope === IAccountManager::SCOPE_PRIVATE || empty($scope)) { + continue; + } + + $publish = true; + switch ($property->getName()) { + case IAccountManager::PROPERTY_DISPLAYNAME: + $vCard->add(new Text($vCard, 'FN', $property->getValue(), ['X-NC-SCOPE' => $scope])); + $vCard->add(new Text($vCard, 'N', $this->splitFullName($property->getValue()), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_AVATAR: + if ($image !== null) { + $vCard->add('PHOTO', $image->data(), ['ENCODING' => 'b', 'TYPE' => $image->mimeType(), ['X-NC-SCOPE' => $scope]]); + } + break; + case IAccountManager::COLLECTION_EMAIL: + case IAccountManager::PROPERTY_EMAIL: + $vCard->add(new Text($vCard, 'EMAIL', $property->getValue(), ['TYPE' => 'OTHER', 'X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_WEBSITE: + $vCard->add(new Text($vCard, 'URL', $property->getValue(), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_PROFILE_ENABLED: + if ($property->getValue()) { + $vCard->add( + new Text( + $vCard, + 'X-SOCIALPROFILE', + $this->urlGenerator->linkToRouteAbsolute('profile.ProfilePage.index', ['targetUserId' => $user->getUID()]), + [ + 'TYPE' => 'NEXTCLOUD', + 'X-NC-SCOPE' => IAccountManager::SCOPE_PUBLISHED + ] + ) + ); + } + break; + case IAccountManager::PROPERTY_PHONE: + $vCard->add(new Text($vCard, 'TEL', $property->getValue(), ['TYPE' => 'VOICE', 'X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_ADDRESS: + // structured prop: https://www.rfc-editor.org/rfc/rfc6350.html#section-6.3.1 + // post office box;extended address;street address;locality;region;postal code;country + $vCard->add( + new Text( + $vCard, + 'ADR', + [ '', '', '', $property->getValue(), '', '', '' ], + [ + 'TYPE' => 'OTHER', + 'X-NC-SCOPE' => $scope, + ] + ) + ); + break; + case IAccountManager::PROPERTY_TWITTER: + $vCard->add(new Text($vCard, 'X-SOCIALPROFILE', $property->getValue(), ['TYPE' => 'TWITTER', 'X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_ORGANISATION: + $vCard->add(new Text($vCard, 'ORG', $property->getValue(), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_ROLE: + $vCard->add(new Text($vCard, 'TITLE', $property->getValue(), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_BIOGRAPHY: + $vCard->add(new Text($vCard, 'NOTE', $property->getValue(), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_BIRTHDATE: + try { + $birthdate = new DateTimeImmutable($property->getValue()); + } catch (Exception $e) { + // Invalid date -> just skip the property + $this->logger->info("Failed to parse user's birthdate for the SAB: " . $property->getValue(), [ + 'exception' => $e, + 'userId' => $user->getUID(), + ]); break; - } + } + $dateProperty = new Date($vCard, 'BDAY', null, ['X-NC-SCOPE' => $scope]); + $dateProperty->setDateTime($birthdate); + $vCard->add($dateProperty); + break; + } + } + + // Local properties + $managers = $user->getManagerUids(); + // X-MANAGERSNAME only allows a single value, so we take the first manager + if (isset($managers[0])) { + $displayName = $this->userManager->getDisplayName($managers[0]); + // Only set the manager if a user object is found + if ($displayName !== null) { + $vCard->add(new Text($vCard, 'X-MANAGERSNAME', $displayName, [ + 'uid' => $managers[0], + 'X-NC-SCOPE' => IAccountManager::SCOPE_LOCAL, + ])); } } @@ -125,7 +178,7 @@ class Converter { private function getAvatarImage(IUser $user): ?IImage { try { - return $user->getAvatarImage(-1); + return $user->getAvatarImage(512); } catch (Exception $ex) { return null; } diff --git a/apps/dav/lib/CardDAV/HasPhotoPlugin.php b/apps/dav/lib/CardDAV/HasPhotoPlugin.php index 528fcb36bf5..6e2e0423910 100644 --- a/apps/dav/lib/CardDAV/HasPhotoPlugin.php +++ b/apps/dav/lib/CardDAV/HasPhotoPlugin.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV; @@ -66,8 +47,8 @@ class HasPhotoPlugin extends ServerPlugin { return $vcard instanceof VCard && $vcard->PHOTO // Either the PHOTO is a url (doesn't start with data:) or the mimetype has to start with image/ - && (strpos($vcard->PHOTO->getValue(), 'data:') !== 0 - || strpos($vcard->PHOTO->getValue(), 'data:image/') === 0) + && (!str_starts_with($vcard->PHOTO->getValue(), 'data:') + || str_starts_with($vcard->PHOTO->getValue(), 'data:image/')) ; }); } diff --git a/apps/dav/lib/CardDAV/ImageExportPlugin.php b/apps/dav/lib/CardDAV/ImageExportPlugin.php index 4caf234e346..74a8b032e42 100644 --- a/apps/dav/lib/CardDAV/ImageExportPlugin.php +++ b/apps/dav/lib/CardDAV/ImageExportPlugin.php @@ -1,29 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Jacob Neplokh <me@jacobneplokh.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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\CardDAV; +use OCP\AppFramework\Http; use OCP\Files\NotFoundException; use Sabre\CardDAV\Card; use Sabre\DAV\Server; @@ -35,16 +19,15 @@ class ImageExportPlugin extends ServerPlugin { /** @var Server */ protected $server; - /** @var PhotoCache */ - private $cache; /** * ImageExportPlugin constructor. * * @param PhotoCache $cache */ - public function __construct(PhotoCache $cache) { - $this->cache = $cache; + public function __construct( + private PhotoCache $cache, + ) { } /** @@ -77,7 +60,7 @@ class ImageExportPlugin extends ServerPlugin { $path = $request->getPath(); $node = $this->server->tree->getNodeForPath($path); - if (!($node instanceof Card)) { + if (!$node instanceof Card) { return true; } @@ -98,18 +81,17 @@ class ImageExportPlugin extends ServerPlugin { $response->setHeader('Cache-Control', 'private, max-age=3600, must-revalidate'); $response->setHeader('Etag', $node->getETag()); - $response->setHeader('Pragma', 'public'); try { $file = $this->cache->get($addressbook->getResourceId(), $node->getName(), $size, $node); $response->setHeader('Content-Type', $file->getMimeType()); $fileName = $node->getName() . '.' . PhotoCache::ALLOWED_CONTENT_TYPES[$file->getMimeType()]; $response->setHeader('Content-Disposition', "attachment; filename=$fileName"); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setBody($file->getContent()); } catch (NotFoundException $e) { - $response->setStatus(404); + $response->setStatus(Http::STATUS_NO_CONTENT); } return false; diff --git a/apps/dav/lib/CardDAV/Integration/ExternalAddressBook.php b/apps/dav/lib/CardDAV/Integration/ExternalAddressBook.php index b1deb638f3d..372906a6ae8 100644 --- a/apps/dav/lib/CardDAV/Integration/ExternalAddressBook.php +++ b/apps/dav/lib/CardDAV/Integration/ExternalAddressBook.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV\Integration; @@ -50,19 +32,10 @@ abstract class ExternalAddressBook implements IAddressBook, DAV\IProperties { */ private const DELIMITER = '--'; - /** @var string */ - private $appId; - - /** @var string */ - private $uri; - - /** - * @param string $appId - * @param string $uri - */ - public function __construct(string $appId, string $uri) { - $this->appId = $appId; - $this->uri = $uri; + public function __construct( + private string $appId, + private string $uri, + ) { } /** @@ -98,7 +71,7 @@ abstract class ExternalAddressBook implements IAddressBook, DAV\IProperties { * @return bool */ public static function isAppGeneratedAddressBook(string $uri): bool { - return strpos($uri, self::PREFIX) === 0 && substr_count($uri, self::DELIMITER) >= 2; + return str_starts_with($uri, self::PREFIX) && substr_count($uri, self::DELIMITER) >= 2; } /** @@ -128,6 +101,6 @@ abstract class ExternalAddressBook implements IAddressBook, DAV\IProperties { * @return bool */ public static function doesViolateReservedName(string $uri): bool { - return strpos($uri, self::PREFIX) === 0; + return str_starts_with($uri, self::PREFIX); } } diff --git a/apps/dav/lib/CardDAV/Integration/IAddressBookProvider.php b/apps/dav/lib/CardDAV/Integration/IAddressBookProvider.php index 0560c13c05c..a8fa074f635 100644 --- a/apps/dav/lib/CardDAV/Integration/IAddressBookProvider.php +++ b/apps/dav/lib/CardDAV/Integration/IAddressBookProvider.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV\Integration; diff --git a/apps/dav/lib/CardDAV/MultiGetExportPlugin.php b/apps/dav/lib/CardDAV/MultiGetExportPlugin.php index 62c4f7e8178..9d6b0df838e 100644 --- a/apps/dav/lib/CardDAV/MultiGetExportPlugin.php +++ b/apps/dav/lib/CardDAV/MultiGetExportPlugin.php @@ -3,29 +3,12 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV; +use OCP\AppFramework\Http; use Sabre\DAV; use Sabre\DAV\Server; use Sabre\HTTP\RequestInterface; @@ -61,8 +44,8 @@ class MultiGetExportPlugin extends DAV\ServerPlugin { } // Only handling xml - $contentType = $response->getHeader('Content-Type'); - if (strpos($contentType, 'application/xml') === false && strpos($contentType, 'text/xml') === false) { + $contentType = (string)$response->getHeader('Content-Type'); + if (!str_contains($contentType, 'application/xml') && !str_contains($contentType, 'text/xml')) { return; } @@ -83,7 +66,7 @@ class MultiGetExportPlugin extends DAV\ServerPlugin { $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); $response->setHeader('Content-Type', 'text/vcard'); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setBody($output); return true; diff --git a/apps/dav/lib/CardDAV/PhotoCache.php b/apps/dav/lib/CardDAV/PhotoCache.php index d3e4b2450d3..03c71f7e4a3 100644 --- a/apps/dav/lib/CardDAV/PhotoCache.php +++ b/apps/dav/lib/CardDAV/PhotoCache.php @@ -1,40 +1,20 @@ <?php + /** - * @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Jacob Neplokh <me@jacobneplokh.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\DAV\CardDAV; +use OCP\Files\AppData\IAppDataFactory; use OCP\Files\IAppData; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; -use OCP\ILogger; +use OCP\Image; +use Psr\Log\LoggerInterface; use Sabre\CardDAV\Card; use Sabre\VObject\Document; use Sabre\VObject\Parameter; @@ -42,42 +22,28 @@ use Sabre\VObject\Property\Binary; use Sabre\VObject\Reader; class PhotoCache { + private ?IAppData $photoCacheAppData = null; - /** @var array */ + /** @var array */ public const ALLOWED_CONTENT_TYPES = [ 'image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/vnd.microsoft.icon' => 'ico', + 'image/webp' => 'webp', + 'image/avif' => 'avif', ]; - /** @var IAppData */ - protected $appData; - - /** @var ILogger */ - protected $logger; - - /** - * PhotoCache constructor. - * - * @param IAppData $appData - * @param ILogger $logger - */ - public function __construct(IAppData $appData, ILogger $logger) { - $this->appData = $appData; - $this->logger = $logger; + public function __construct( + private IAppDataFactory $appDataFactory, + private LoggerInterface $logger, + ) { } /** - * @param int $addressBookId - * @param string $cardUri - * @param int $size - * @param Card $card - * - * @return ISimpleFile * @throws NotFoundException */ - public function get($addressBookId, $cardUri, $size, Card $card) { + public function get(int $addressBookId, string $cardUri, int $size, Card $card): ISimpleFile { $folder = $this->getFolder($addressBookId, $cardUri); if ($this->isEmpty($folder)) { @@ -95,17 +61,11 @@ class PhotoCache { return $this->getFile($folder, $size); } - /** - * @param ISimpleFolder $folder - * @return bool - */ - private function isEmpty(ISimpleFolder $folder) { + private function isEmpty(ISimpleFolder $folder): bool { return $folder->getDirectoryListing() === []; } /** - * @param ISimpleFolder $folder - * @param Card $card * @throws NotPermittedException */ private function init(ISimpleFolder $folder, Card $card): void { @@ -128,11 +88,14 @@ class PhotoCache { $file->putContent($data['body']); } - private function hasPhoto(ISimpleFolder $folder) { + private function hasPhoto(ISimpleFolder $folder): bool { return !$folder->fileExists('nophoto'); } - private function getFile(ISimpleFolder $folder, $size) { + /** + * @param float|-1 $size + */ + private function getFile(ISimpleFolder $folder, $size): ISimpleFile { $ext = $this->getExtension($folder); if ($size === -1) { @@ -148,7 +111,7 @@ class PhotoCache { throw new NotFoundException; } - $photo = new \OC_Image(); + $photo = new Image(); /** @var ISimpleFile $file */ $file = $folder->getFile('photo.' . $ext); $photo->loadFromData($file->getContent()); @@ -158,7 +121,7 @@ class PhotoCache { $ratio = 1 / $ratio; } - $size = (int) ($size * $ratio); + $size = (int)($size * $ratio); if ($size !== -1) { $photo->resize($size); } @@ -180,21 +143,18 @@ class PhotoCache { private function getFolder(int $addressBookId, string $cardUri, bool $createIfNotExists = true): ISimpleFolder { $hash = md5($addressBookId . ' ' . $cardUri); try { - return $this->appData->getFolder($hash); + return $this->getPhotoCacheAppData()->getFolder($hash); } catch (NotFoundException $e) { if ($createIfNotExists) { - return $this->appData->newFolder($hash); - } else { - throw $e; + return $this->getPhotoCacheAppData()->newFolder($hash); } + throw $e; } } /** * Get the extension of the avatar. If there is no avatar throw Exception * - * @param ISimpleFolder $folder - * @return string * @throws NotFoundException */ private function getExtension(ISimpleFolder $folder): string { @@ -209,23 +169,22 @@ class PhotoCache { /** * @param Card $node - * @return bool|array{body: string, Content-Type: string} + * @return false|array{body: string, Content-Type: string} */ private function getPhoto(Card $node) { try { $vObject = $this->readCard($node->get()); return $this->getPhotoFromVObject($vObject); } catch (\Exception $e) { - $this->logger->logException($e, [ - 'message' => 'Exception during vcard photo parsing' + $this->logger->error('Exception during vcard photo parsing', [ + 'exception' => $e ]); } return false; } /** - * @param Document $vObject - * @return bool|array{body: string, Content-Type: string} + * @return false|array{body: string, Content-Type: string} */ public function getPhotoFromVObject(Document $vObject) { try { @@ -262,18 +221,14 @@ class PhotoCache { 'body' => $val ]; } catch (\Exception $e) { - $this->logger->logException($e, [ - 'message' => 'Exception during vcard photo parsing' + $this->logger->error('Exception during vcard photo parsing', [ + 'exception' => $e ]); } return false; } - /** - * @param string $cardData - * @return \Sabre\VObject\Document - */ - private function readCard($cardData) { + private function readCard(string $cardData): Document { return Reader::read($cardData); } @@ -286,9 +241,9 @@ class PhotoCache { if (isset($params['TYPE']) || isset($params['MEDIATYPE'])) { /** @var Parameter $typeParam */ $typeParam = isset($params['TYPE']) ? $params['TYPE'] : $params['MEDIATYPE']; - $type = $typeParam->getValue(); + $type = (string)$typeParam->getValue(); - if (strpos($type, 'image/') === 0) { + if (str_starts_with($type, 'image/')) { return $type; } else { return 'image/' . strtolower($type); @@ -310,4 +265,11 @@ class PhotoCache { // that's OK, nothing to do } } + + private function getPhotoCacheAppData(): IAppData { + if ($this->photoCacheAppData === null) { + $this->photoCacheAppData = $this->appDataFactory->get('dav-photocache'); + } + return $this->photoCacheAppData; + } } diff --git a/apps/dav/lib/CardDAV/Plugin.php b/apps/dav/lib/CardDAV/Plugin.php index df8f7e6a436..0ec10306ceb 100644 --- a/apps/dav/lib/CardDAV/Plugin.php +++ b/apps/dav/lib/CardDAV/Plugin.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CardDAV; diff --git a/apps/dav/lib/CardDAV/Security/CardDavRateLimitingPlugin.php b/apps/dav/lib/CardDAV/Security/CardDavRateLimitingPlugin.php new file mode 100644 index 00000000000..3e18a1341b0 --- /dev/null +++ b/apps/dav/lib/CardDAV/Security/CardDavRateLimitingPlugin.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\CardDAV\Security; + +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OCA\DAV\CardDAV\CardDavBackend; +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 CardDavRateLimitingPlugin extends ServerPlugin { + public function __construct( + private Limiter $limiter, + private IUserManager $userManager, + private CardDavBackend $cardDavBackend, + private LoggerInterface $logger, + private IAppConfig $config, + private ?string $userId, + ) { + $this->limiter = $limiter; + $this->userManager = $userManager; + $this->cardDavBackend = $cardDavBackend; + $this->config = $config; + $this->logger = $logger; + } + + 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) === 4 && $pathParts[0] === 'addressbooks') { + // Path looks like addressbooks/users/username/addressbooksname so a new addressbook is created + try { + $this->limiter->registerUserRequest( + 'carddav-create-address-book', + $this->config->getValueInt('dav', 'rateLimitAddressBookCreation', 10), + $this->config->getValueInt('dav', 'rateLimitPeriodAddressBookCreation', 3600), + $user + ); + } catch (RateLimitExceededException $e) { + throw new TooManyRequests('Too many addressbooks created', 0, $e); + } + + $addressBookLimit = $this->config->getValueInt('dav', 'maximumAdressbooks', 10); + if ($addressBookLimit === -1) { + return; + } + $numAddressbooks = $this->cardDavBackend->getAddressBooksForUserCount('principals/users/' . $user->getUID()); + + if ($numAddressbooks >= $addressBookLimit) { + $this->logger->warning('Maximum number of address books reached', [ + 'addressbooks' => $numAddressbooks, + 'addressBookLimit' => $addressBookLimit, + ]); + throw new Forbidden('AddressBook limit reached', 0); + } + } + } + +} diff --git a/apps/dav/lib/CardDAV/Sharing/Backend.php b/apps/dav/lib/CardDAV/Sharing/Backend.php new file mode 100644 index 00000000000..557115762fc --- /dev/null +++ b/apps/dav/lib/CardDAV/Sharing/Backend.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CardDAV\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/CardDAV/Sharing/Service.php b/apps/dav/lib/CardDAV/Sharing/Service.php new file mode 100644 index 00000000000..1ab208f7ec3 --- /dev/null +++ b/apps/dav/lib/CardDAV/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\CardDAV\Sharing; + +use OCA\DAV\DAV\Sharing\SharingMapper; +use OCA\DAV\DAV\Sharing\SharingService; + +class Service extends SharingService { + protected string $resourceType = 'addressbook'; + public function __construct( + protected SharingMapper $mapper, + ) { + parent::__construct($mapper); + } +} diff --git a/apps/dav/lib/CardDAV/SyncService.php b/apps/dav/lib/CardDAV/SyncService.php index 73bfaf01b60..e6da3ed5923 100644 --- a/apps/dav/lib/CardDAV/SyncService.php +++ b/apps/dav/lib/CardDAV/SyncService.php @@ -1,107 +1,69 @@ <?php + + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CardDAV; -use OC\Accounts\AccountManager; +use OCP\AppFramework\Db\TTransactional; use OCP\AppFramework\Http; -use OCP\ILogger; +use OCP\DB\Exception; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IDBConnection; use OCP\IUser; use OCP\IUserManager; -use Sabre\DAV\Client; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Log\LoggerInterface; use Sabre\DAV\Xml\Response\MultiStatus; use Sabre\DAV\Xml\Service; -use Sabre\HTTP\ClientHttpException; use Sabre\VObject\Reader; +use Sabre\Xml\ParseException; +use function is_null; class SyncService { - /** @var CardDavBackend */ - private $backend; - - /** @var IUserManager */ - private $userManager; - - /** @var ILogger */ - private $logger; - - /** @var array */ - private $localSystemAddressBook; - - /** @var Converter */ - private $converter; - - /** @var string */ - protected $certPath; - - /** - * SyncService constructor. - * - * @param CardDavBackend $backend - * @param IUserManager $userManager - * @param ILogger $logger - * @param AccountManager $accountManager - */ - public function __construct(CardDavBackend $backend, IUserManager $userManager, ILogger $logger, Converter $converter) { - $this->backend = $backend; - $this->userManager = $userManager; - $this->logger = $logger; - $this->converter = $converter; + use TTransactional; + private ?array $localSystemAddressBook = null; + protected string $certPath; + + public function __construct( + private CardDavBackend $backend, + private IUserManager $userManager, + private IDBConnection $dbConnection, + private LoggerInterface $logger, + private Converter $converter, + private IClientService $clientService, + private IConfig $config, + ) { $this->certPath = ''; } /** - * @param string $url - * @param string $userName - * @param string $addressBookUrl - * @param string $sharedSecret - * @param string $syncToken - * @param int $targetBookId - * @param string $targetPrincipal - * @param array $targetProperties - * @return string + * @psalm-return list{0: ?string, 1: boolean} * @throws \Exception */ - public function syncRemoteAddressBook($url, $userName, $addressBookUrl, $sharedSecret, $syncToken, $targetBookId, $targetPrincipal, $targetProperties) { + public function syncRemoteAddressBook(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken, string $targetBookHash, string $targetPrincipal, array $targetProperties): array { // 1. create addressbook - $book = $this->ensureSystemAddressBookExists($targetPrincipal, $targetBookId, $targetProperties); + $book = $this->ensureSystemAddressBookExists($targetPrincipal, $targetBookHash, $targetProperties); $addressBookId = $book['id']; // 2. query changes try { $response = $this->requestSyncReport($url, $userName, $addressBookUrl, $sharedSecret, $syncToken); - } catch (ClientHttpException $ex) { + } catch (ClientExceptionInterface $ex) { if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) { // remote server revoked access to the address book, remove it $this->backend->deleteAddressBook($addressBookId); - $this->logger->info('Authorization failed, remove address book: ' . $url, ['app' => 'dav']); + $this->logger->error('Authorization failed, remove address book: ' . $url, ['app' => 'dav']); throw $ex; } + $this->logger->error('Client exception:', ['app' => 'dav', 'exception' => $ex]); + throw $ex; } // 3. apply changes @@ -110,123 +72,147 @@ class SyncService { $cardUri = basename($resource); if (isset($status[200])) { $vCard = $this->download($url, $userName, $sharedSecret, $resource); - $existingCard = $this->backend->getCard($addressBookId, $cardUri); - if ($existingCard === false) { - $this->backend->createCard($addressBookId, $cardUri, $vCard['body']); - } else { - $this->backend->updateCard($addressBookId, $cardUri, $vCard['body']); - } + $this->atomic(function () use ($addressBookId, $cardUri, $vCard): void { + $existingCard = $this->backend->getCard($addressBookId, $cardUri); + if ($existingCard === false) { + $this->backend->createCard($addressBookId, $cardUri, $vCard); + } else { + $this->backend->updateCard($addressBookId, $cardUri, $vCard); + } + }, $this->dbConnection); } else { $this->backend->deleteCard($addressBookId, $cardUri); } } - return $response['token']; + return [ + $response['token'], + $response['truncated'], + ]; } /** - * @param string $principal - * @param string $id - * @param array $properties - * @return array|null * @throws \Sabre\DAV\Exception\BadRequest */ - public function ensureSystemAddressBookExists($principal, $id, $properties) { - $book = $this->backend->getAddressBooksByUri($principal, $id); - if (!is_null($book)) { - return $book; - } - $this->backend->createAddressBook($principal, $id, $properties); - - return $this->backend->getAddressBooksByUri($principal, $id); - } - - /** - * Check if there is a valid certPath we should use - * - * @return string - */ - protected function getCertPath() { + public function ensureSystemAddressBookExists(string $principal, string $uri, array $properties): ?array { + try { + return $this->atomic(function () use ($principal, $uri, $properties) { + $book = $this->backend->getAddressBooksByUri($principal, $uri); + if (!is_null($book)) { + return $book; + } + $this->backend->createAddressBook($principal, $uri, $properties); + + return $this->backend->getAddressBooksByUri($principal, $uri); + }, $this->dbConnection); + } catch (Exception $e) { + // READ COMMITTED doesn't prevent a nonrepeatable read above, so + // two processes might create an address book here. Ignore our + // failure and continue loading the entry written by the other process + if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } - // we already have a valid certPath - if ($this->certPath !== '') { - return $this->certPath; + // If this fails we might have hit a replication node that does not + // have the row written in the other process. + // TODO: find an elegant way to handle this + $ab = $this->backend->getAddressBooksByUri($principal, $uri); + if ($ab === null) { + throw new Exception('Could not create system address book', $e->getCode(), $e); + } + return $ab; } + } - $certManager = \OC::$server->getCertificateManager(); - $certPath = $certManager->getAbsoluteBundlePath(); - if (file_exists($certPath)) { - $this->certPath = $certPath; - } + public function ensureLocalSystemAddressBookExists(): ?array { + return $this->ensureSystemAddressBookExists('principals/system/system', 'system', [ + '{' . Plugin::NS_CARDDAV . '}addressbook-description' => 'System addressbook which holds all users of this instance' + ]); + } - return $this->certPath; + private function prepareUri(string $host, string $path): string { + /* + * The trailing slash is important for merging the uris. + * + * $host is stored in oc_trusted_servers.url and usually without a trailing slash. + * + * Example for a report request + * + * $host = 'https://server.internal/cloud' + * $path = 'remote.php/dav/addressbooks/system/system/system' + * + * Without the trailing slash, the webroot is missing: + * https://server.internal/remote.php/dav/addressbooks/system/system/system + * + * Example for a download request + * + * $host = 'https://server.internal/cloud' + * $path = '/cloud/remote.php/dav/addressbooks/system/system/system/Database:alice.vcf' + * + * The response from the remote usually contains the webroot already and must be normalized to: + * https://server.internal/cloud/remote.php/dav/addressbooks/system/system/system/Database:alice.vcf + */ + $host = rtrim($host, '/') . '/'; + + $uri = \GuzzleHttp\Psr7\UriResolver::resolve( + \GuzzleHttp\Psr7\Utils::uriFor($host), + \GuzzleHttp\Psr7\Utils::uriFor($path) + ); + + return (string)$uri; } /** - * @param string $url - * @param string $userName - * @param string $addressBookUrl - * @param string $sharedSecret - * @return Client + * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool} + * @throws ClientExceptionInterface + * @throws ParseException */ - protected function getClient($url, $userName, $sharedSecret) { - $settings = [ - 'baseUri' => $url . '/', - 'userName' => $userName, - 'password' => $sharedSecret, + protected function requestSyncReport(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken): array { + $client = $this->clientService->newClient(); + $uri = $this->prepareUri($url, $addressBookUrl); + + $options = [ + 'auth' => [$userName, $sharedSecret], + 'body' => $this->buildSyncCollectionRequestBody($syncToken), + 'headers' => ['Content-Type' => 'application/xml'], + 'timeout' => $this->config->getSystemValueInt('carddav_sync_request_timeout', IClient::DEFAULT_REQUEST_TIMEOUT), + 'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false), ]; - $client = new Client($settings); - $certPath = $this->getCertPath(); - $client->setThrowExceptions(true); - if ($certPath !== '' && strpos($url, 'http://') !== 0) { - $client->addCurlSetting(CURLOPT_CAINFO, $this->certPath); - } + $response = $client->request( + 'REPORT', + $uri, + $options + ); - return $client; - } + $body = $response->getBody(); + assert(is_string($body)); - /** - * @param string $url - * @param string $userName - * @param string $addressBookUrl - * @param string $sharedSecret - * @param string $syncToken - * @return array - */ - protected function requestSyncReport($url, $userName, $addressBookUrl, $sharedSecret, $syncToken) { - $client = $this->getClient($url, $userName, $sharedSecret); + return $this->parseMultiStatus($body, $addressBookUrl); + } - $body = $this->buildSyncCollectionRequestBody($syncToken); + protected function download(string $url, string $userName, string $sharedSecret, string $resourcePath): string { + $client = $this->clientService->newClient(); + $uri = $this->prepareUri($url, $resourcePath); - $response = $client->request('REPORT', $addressBookUrl, $body, [ - 'Content-Type' => 'application/xml' - ]); + $options = [ + 'auth' => [$userName, $sharedSecret], + 'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false), + ]; - return $this->parseMultiStatus($response['body']); - } + $response = $client->get( + $uri, + $options + ); - /** - * @param string $url - * @param string $userName - * @param string $sharedSecret - * @param string $resourcePath - * @return array - */ - protected function download($url, $userName, $sharedSecret, $resourcePath) { - $client = $this->getClient($url, $userName, $sharedSecret); - return $client->request('GET', $resourcePath); + return (string)$response->getBody(); } - /** - * @param string|null $syncToken - * @return string - */ - private function buildSyncCollectionRequestBody($syncToken) { + private function buildSyncCollectionRequestBody(?string $syncToken): string { $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->formatOutput = true; $root = $dom->createElementNS('DAV:', 'd:sync-collection'); - $sync = $dom->createElement('d:sync-token', $syncToken); + $sync = $dom->createElement('d:sync-token', $syncToken ?? ''); $prop = $dom->createElement('d:prop'); $cont = $dom->createElement('d:getcontenttype'); $etag = $dom->createElement('d:getetag'); @@ -240,49 +226,77 @@ class SyncService { } /** - * @param string $body - * @return array - * @throws \Sabre\Xml\ParseException + * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool} + * @throws ParseException */ - private function parseMultiStatus($body) { - $xml = new Service(); - + private function parseMultiStatus(string $body, string $addressBookUrl): array { /** @var MultiStatus $multiStatus */ - $multiStatus = $xml->expect('{DAV:}multistatus', $body); + $multiStatus = (new Service())->expect('{DAV:}multistatus', $body); $result = []; + $truncated = false; + foreach ($multiStatus->getResponses() as $response) { - $result[$response->getHref()] = $response->getResponseProperties(); + $href = $response->getHref(); + if ($response->getHttpStatus() === '507' && $this->isResponseForRequestUri($href, $addressBookUrl)) { + $truncated = true; + } else { + $result[$response->getHref()] = $response->getResponseProperties(); + } } - return ['response' => $result, 'token' => $multiStatus->getSyncToken()]; + return ['response' => $result, 'token' => $multiStatus->getSyncToken(), 'truncated' => $truncated]; + } + + /** + * Determines whether the provided response URI corresponds to the given request URI. + */ + private function isResponseForRequestUri(string $responseUri, string $requestUri): bool { + /* + * Example response uri: + * + * /remote.php/dav/addressbooks/system/system/system/ + * /cloud/remote.php/dav/addressbooks/system/system/system/ (when installed in a subdirectory) + * + * Example request uri: + * + * remote.php/dav/addressbooks/system/system/system + * + * References: + * https://github.com/nextcloud/3rdparty/blob/e0a509739b13820f0a62ff9cad5d0fede00e76ee/sabre/dav/lib/DAV/Sync/Plugin.php#L172-L174 + * https://github.com/nextcloud/server/blob/b40acb34a39592070d8455eb91c5364c07928c50/apps/federation/lib/SyncFederationAddressBooks.php#L41 + */ + return str_ends_with( + rtrim($responseUri, '/'), + rtrim($requestUri, '/') + ); } /** * @param IUser $user */ - public function updateUser(IUser $user) { + public function updateUser(IUser $user): void { $systemAddressBook = $this->getLocalSystemAddressBook(); $addressBookId = $systemAddressBook['id']; - $name = $user->getBackendClassName(); - $userId = $user->getUID(); - $cardId = "$name:$userId.vcf"; - $card = $this->backend->getCard($addressBookId, $cardId); + $cardId = self::getCardUri($user); if ($user->isEnabled()) { - if ($card === false) { - $vCard = $this->converter->createCardFromUser($user); - if ($vCard !== null) { - $this->backend->createCard($addressBookId, $cardId, $vCard->serialize()); - } - } else { - $vCard = $this->converter->createCardFromUser($user); - if (is_null($vCard)) { - $this->backend->deleteCard($addressBookId, $cardId); + $this->atomic(function () use ($addressBookId, $cardId, $user): void { + $card = $this->backend->getCard($addressBookId, $cardId); + if ($card === false) { + $vCard = $this->converter->createCardFromUser($user); + if ($vCard !== null) { + $this->backend->createCard($addressBookId, $cardId, $vCard->serialize(), false); + } } else { - $this->backend->updateCard($addressBookId, $cardId, $vCard->serialize()); + $vCard = $this->converter->createCardFromUser($user); + if (is_null($vCard)) { + $this->backend->deleteCard($addressBookId, $cardId); + } else { + $this->backend->updateCard($addressBookId, $cardId, $vCard->serialize()); + } } - } + }, $this->dbConnection); } else { $this->backend->deleteCard($addressBookId, $cardId); } @@ -294,10 +308,7 @@ class SyncService { public function deleteUser($userOrCardId) { $systemAddressBook = $this->getLocalSystemAddressBook(); if ($userOrCardId instanceof IUser) { - $name = $userOrCardId->getBackendClassName(); - $userId = $userOrCardId->getUID(); - - $userOrCardId = "$name:$userId.vcf"; + $userOrCardId = self::getCardUri($userOrCardId); } $this->backend->deleteCard($systemAddressBook['id'], $userOrCardId); } @@ -307,18 +318,18 @@ class SyncService { */ public function getLocalSystemAddressBook() { if (is_null($this->localSystemAddressBook)) { - $systemPrincipal = "principals/system/system"; - $this->localSystemAddressBook = $this->ensureSystemAddressBookExists($systemPrincipal, 'system', [ - '{' . Plugin::NS_CARDDAV . '}addressbook-description' => 'System addressbook which holds all users of this instance' - ]); + $this->localSystemAddressBook = $this->ensureLocalSystemAddressBookExists(); } return $this->localSystemAddressBook; } - public function syncInstance(\Closure $progressCallback = null) { + /** + * @return void + */ + public function syncInstance(?\Closure $progressCallback = null) { $systemAddressBook = $this->getLocalSystemAddressBook(); - $this->userManager->callForAllUsers(function ($user) use ($systemAddressBook, $progressCallback) { + $this->userManager->callForAllUsers(function ($user) use ($systemAddressBook, $progressCallback): void { $this->updateUser($user); if (!is_null($progressCallback)) { $progressCallback(); @@ -336,4 +347,12 @@ class SyncService { } } } + + /** + * @param IUser $user + * @return string + */ + public static function getCardUri(IUser $user): string { + return $user->getBackendClassName() . ':' . $user->getUID() . '.vcf'; + } } diff --git a/apps/dav/lib/CardDAV/SystemAddressbook.php b/apps/dav/lib/CardDAV/SystemAddressbook.php index 502e353acb3..912a2f1dcee 100644 --- a/apps/dav/lib/CardDAV/SystemAddressbook.php +++ b/apps/dav/lib/CardDAV/SystemAddressbook.php @@ -3,51 +3,335 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CardDAV; +use OCA\Federation\TrustedServers; +use OCP\Accounts\IAccountManager; use OCP\IConfig; +use OCP\IGroupManager; use OCP\IL10N; +use OCP\IRequest; +use OCP\IUserSession; use Sabre\CardDAV\Backend\BackendInterface; +use Sabre\CardDAV\Backend\SyncSupport; +use Sabre\CardDAV\Card; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\VObject\Component\VCard; +use Sabre\VObject\Reader; +use function array_filter; +use function array_intersect; +use function array_unique; +use function in_array; class SystemAddressbook extends AddressBook { - /** @var IConfig */ - private $config; + public const URI_SHARED = 'z-server-generated--system'; - public function __construct(BackendInterface $carddavBackend, array $addressBookInfo, IL10N $l10n, IConfig $config) { + public function __construct( + BackendInterface $carddavBackend, + array $addressBookInfo, + IL10N $l10n, + private IConfig $config, + private IUserSession $userSession, + private ?IRequest $request = null, + private ?TrustedServers $trustedServers = null, + private ?IGroupManager $groupManager = null, + ) { parent::__construct($carddavBackend, $addressBookInfo, $l10n); - $this->config = $config; + + $this->addressBookInfo['{DAV:}displayname'] = $l10n->t('Accounts'); + $this->addressBookInfo['{' . Plugin::NS_CARDDAV . '}addressbook-description'] = $l10n->t('System address book which holds all accounts'); } + /** + * No checkbox checked -> Show only the same user + * 'Allow username autocompletion in share dialog' -> show everyone + * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users within the same groups' -> show only users in intersecting groups + * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users based on phone number integration' -> show only the same user + * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users within the same groups' + 'Allow username autocompletion to users based on phone number integration' -> show only users in intersecting groups + */ public function getChildren() { $shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; $shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; $shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; - if (!$shareEnumeration || $shareEnumerationGroup || $shareEnumerationPhone) { + $user = $this->userSession->getUser(); + if (!$user) { + // Should never happen because we don't allow anonymous access return []; } + if ($user->getBackendClassName() === 'Guests' || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) { + $name = SyncService::getCardUri($user); + try { + return [parent::getChild($name)]; + } catch (NotFound $e) { + return []; + } + } + if ($shareEnumerationGroup) { + if ($this->groupManager === null) { + // Group manager is not available, so we can't determine which data is safe + return []; + } + $groups = $this->groupManager->getUserGroups($user); + $names = []; + foreach ($groups as $group) { + $users = $group->getUsers(); + foreach ($users as $groupUser) { + if ($groupUser->getBackendClassName() === 'Guests') { + continue; + } + $names[] = SyncService::getCardUri($groupUser); + } + } + return parent::getMultipleChildren(array_unique($names)); + } + + $children = parent::getChildren(); + return array_filter($children, function (Card $child) { + // check only for URIs that begin with Guests: + return !str_starts_with($child->getName(), 'Guests:'); + }); + } + + /** + * @param array $paths + * @return Card[] + * @throws NotFound + */ + public function getMultipleChildren($paths): array { + $shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; + $shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; + $shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; + $user = $this->userSession->getUser(); + if (($user !== null && $user->getBackendClassName() === 'Guests') || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) { + // No user or cards with no access + if ($user === null || !in_array(SyncService::getCardUri($user), $paths, true)) { + return []; + } + // Only return the own card + try { + return [parent::getChild(SyncService::getCardUri($user))]; + } catch (NotFound $e) { + return []; + } + } + if ($shareEnumerationGroup) { + if ($this->groupManager === null || $user === null) { + // Group manager or user is not available, so we can't determine which data is safe + return []; + } + $groups = $this->groupManager->getUserGroups($user); + $allowedNames = []; + foreach ($groups as $group) { + $users = $group->getUsers(); + foreach ($users as $groupUser) { + if ($groupUser->getBackendClassName() === 'Guests') { + continue; + } + $allowedNames[] = SyncService::getCardUri($groupUser); + } + } + return parent::getMultipleChildren(array_intersect($paths, $allowedNames)); + } + if (!$this->isFederation()) { + return parent::getMultipleChildren($paths); + } + + $objs = $this->carddavBackend->getMultipleCards($this->addressBookInfo['id'], $paths); + $children = []; + /** @var array $obj */ + foreach ($objs as $obj) { + if (empty($obj)) { + continue; + } + $carddata = $this->extractCarddata($obj); + if (empty($carddata)) { + continue; + } else { + $obj['carddata'] = $carddata; + } + $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + return $children; + } + + /** + * @param string $name + * @return Card + * @throws NotFound + * @throws Forbidden + */ + public function getChild($name): Card { + $user = $this->userSession->getUser(); + $shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; + $shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; + $shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; + if (($user !== null && $user->getBackendClassName() === 'Guests') || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) { + $ownName = $user !== null ? SyncService::getCardUri($user) : null; + if ($ownName === $name) { + return parent::getChild($name); + } + throw new Forbidden(); + } + if ($shareEnumerationGroup) { + if ($user === null || $this->groupManager === null) { + // Group manager is not available, so we can't determine which data is safe + throw new Forbidden(); + } + $groups = $this->groupManager->getUserGroups($user); + foreach ($groups as $group) { + foreach ($group->getUsers() as $groupUser) { + if ($groupUser->getBackendClassName() === 'Guests') { + continue; + } + $otherName = SyncService::getCardUri($groupUser); + if ($otherName === $name) { + return parent::getChild($name); + } + } + } + throw new Forbidden(); + } + if (!$this->isFederation()) { + return parent::getChild($name); + } + + $obj = $this->carddavBackend->getCard($this->addressBookInfo['id'], $name); + if (!$obj) { + throw new NotFound('Card not found'); + } + $carddata = $this->extractCarddata($obj); + if (empty($carddata)) { + throw new Forbidden(); + } else { + $obj['carddata'] = $carddata; + } + return new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + public function getChanges($syncToken, $syncLevel, $limit = null) { + + if (!$this->carddavBackend instanceof SyncSupport) { + return null; + } + + if (!$this->isFederation()) { + return parent::getChanges($syncToken, $syncLevel, $limit); + } + + $changed = $this->carddavBackend->getChangesForAddressBook( + $this->addressBookInfo['id'], + $syncToken, + $syncLevel, + $limit + ); + + if (empty($changed)) { + return $changed; + } + + $added = $modified = $deleted = []; + foreach ($changed['added'] as $uri) { + try { + $this->getChild($uri); + $added[] = $uri; + } catch (NotFound|Forbidden $e) { + $deleted[] = $uri; + } + } + foreach ($changed['modified'] as $uri) { + try { + $this->getChild($uri); + $modified[] = $uri; + } catch (NotFound|Forbidden $e) { + $deleted[] = $uri; + } + } + $changed['added'] = $added; + $changed['modified'] = $modified; + $changed['deleted'] = $deleted; + return $changed; + } + + private function isFederation(): bool { + if ($this->trustedServers === null || $this->request === null) { + return false; + } + + /** @psalm-suppress NoInterfaceProperties */ + $server = $this->request->server; + if (!isset($server['PHP_AUTH_USER']) || $server['PHP_AUTH_USER'] !== 'system') { + return false; + } + + /** @psalm-suppress NoInterfaceProperties */ + $sharedSecret = $server['PHP_AUTH_PW'] ?? null; + if ($sharedSecret === null) { + return false; + } + + $servers = $this->trustedServers->getServers(); + $trusted = array_filter($servers, function ($trustedServer) use ($sharedSecret) { + return $trustedServer['shared_secret'] === $sharedSecret; + }); + // Authentication is fine, but it's not for a federated share + if (empty($trusted)) { + return false; + } + + return true; + } + + /** + * If the validation doesn't work the card is "not found" so we + * return empty carddata even if the carddata might exist in the local backend. + * This can happen when a user sets the required properties + * FN, N to a local scope only but the request is from + * a federated share. + * + * @see https://github.com/nextcloud/server/issues/38042 + * + * @param array $obj + * @return string|null + */ + private function extractCarddata(array $obj): ?string { + $obj['acl'] = $this->getChildACL(); + $cardData = $obj['carddata']; + /** @var VCard $vCard */ + $vCard = Reader::read($cardData); + foreach ($vCard->children() as $child) { + $scope = $child->offsetGet('X-NC-SCOPE'); + if ($scope !== null && $scope->getValue() === IAccountManager::SCOPE_LOCAL) { + $vCard->remove($child); + } + } + $messages = $vCard->validate(); + if (!empty($messages)) { + return null; + } + + return $vCard->serialize(); + } + + /** + * @return mixed + * @throws Forbidden + */ + public function delete() { + if ($this->isFederation()) { + parent::delete(); + } + throw new Forbidden(); + } - return parent::getChildren(); + public function getACL() { + return array_filter(parent::getACL(), function ($acl) { + if (in_array($acl['privilege'], ['{DAV:}write', '{DAV:}all'], true)) { + return false; + } + return true; + }); } } diff --git a/apps/dav/lib/CardDAV/UserAddressBooks.php b/apps/dav/lib/CardDAV/UserAddressBooks.php index 98957301120..e29e52e77df 100644 --- a/apps/dav/lib/CardDAV/UserAddressBooks.php +++ b/apps/dav/lib/CardDAV/UserAddressBooks.php @@ -3,57 +3,47 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CardDAV; use OCA\DAV\AppInfo\PluginManager; -use OCA\DAV\CardDAV\Integration\IAddressBookProvider; use OCA\DAV\CardDAV\Integration\ExternalAddressBook; +use OCA\DAV\CardDAV\Integration\IAddressBookProvider; +use OCA\Federation\TrustedServers; +use OCP\AppFramework\QueryException; use OCP\IConfig; +use OCP\IGroupManager; use OCP\IL10N; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Server; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; use Sabre\CardDAV\Backend; -use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\CardDAV\IAddressBook; -use function array_map; +use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\DAV\MkCol; +use function array_map; class UserAddressBooks extends \Sabre\CardDAV\AddressBookHome { - /** @var IL10N */ protected $l10n; /** @var IConfig */ protected $config; - /** @var PluginManager */ - private $pluginManager; - - public function __construct(Backend\BackendInterface $carddavBackend, - string $principalUri, - PluginManager $pluginManager) { + public function __construct( + Backend\BackendInterface $carddavBackend, + string $principalUri, + private PluginManager $pluginManager, + private ?IUser $user, + private ?IGroupManager $groupManager, + ) { parent::__construct($carddavBackend, $principalUri); - $this->pluginManager = $pluginManager; } /** @@ -66,18 +56,53 @@ class UserAddressBooks extends \Sabre\CardDAV\AddressBookHome { $this->l10n = \OC::$server->getL10N('dav'); } if ($this->config === null) { - $this->config = \OC::$server->getConfig(); + $this->config = Server::get(IConfig::class); } + /** @var string|array $principal */ + $principal = $this->principalUri; $addressBooks = $this->carddavBackend->getAddressBooksForUser($this->principalUri); - /** @var IAddressBook[] $objects */ - $objects = array_map(function (array $addressBook) { - if ($addressBook['principaluri'] === 'principals/system/system') { - return new SystemAddressbook($this->carddavBackend, $addressBook, $this->l10n, $this->config); + // add the system address book + $systemAddressBook = null; + $systemAddressBookExposed = $this->config->getAppValue('dav', 'system_addressbook_exposed', 'yes') === 'yes'; + if ($systemAddressBookExposed && is_string($principal) && $principal !== 'principals/system/system' && $this->carddavBackend instanceof CardDavBackend) { + $systemAddressBook = $this->carddavBackend->getAddressBooksByUri('principals/system/system', 'system'); + if ($systemAddressBook !== null) { + $systemAddressBook['uri'] = SystemAddressbook::URI_SHARED; } + } + if (!is_null($systemAddressBook)) { + $addressBooks[] = $systemAddressBook; + } - return new AddressBook($this->carddavBackend, $addressBook, $this->l10n); - }, $addressBooks); + $objects = []; + if (!empty($addressBooks)) { + /** @var IAddressBook[] $objects */ + $objects = array_map(function (array $addressBook) { + $trustedServers = null; + $request = null; + try { + $trustedServers = Server::get(TrustedServers::class); + $request = Server::get(IRequest::class); + } catch (QueryException|NotFoundExceptionInterface|ContainerExceptionInterface $e) { + // nothing to do, the request / trusted servers don't exist + } + if ($addressBook['principaluri'] === 'principals/system/system') { + return new SystemAddressbook( + $this->carddavBackend, + $addressBook, + $this->l10n, + $this->config, + Server::get(IUserSession::class), + $request, + $trustedServers, + $this->groupManager + ); + } + + return new AddressBook($this->carddavBackend, $addressBook, $this->l10n); + }, $addressBooks); + } /** @var IAddressBook[][] $objectsFromPlugins */ $objectsFromPlugins = array_map(function (IAddressBookProvider $plugin): array { return $plugin->fetchAllForAddressBookHome($this->principalUri); diff --git a/apps/dav/lib/CardDAV/Validation/CardDavValidatePlugin.php b/apps/dav/lib/CardDAV/Validation/CardDavValidatePlugin.php new file mode 100644 index 00000000000..a5fd80ec124 --- /dev/null +++ b/apps/dav/lib/CardDAV/Validation/CardDavValidatePlugin.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\CardDAV\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 CardDavValidatePlugin 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 + $cardSizeLimit = $this->config->getValueInt(Application::APP_ID, 'card_size_limit', 5242880); + if ((int)$request->getRawServerValue('CONTENT_LENGTH') > $cardSizeLimit) { + throw new Forbidden("VCard object exceeds $cardSizeLimit bytes"); + } + // all tests passed return true + return true; + } + +} diff --git a/apps/dav/lib/CardDAV/Xml/Groups.php b/apps/dav/lib/CardDAV/Xml/Groups.php index bde36129382..07aeecb3fa2 100644 --- a/apps/dav/lib/CardDAV/Xml/Groups.php +++ b/apps/dav/lib/CardDAV/Xml/Groups.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CardDAV\Xml; @@ -29,14 +13,12 @@ use Sabre\Xml\XmlSerializable; class Groups implements XmlSerializable { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; - /** @var string[] of TYPE:CHECKSUM */ - private $groups; - /** - * @param string $groups + * @param list<string> $groups */ - public function __construct($groups) { - $this->groups = $groups; + public function __construct( + private array $groups, + ) { } public function xmlSerialize(Writer $writer) { diff --git a/apps/dav/lib/Command/ClearCalendarUnshares.php b/apps/dav/lib/Command/ClearCalendarUnshares.php new file mode 100644 index 00000000000..bb367a9cd0f --- /dev/null +++ b/apps/dav/lib/Command/ClearCalendarUnshares.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Command; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Sharing\Backend; +use OCA\DAV\CalDAV\Sharing\Service; +use OCA\DAV\Connector\Sabre\Principal; +use OCA\DAV\DAV\Sharing\Backend as BackendAlias; +use OCA\DAV\DAV\Sharing\SharingMapper; +use OCP\IAppConfig; +use OCP\IUserManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +#[AsCommand( + name: 'dav:clear-calendar-unshares', + description: 'Clear calendar unshares for a user', + hidden: false, +)] +class ClearCalendarUnshares extends Command { + public function __construct( + private IUserManager $userManager, + private IAppConfig $appConfig, + private Principal $principal, + private CalDavBackend $caldav, + private Backend $sharingBackend, + private Service $sharingService, + private SharingMapper $mapper, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->addArgument( + 'uid', + InputArgument::REQUIRED, + 'User whose unshares to clear' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = (string)$input->getArgument('uid'); + if (!$this->userManager->userExists($user)) { + throw new \InvalidArgumentException("User $user is unknown"); + } + + $principal = $this->principal->getPrincipalByPath('principals/users/' . $user); + if ($principal === null) { + throw new \InvalidArgumentException("Unable to fetch principal for user $user "); + } + + $shares = $this->mapper->getSharesByPrincipals([$principal['uri']], 'calendar'); + $unshares = array_filter($shares, static fn ($share) => $share['access'] === BackendAlias::ACCESS_UNSHARED); + + if (count($unshares) === 0) { + $output->writeln("User $user has no calendar unshares"); + return self::SUCCESS; + } + + $rows = array_map(fn ($share) => $this->formatCalendarUnshare($share), $shares); + + $table = new Table($output); + $table + ->setHeaders(['Share Id', 'Calendar Id', 'Calendar URI', 'Calendar Name']) + ->setRows($rows) + ->render(); + + $output->writeln(''); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Please confirm to delete the above calendar unshare entries [y/n]', false); + + if ($helper->ask($input, $output, $question)) { + $this->mapper->deleteUnsharesByPrincipal($principal['uri'], 'calendar'); + $output->writeln("Calendar unshares for user $user deleted"); + } + + return self::SUCCESS; + } + + private function formatCalendarUnshare(array $share): array { + $calendarInfo = $this->caldav->getCalendarById($share['resourceid']); + + $resourceUri = 'Resource not found'; + $resourceName = ''; + + if ($calendarInfo !== null) { + $resourceUri = $calendarInfo['uri']; + $resourceName = $calendarInfo['{DAV:}displayname']; + } + + return [ + $share['id'], + $share['resourceid'], + $resourceUri, + $resourceName, + ]; + } +} diff --git a/apps/dav/lib/Command/ClearContactsPhotoCache.php b/apps/dav/lib/Command/ClearContactsPhotoCache.php new file mode 100644 index 00000000000..82e64c3145a --- /dev/null +++ b/apps/dav/lib/Command/ClearContactsPhotoCache.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Command; + +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\NotPermittedException; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +#[AsCommand( + name: 'dav:clear-contacts-photo-cache', + description: 'Clear cached contact photos', + hidden: false, +)] +class ClearContactsPhotoCache extends Command { + + public function __construct( + private IAppDataFactory $appDataFactory, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $photoCacheAppData = $this->appDataFactory->get('dav-photocache'); + + $folders = $photoCacheAppData->getDirectoryListing(); + $countFolders = count($folders); + + if ($countFolders === 0) { + $output->writeln('No cached contact photos found.'); + return self::SUCCESS; + } + + $output->writeln('Found ' . count($folders) . ' cached contact photos.'); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Please confirm to clear the contacts photo cache [y/n] ', true); + + if ($helper->ask($input, $output, $question) === false) { + $output->writeln('Clearing the contacts photo cache aborted.'); + return self::SUCCESS; + } + + $progressBar = new ProgressBar($output, $countFolders); + $progressBar->start(); + + foreach ($folders as $folder) { + try { + $folder->delete(); + } catch (NotPermittedException) { + } + $progressBar->advance(); + } + + $progressBar->finish(); + + $output->writeln(''); + $output->writeln('Contacts photo cache cleared.'); + + return self::SUCCESS; + } +} diff --git a/apps/dav/lib/Command/CreateAddressBook.php b/apps/dav/lib/Command/CreateAddressBook.php index 3d56d95868d..9626edeba26 100644 --- a/apps/dav/lib/Command/CreateAddressBook.php +++ b/apps/dav/lib/Command/CreateAddressBook.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.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: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Command; @@ -32,35 +15,23 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class CreateAddressBook extends Command { - - /** @var IUserManager */ - private $userManager; - - /** @var CardDavBackend */ - private $cardDavBackend; - - /** - * @param IUserManager $userManager - * @param CardDavBackend $cardDavBackend - */ - public function __construct(IUserManager $userManager, - CardDavBackend $cardDavBackend + public function __construct( + private IUserManager $userManager, + private CardDavBackend $cardDavBackend, ) { parent::__construct(); - $this->userManager = $userManager; - $this->cardDavBackend = $cardDavBackend; } - protected function configure() { + protected function configure(): void { $this - ->setName('dav:create-addressbook') - ->setDescription('Create a dav addressbook') - ->addArgument('user', - InputArgument::REQUIRED, - 'User for whom the addressbook will be created') - ->addArgument('name', - InputArgument::REQUIRED, - 'Name of the addressbook'); + ->setName('dav:create-addressbook') + ->setDescription('Create a dav addressbook') + ->addArgument('user', + InputArgument::REQUIRED, + 'User for whom the addressbook will be created') + ->addArgument('name', + InputArgument::REQUIRED, + 'Name of the addressbook'); } protected function execute(InputInterface $input, OutputInterface $output): int { @@ -71,6 +42,6 @@ class CreateAddressBook extends Command { $name = $input->getArgument('name'); $this->cardDavBackend->createAddressBook("principals/users/$user", $name, []); - return 0; + return self::SUCCESS; } } diff --git a/apps/dav/lib/Command/CreateCalendar.php b/apps/dav/lib/Command/CreateCalendar.php index 1d818809245..033b5f8d347 100644 --- a/apps/dav/lib/Command/CreateCalendar.php +++ b/apps/dav/lib/Command/CreateCalendar.php @@ -1,68 +1,43 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Command; use OC\KnownUser\KnownUserService; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\Proxy\ProxyMapper; +use OCA\DAV\CalDAV\Sharing\Backend; use OCA\DAV\Connector\Sabre\Principal; +use OCP\Accounts\IAccountManager; +use OCP\App\IAppManager; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IDBConnection; use OCP\IGroupManager; use OCP\IUserManager; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use OCP\Server; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class CreateCalendar extends Command { - - /** @var IUserManager */ - protected $userManager; - - /** @var IGroupManager $groupManager */ - private $groupManager; - - /** @var \OCP\IDBConnection */ - protected $dbConnection; - - /** - * @param IUserManager $userManager - * @param IGroupManager $groupManager - * @param IDBConnection $dbConnection - */ - public function __construct(IUserManager $userManager, IGroupManager $groupManager, IDBConnection $dbConnection) { + public function __construct( + protected IUserManager $userManager, + private IGroupManager $groupManager, + protected IDBConnection $dbConnection, + ) { parent::__construct(); - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->dbConnection = $dbConnection; } - protected function configure() { + protected function configure(): void { $this ->setName('dav:create-calendar') ->setDescription('Create a dav calendar') @@ -82,33 +57,31 @@ class CreateCalendar extends Command { $principalBackend = new Principal( $this->userManager, $this->groupManager, - \OC::$server->getShareManager(), - \OC::$server->getUserSession(), - \OC::$server->getAppManager(), - \OC::$server->query(ProxyMapper::class), - \OC::$server->get(KnownUserService::class), - \OC::$server->getConfig(), + Server::get(IAccountManager::class), + Server::get(\OCP\Share\IManager::class), + Server::get(IUserSession::class), + Server::get(IAppManager::class), + Server::get(ProxyMapper::class), + Server::get(KnownUserService::class), + Server::get(IConfig::class), \OC::$server->getL10NFactory(), ); - $random = \OC::$server->getSecureRandom(); - $logger = \OC::$server->getLogger(); - $dispatcher = \OC::$server->get(IEventDispatcher::class); - $legacyDispatcher = \OC::$server->getEventDispatcher(); - $config = \OC::$server->get(IConfig::class); - + $random = Server::get(ISecureRandom::class); + $logger = Server::get(LoggerInterface::class); + $dispatcher = Server::get(IEventDispatcher::class); + $config = Server::get(IConfig::class); $name = $input->getArgument('name'); $caldav = new CalDavBackend( $this->dbConnection, $principalBackend, $this->userManager, - $this->groupManager, $random, $logger, $dispatcher, - $legacyDispatcher, - $config + $config, + Server::get(Backend::class), ); $caldav->createCalendar("principals/users/$user", $name, []); - return 0; + return self::SUCCESS; } } diff --git a/apps/dav/lib/Command/CreateSubscription.php b/apps/dav/lib/Command/CreateSubscription.php new file mode 100644 index 00000000000..1364070e530 --- /dev/null +++ b/apps/dav/lib/Command/CreateSubscription.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Command; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\Theming\ThemingDefaults; +use OCP\IUserManager; +use Sabre\DAV\Xml\Property\Href; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class CreateSubscription extends Command { + public function __construct( + protected IUserManager $userManager, + private CalDavBackend $caldav, + private ThemingDefaults $themingDefaults, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('dav:create-subscription') + ->setDescription('Create a dav subscription') + ->addArgument('user', + InputArgument::REQUIRED, + 'User for whom the subscription will be created') + ->addArgument('name', + InputArgument::REQUIRED, + 'Name of the subscription to create') + ->addArgument('url', + InputArgument::REQUIRED, + 'Source url of the subscription to create') + ->addArgument('color', + InputArgument::OPTIONAL, + 'Hex color code for the calendar color'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = $input->getArgument('user'); + if (!$this->userManager->userExists($user)) { + $output->writeln("<error>User <$user> in unknown.</error>"); + return self::FAILURE; + } + + $name = $input->getArgument('name'); + $url = $input->getArgument('url'); + $color = $input->getArgument('color') ?? $this->themingDefaults->getColorPrimary(); + $subscriptions = $this->caldav->getSubscriptionsForUser("principals/users/$user"); + + $exists = array_filter($subscriptions, function ($row) use ($url) { + return $row['source'] === $url; + }); + + if (!empty($exists)) { + $output->writeln("<error>Subscription for url <$url> already exists for this user.</error>"); + return self::FAILURE; + } + + $urlProperty = new Href($url); + $properties = ['{http://owncloud.org/ns}calendar-enabled' => 1, + '{DAV:}displayname' => $name, + '{http://apple.com/ns/ical/}calendar-color' => $color, + '{http://calendarserver.org/ns/}source' => $urlProperty, + ]; + $this->caldav->createSubscription("principals/users/$user", $name, $properties); + return self::SUCCESS; + } + +} diff --git a/apps/dav/lib/Command/DeleteCalendar.php b/apps/dav/lib/Command/DeleteCalendar.php index dd5f11c740f..f6dbed856e6 100644 --- a/apps/dav/lib/Command/DeleteCalendar.php +++ b/apps/dav/lib/Command/DeleteCalendar.php @@ -2,24 +2,8 @@ declare(strict_types=1); /** - * - * @copyright Copyright (c) 2021, Mattia Narducci (mattianarducci1@gmail.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 <https://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Command; @@ -38,40 +22,14 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class DeleteCalendar extends Command { - /** @var CalDavBackend */ - private $calDav; - - /** @var IConfig */ - private $config; - - /** @var IL10N */ - private $l10n; - - /** @var IUserManager */ - private $userManager; - - /** @var LoggerInterface */ - private $logger; - - /** - * @param CalDavBackend $calDav - * @param IConfig $config - * @param IL10N $l10n - * @param IUserManager $userManager - */ public function __construct( - CalDavBackend $calDav, - IConfig $config, - IL10N $l10n, - IUserManager $userManager, - LoggerInterface $logger + private CalDavBackend $calDav, + private IConfig $config, + private IL10N $l10n, + private IUserManager $userManager, + private LoggerInterface $logger, ) { parent::__construct(); - $this->calDav = $calDav; - $this->config = $config; - $this->l10n = $l10n; - $this->userManager = $userManager; - $this->logger = $logger; } protected function configure(): void { @@ -96,9 +54,9 @@ class DeleteCalendar extends Command { protected function execute( InputInterface $input, - OutputInterface $output + OutputInterface $output, ): int { - /** @var string $user **/ + /** @var string $user */ $user = $input->getArgument('uid'); if (!$this->userManager->userExists($user)) { throw new \InvalidArgumentException( @@ -109,7 +67,7 @@ class DeleteCalendar extends Command { if ($birthday !== false) { $name = BirthdayService::BIRTHDAY_CALENDAR_URI; } else { - /** @var string $name **/ + /** @var string $name */ $name = $input->getArgument('name'); if (!$name) { throw new \InvalidArgumentException( @@ -140,6 +98,6 @@ class DeleteCalendar extends Command { $calendar->delete(); - return 0; + return self::SUCCESS; } } diff --git a/apps/dav/lib/Command/DeleteSubscription.php b/apps/dav/lib/Command/DeleteSubscription.php new file mode 100644 index 00000000000..db0cb6295c9 --- /dev/null +++ b/apps/dav/lib/Command/DeleteSubscription.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Command; + +use OCA\DAV\CalDAV\CachedSubscription; +use OCA\DAV\CalDAV\CalDavBackend; +use OCP\IUserManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'dav:delete-subscription', + description: 'Delete a calendar subscription for a user', + hidden: false, +)] +class DeleteSubscription extends Command { + public function __construct( + private CalDavBackend $calDavBackend, + private IUserManager $userManager, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->addArgument( + 'uid', + InputArgument::REQUIRED, + 'User who owns the calendar subscription' + ) + ->addArgument( + 'uri', + InputArgument::REQUIRED, + 'URI of the calendar to be deleted' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = (string)$input->getArgument('uid'); + if (!$this->userManager->userExists($user)) { + throw new \InvalidArgumentException("User $user is unknown"); + } + + $uri = (string)$input->getArgument('uri'); + if ($uri === '') { + throw new \InvalidArgumentException('Specify the URI of the calendar to be deleted'); + } + + $subscriptionInfo = $this->calDavBackend->getSubscriptionByUri( + 'principals/users/' . $user, + $uri + ); + + if ($subscriptionInfo === null) { + throw new \InvalidArgumentException("User $user has no calendar subscription with the URI $uri"); + } + + $subscription = new CachedSubscription( + $this->calDavBackend, + $subscriptionInfo, + ); + + $subscription->delete(); + + $output->writeln("Calendar subscription with the URI $uri for user $user deleted"); + + return self::SUCCESS; + } +} diff --git a/apps/dav/lib/Command/ExportCalendar.php b/apps/dav/lib/Command/ExportCalendar.php new file mode 100644 index 00000000000..6ed8aa2cfe4 --- /dev/null +++ b/apps/dav/lib/Command/ExportCalendar.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Command; + +use InvalidArgumentException; +use OCA\DAV\CalDAV\Export\ExportService; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\ICalendarExport; +use OCP\Calendar\IManager; +use OCP\IUserManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Calendar Export Command + * + * Used to export data from supported calendars to disk or stdout + */ +#[AsCommand( + name: 'calendar:export', + description: 'Export calendar data from supported calendars to disk or stdout', + hidden: false +)] +class ExportCalendar extends Command { + public function __construct( + private IUserManager $userManager, + private IManager $calendarManager, + private ExportService $exportService, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->setName('calendar:export') + ->setDescription('Export calendar data from supported calendars to disk or stdout') + ->addArgument('uid', InputArgument::REQUIRED, 'Id of system user') + ->addArgument('uri', InputArgument::REQUIRED, 'Uri of calendar') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Format of output (ical, jcal, xcal) defaults to ical', 'ical') + ->addOption('location', null, InputOption::VALUE_REQUIRED, 'Location of where to write the output. defaults to stdout'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $userId = $input->getArgument('uid'); + $calendarId = $input->getArgument('uri'); + $format = $input->getOption('format'); + $location = $input->getOption('location'); + + if (!$this->userManager->userExists($userId)) { + throw new InvalidArgumentException("User <$userId> not found."); + } + // retrieve calendar and evaluate if export is supported + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + throw new InvalidArgumentException("Calendar <$calendarId> not found."); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarExport) { + throw new InvalidArgumentException("Calendar <$calendarId> does not support exporting"); + } + // construct options object + $options = new CalendarExportOptions(); + // evaluate if provided format is supported + if (!in_array($format, ExportService::FORMATS, true)) { + throw new InvalidArgumentException("Format <$format> is not valid."); + } + $options->setFormat($format); + // evaluate is a valid location was given and is usable otherwise output to stdout + if ($location !== null) { + $handle = fopen($location, 'wb'); + if ($handle === false) { + throw new InvalidArgumentException("Location <$location> is not valid. Can not open location for write operation."); + } + + foreach ($this->exportService->export($calendar, $options) as $chunk) { + fwrite($handle, $chunk); + } + fclose($handle); + } else { + foreach ($this->exportService->export($calendar, $options) as $chunk) { + $output->writeln($chunk); + } + } + + return self::SUCCESS; + } +} diff --git a/apps/dav/lib/Command/FixCalendarSyncCommand.php b/apps/dav/lib/Command/FixCalendarSyncCommand.php new file mode 100644 index 00000000000..cb31355c10d --- /dev/null +++ b/apps/dav/lib/Command/FixCalendarSyncCommand.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Command; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCP\IUser; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class FixCalendarSyncCommand extends Command { + + public function __construct( + private IUserManager $userManager, + private CalDavBackend $calDavBackend, + ) { + parent::__construct('dav:fix-missing-caldav-changes'); + } + + protected function configure(): void { + $this->setDescription('Insert missing calendarchanges rows for existing events'); + $this->addArgument( + 'user', + InputArgument::OPTIONAL, + 'User to fix calendar sync tokens for, if omitted all users will be fixed', + null, + ); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $userArg = $input->getArgument('user'); + if ($userArg !== null) { + $user = $this->userManager->get($userArg); + if ($user === null) { + $output->writeln("<error>User $userArg does not exist</error>"); + return self::FAILURE; + } + + $this->fixUserCalendars($user); + } else { + $progress = new ProgressBar($output); + $this->userManager->callForSeenUsers(function (IUser $user) use ($progress): void { + $this->fixUserCalendars($user, $progress); + }); + $progress->finish(); + } + $output->writeln(''); + return self::SUCCESS; + } + + private function fixUserCalendars(IUser $user, ?ProgressBar $progress = null): void { + $calendars = $this->calDavBackend->getCalendarsForUser('principals/users/' . $user->getUID()); + + foreach ($calendars as $calendar) { + $this->calDavBackend->restoreChanges($calendar['id']); + } + + if ($progress !== null) { + $progress->advance(); + } + } + +} diff --git a/apps/dav/lib/Command/GetAbsenceCommand.php b/apps/dav/lib/Command/GetAbsenceCommand.php new file mode 100644 index 00000000000..50d8df4ab38 --- /dev/null +++ b/apps/dav/lib/Command/GetAbsenceCommand.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\DAV\Command; + +use OCA\DAV\Service\AbsenceService; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class GetAbsenceCommand extends Command { + + public function __construct( + private IUserManager $userManager, + private AbsenceService $absenceService, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->setName('dav:absence:get'); + $this->addArgument( + 'user-id', + InputArgument::REQUIRED, + 'User ID of the affected account' + ); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $userId = $input->getArgument('user-id'); + + $user = $this->userManager->get($userId); + if ($user === null) { + $output->writeln('<error>User not found</error>'); + return 1; + } + + $absence = $this->absenceService->getAbsence($userId); + if ($absence === null) { + $output->writeln('<info>No absence set</info>'); + return 0; + } + + $output->writeln('<info>Start day:</info> ' . $absence->getFirstDay()); + $output->writeln('<info>End day:</info> ' . $absence->getLastDay()); + $output->writeln('<info>Short message:</info> ' . $absence->getStatus()); + $output->writeln('<info>Message:</info> ' . $absence->getMessage()); + $output->writeln('<info>Replacement user:</info> ' . ($absence->getReplacementUserId() ?? 'none')); + $output->writeln('<info>Replacement display name:</info> ' . ($absence->getReplacementUserDisplayName() ?? 'none')); + + return 0; + } + +} diff --git a/apps/dav/lib/Command/ListAddressbooks.php b/apps/dav/lib/Command/ListAddressbooks.php new file mode 100644 index 00000000000..c0b6e63ccb8 --- /dev/null +++ b/apps/dav/lib/Command/ListAddressbooks.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Command; + +use OCA\DAV\CardDAV\CardDavBackend; +use OCA\DAV\CardDAV\SystemAddressbook; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class ListAddressbooks extends Command { + public function __construct( + protected IUserManager $userManager, + private CardDavBackend $cardDavBackend, + ) { + parent::__construct('dav:list-addressbooks'); + } + + protected function configure(): void { + $this + ->setDescription('List all addressbooks of a user') + ->addArgument('uid', + InputArgument::REQUIRED, + 'User for whom all addressbooks will be listed'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = $input->getArgument('uid'); + if (!$this->userManager->userExists($user)) { + throw new \InvalidArgumentException("User <$user> is unknown."); + } + + $addressBooks = $this->cardDavBackend->getAddressBooksForUser("principals/users/$user"); + + $addressBookTableData = []; + foreach ($addressBooks as $book) { + // skip system / contacts integration address book + if ($book['uri'] === SystemAddressbook::URI_SHARED) { + continue; + } + + $readOnly = false; + $readOnlyIndex = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'; + if (isset($book[$readOnlyIndex])) { + $readOnly = $book[$readOnlyIndex]; + } + + $addressBookTableData[] = [ + $book['uri'], + $book['{DAV:}displayname'], + $book['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] ?? $book['principaluri'], + $book['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname'], + $readOnly ? ' x ' : ' ✓ ', + ]; + } + + if (count($addressBookTableData) > 0) { + $table = new Table($output); + $table->setHeaders(['Database ID', 'URI', 'Displayname', 'Owner principal', 'Owner displayname', 'Writable']) + ->setRows($addressBookTableData); + + $table->render(); + } else { + $output->writeln("<info>User <$user> has no addressbooks</info>"); + } + return self::SUCCESS; + } +} diff --git a/apps/dav/lib/Command/ListCalendarShares.php b/apps/dav/lib/Command/ListCalendarShares.php new file mode 100644 index 00000000000..2729bc80530 --- /dev/null +++ b/apps/dav/lib/Command/ListCalendarShares.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Command; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Sharing\Backend; +use OCA\DAV\Connector\Sabre\Principal; +use OCA\DAV\DAV\Sharing\SharingMapper; +use OCP\IUserManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'dav:list-calendar-shares', + description: 'List all calendar shares for a user', + hidden: false, +)] +class ListCalendarShares extends Command { + public function __construct( + private IUserManager $userManager, + private Principal $principal, + private CalDavBackend $caldav, + private SharingMapper $mapper, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->addArgument( + 'uid', + InputArgument::REQUIRED, + 'User whose calendar shares will be listed' + ); + $this->addOption( + 'calendar-id', + '', + InputOption::VALUE_REQUIRED, + 'List only shares for the given calendar id id', + null, + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = (string)$input->getArgument('uid'); + if (!$this->userManager->userExists($user)) { + throw new \InvalidArgumentException("User $user is unknown"); + } + + $principal = $this->principal->getPrincipalByPath('principals/users/' . $user); + if ($principal === null) { + throw new \InvalidArgumentException("Unable to fetch principal for user $user"); + } + + $memberships = array_merge( + [$principal['uri']], + $this->principal->getGroupMembership($principal['uri']), + $this->principal->getCircleMembership($principal['uri']), + ); + + $shares = $this->mapper->getSharesByPrincipals($memberships, 'calendar'); + + $calendarId = $input->getOption('calendar-id'); + if ($calendarId !== null) { + $shares = array_filter($shares, fn ($share) => $share['resourceid'] === (int)$calendarId); + } + + $rows = array_map(fn ($share) => $this->formatCalendarShare($share), $shares); + + if (count($rows) > 0) { + $table = new Table($output); + $table + ->setHeaders(['Share Id', 'Calendar Id', 'Calendar URI', 'Calendar Name', 'Calendar Owner', 'Access By', 'Permissions']) + ->setRows($rows) + ->render(); + } else { + $output->writeln("User $user has no calendar shares"); + } + + return self::SUCCESS; + } + + private function formatCalendarShare(array $share): array { + $calendarInfo = $this->caldav->getCalendarById($share['resourceid']); + + $calendarUri = 'Resource not found'; + $calendarName = ''; + $calendarOwner = ''; + + if ($calendarInfo !== null) { + $calendarUri = $calendarInfo['uri']; + $calendarName = $calendarInfo['{DAV:}displayname']; + $calendarOwner = $calendarInfo['{http://nextcloud.com/ns}owner-displayname'] . ' (' . $calendarInfo['principaluri'] . ')'; + } + + $accessBy = match (true) { + str_starts_with($share['principaluri'], 'principals/users/') => 'Individual', + str_starts_with($share['principaluri'], 'principals/groups/') => 'Group (' . $share['principaluri'] . ')', + str_starts_with($share['principaluri'], 'principals/circles/') => 'Team (' . $share['principaluri'] . ')', + default => $share['principaluri'], + }; + + $permissions = match ($share['access']) { + Backend::ACCESS_READ => 'Read', + Backend::ACCESS_READ_WRITE => 'Read/Write', + Backend::ACCESS_UNSHARED => 'Unshare', + default => $share['access'], + }; + + return [ + $share['id'], + $share['resourceid'], + $calendarUri, + $calendarName, + $calendarOwner, + $accessBy, + $permissions, + ]; + } +} diff --git a/apps/dav/lib/Command/ListCalendars.php b/apps/dav/lib/Command/ListCalendars.php index 35581c2d4b2..408a7e5247f 100644 --- a/apps/dav/lib/Command/ListCalendars.php +++ b/apps/dav/lib/Command/ListCalendars.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2018, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Command; @@ -35,24 +16,14 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class ListCalendars extends Command { - - /** @var IUserManager */ - protected $userManager; - - /** @var CalDavBackend */ - private $caldav; - - /** - * @param IUserManager $userManager - * @param CalDavBackend $caldav - */ - public function __construct(IUserManager $userManager, CalDavBackend $caldav) { + public function __construct( + protected IUserManager $userManager, + private CalDavBackend $caldav, + ) { parent::__construct(); - $this->userManager = $userManager; - $this->caldav = $caldav; } - protected function configure() { + protected function configure(): void { $this ->setName('dav:list-calendars') ->setDescription('List all calendars of a user') @@ -93,13 +64,13 @@ class ListCalendars extends Command { if (count($calendarTableData) > 0) { $table = new Table($output); - $table->setHeaders(['uri', 'displayname', 'owner\'s userid', 'owner\'s displayname', 'writable']) + $table->setHeaders(['URI', 'Displayname', 'Owner principal', 'Owner displayname', 'Writable']) ->setRows($calendarTableData); $table->render(); } else { $output->writeln("<info>User <$user> has no calendars</info>"); } - return 0; + return self::SUCCESS; } } diff --git a/apps/dav/lib/Command/ListSubscriptions.php b/apps/dav/lib/Command/ListSubscriptions.php new file mode 100644 index 00000000000..67753f25973 --- /dev/null +++ b/apps/dav/lib/Command/ListSubscriptions.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Command; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCP\IAppConfig; +use OCP\IUserManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'dav:list-subscriptions', + description: 'List all calendar subscriptions for a user', + hidden: false, +)] +class ListSubscriptions extends Command { + public function __construct( + private IUserManager $userManager, + private IAppConfig $appConfig, + private CalDavBackend $caldav, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->addArgument( + 'uid', + InputArgument::REQUIRED, + 'User whose calendar subscriptions will be listed' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = (string)$input->getArgument('uid'); + if (!$this->userManager->userExists($user)) { + throw new \InvalidArgumentException("User $user is unknown"); + } + + $defaultRefreshRate = $this->appConfig->getValueString('dav', 'calendarSubscriptionRefreshRate', 'P1D'); + $subscriptions = $this->caldav->getSubscriptionsForUser("principals/users/$user"); + $rows = []; + + foreach ($subscriptions as $subscription) { + $rows[] = [ + $subscription['uri'], + $subscription['{DAV:}displayname'], + $subscription['{http://apple.com/ns/ical/}refreshrate'] ?? ($defaultRefreshRate . ' (default)'), + $subscription['source'], + ]; + } + + usort($rows, static fn (array $a, array $b) => $a[0] <=> $b[0]); + + if (count($rows) > 0) { + $table = new Table($output); + $table + ->setHeaders(['URI', 'Displayname', 'Refresh rate', 'Source']) + ->setRows($rows) + ->render(); + } else { + $output->writeln("User $user has no subscriptions"); + } + + return self::SUCCESS; + } +} diff --git a/apps/dav/lib/Command/MoveCalendar.php b/apps/dav/lib/Command/MoveCalendar.php index 320fe8aeac6..b8acc191cc3 100644 --- a/apps/dav/lib/Command/MoveCalendar.php +++ b/apps/dav/lib/Command/MoveCalendar.php @@ -1,28 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Thomas Citharel <nextcloud@tcit.fr> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Command; @@ -42,61 +22,23 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; class MoveCalendar extends Command { - - /** @var IUserManager */ - private $userManager; - - /** @var IGroupManager */ - private $groupManager; - - /** @var IShareManager */ - private $shareManager; - - /** @var IConfig $config */ - private $config; - - /** @var IL10N */ - private $l10n; - - /** @var SymfonyStyle */ - private $io; - - /** @var CalDavBackend */ - private $calDav; - - /** @var LoggerInterface */ - private $logger; + private ?SymfonyStyle $io = null; public const URI_USERS = 'principals/users/'; - /** - * @param IUserManager $userManager - * @param IGroupManager $groupManager - * @param IShareManager $shareManager - * @param IConfig $config - * @param IL10N $l10n - * @param CalDavBackend $calDav - */ public function __construct( - IUserManager $userManager, - IGroupManager $groupManager, - IShareManager $shareManager, - IConfig $config, - IL10N $l10n, - CalDavBackend $calDav, - LoggerInterface $logger + private IUserManager $userManager, + private IGroupManager $groupManager, + private IShareManager $shareManager, + private IConfig $config, + private IL10N $l10n, + private CalDavBackend $calDav, + private LoggerInterface $logger, ) { parent::__construct(); - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->shareManager = $shareManager; - $this->config = $config; - $this->l10n = $l10n; - $this->calDav = $calDav; - $this->logger = $logger; } - protected function configure() { + protected function configure(): void { $this ->setName('dav:move-calendar') ->setDescription('Move a calendar from an user to another') @@ -109,7 +51,7 @@ class MoveCalendar extends Command { ->addArgument('destinationuid', InputArgument::REQUIRED, 'User who will receive the calendar') - ->addOption('force', 'f', InputOption::VALUE_NONE, "Force the migration by removing existing shares and renaming calendars in case of conflicts"); + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force the migration by removing existing shares and renaming calendars in case of conflicts'); } protected function execute(InputInterface $input, OutputInterface $output): int { @@ -131,7 +73,7 @@ class MoveCalendar extends Command { $calendar = $this->calDav->getCalendarByUri(self::URI_USERS . $userOrigin, $name); - if (null === $calendar) { + if ($calendar === null) { throw new \InvalidArgumentException("User <$userOrigin> has no calendar named <$name>. You can run occ dav:list-calendars to list calendars URIs for this user."); } @@ -156,35 +98,27 @@ class MoveCalendar extends Command { * Warn that share links have changed if there are shares */ $this->io->note([ - "Please note that moving calendar " . $calendar['uri'] . " from user <$userOrigin> to <$userDestination> has caused share links to change.", - "Sharees will need to change \"example.com/remote.php/dav/calendars/uid/" . $calendar['uri'] . "_shared_by_$userOrigin\" to \"example.com/remote.php/dav/calendars/uid/" . $newName ?: $calendar['uri'] . "_shared_by_$userDestination\"" + 'Please note that moving calendar ' . $calendar['uri'] . " from user <$userOrigin> to <$userDestination> has caused share links to change.", + 'Sharees will need to change "example.com/remote.php/dav/calendars/uid/' . $calendar['uri'] . "_shared_by_$userOrigin\" to \"example.com/remote.php/dav/calendars/uid/" . $newName ?: $calendar['uri'] . "_shared_by_$userDestination\"" ]); } $this->calDav->moveCalendar($name, self::URI_USERS . $userOrigin, self::URI_USERS . $userDestination, $newName); $this->io->success("Calendar <$name> was moved from user <$userOrigin> to <$userDestination>" . ($newName ? " as <$newName>" : '')); - return 0; + return self::SUCCESS; } /** * Check if the calendar exists for user - * - * @param string $userDestination - * @param string $name - * @return bool */ protected function calendarExists(string $userDestination, string $name): bool { - return null !== $this->calDav->getCalendarByUri(self::URI_USERS . $userDestination, $name); + return $this->calDav->getCalendarByUri(self::URI_USERS . $userDestination, $name) !== null; } /** * Try to find a suitable new calendar name that - * doesn't exists for the provided user - * - * @param string $userDestination - * @param string $name - * @return string + * doesn't exist for the provided user */ protected function getNewCalendarName(string $userDestination, string $name): string { $increment = 1; @@ -206,10 +140,6 @@ class MoveCalendar extends Command { /** * Check that moving the calendar won't break shares * - * @param array $calendar - * @param string $userOrigin - * @param string $userDestination - * @param bool $force * @return bool had any shares or not * @throws \InvalidArgumentException */ @@ -222,11 +152,11 @@ class MoveCalendar extends Command { * Check that user destination is member of the groups which whom the calendar was shared * If we ask to force the migration, the share with the group is dropped */ - if ($this->shareManager->shareWithGroupMembersOnly() === true && 'groups' === $prefix && !$this->groupManager->isInGroup($userDestination, $userOrGroup)) { + if ($this->shareManager->shareWithGroupMembersOnly() === true && $prefix === 'groups' && !$this->groupManager->isInGroup($userDestination, $userOrGroup)) { if ($force) { - $this->calDav->updateShares(new Calendar($this->calDav, $calendar, $this->l10n, $this->config, $this->logger), [], ['href' => 'principal:principals/groups/' . $userOrGroup]); + $this->calDav->updateShares(new Calendar($this->calDav, $calendar, $this->l10n, $this->config, $this->logger), [], ['principal:principals/groups/' . $userOrGroup]); } else { - throw new \InvalidArgumentException("User <$userDestination> is not part of the group <$userOrGroup> with whom the calendar <" . $calendar['uri'] . "> was shared. You may use -f to move the calendar while deleting this share."); + throw new \InvalidArgumentException("User <$userDestination> is not part of the group <$userOrGroup> with whom the calendar <" . $calendar['uri'] . '> was shared. You may use -f to move the calendar while deleting this share.'); } } @@ -235,9 +165,9 @@ class MoveCalendar extends Command { */ if ($userOrGroup === $userDestination) { if ($force) { - $this->calDav->updateShares(new Calendar($this->calDav, $calendar, $this->l10n, $this->config, $this->logger), [], ['href' => 'principal:principals/users/' . $userOrGroup]); + $this->calDav->updateShares(new Calendar($this->calDav, $calendar, $this->l10n, $this->config, $this->logger), [], ['principal:principals/users/' . $userOrGroup]); } else { - throw new \InvalidArgumentException("The calendar <" . $calendar['uri'] . "> is already shared to user <$userDestination>.You may use -f to move the calendar while deleting this share."); + throw new \InvalidArgumentException('The calendar <' . $calendar['uri'] . "> is already shared to user <$userDestination>.You may use -f to move the calendar while deleting this share."); } } } diff --git a/apps/dav/lib/Command/RemoveInvalidShares.php b/apps/dav/lib/Command/RemoveInvalidShares.php index 4f9e4836a72..340e878a912 100644 --- a/apps/dav/lib/Command/RemoveInvalidShares.php +++ b/apps/dav/lib/Command/RemoveInvalidShares.php @@ -2,27 +2,11 @@ declare(strict_types=1); + /** - * @copyright Copyright (c) 2018, ownCloud GmbH - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2018 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Command; @@ -37,21 +21,14 @@ use Symfony\Component\Console\Output\OutputInterface; * have no matching principal. Happened because of a bug in the calendar app. */ class RemoveInvalidShares extends Command { - - /** @var IDBConnection */ - private $connection; - /** @var Principal */ - private $principalBackend; - - public function __construct(IDBConnection $connection, - Principal $principalBackend) { + public function __construct( + private IDBConnection $connection, + private Principal $principalBackend, + ) { parent::__construct(); - - $this->connection = $connection; - $this->principalBackend = $principalBackend; } - protected function configure() { + protected function configure(): void { $this ->setName('dav:remove-invalid-shares') ->setDescription('Remove invalid dav shares'); @@ -61,7 +38,7 @@ class RemoveInvalidShares extends Command { $query = $this->connection->getQueryBuilder(); $result = $query->selectDistinct('principaluri') ->from('dav_shares') - ->execute(); + ->executeQuery(); while ($row = $result->fetch()) { $principaluri = $row['principaluri']; @@ -72,16 +49,16 @@ class RemoveInvalidShares extends Command { } $result->closeCursor(); - return 0; + return self::SUCCESS; } /** * @param string $principaluri */ - private function deleteSharesForPrincipal($principaluri) { + private function deleteSharesForPrincipal($principaluri): void { $delete = $this->connection->getQueryBuilder(); $delete->delete('dav_shares') ->where($delete->expr()->eq('principaluri', $delete->createNamedParameter($principaluri))); - $delete->execute(); + $delete->executeStatement(); } } diff --git a/apps/dav/lib/Command/RetentionCleanupCommand.php b/apps/dav/lib/Command/RetentionCleanupCommand.php index c9beabc974a..f1c941af20e 100644 --- a/apps/dav/lib/Command/RetentionCleanupCommand.php +++ b/apps/dav/lib/Command/RetentionCleanupCommand.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Command; @@ -31,18 +14,15 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class RetentionCleanupCommand extends Command { - /** @var RetentionService */ - private $service; - - public function __construct(RetentionService $service) { + public function __construct( + private RetentionService $service, + ) { parent::__construct('dav:retention:clean-up'); - - $this->service = $service; } protected function execute(InputInterface $input, OutputInterface $output): int { $this->service->cleanUp(); - return 0; + return self::SUCCESS; } } diff --git a/apps/dav/lib/Command/SendEventReminders.php b/apps/dav/lib/Command/SendEventReminders.php index 697248d71a0..89bb5ce8c20 100644 --- a/apps/dav/lib/Command/SendEventReminders.php +++ b/apps/dav/lib/Command/SendEventReminders.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Command; @@ -35,22 +18,11 @@ use Symfony\Component\Console\Output\OutputInterface; * @package OCA\DAV\Command */ class SendEventReminders extends Command { - - /** @var ReminderService */ - protected $reminderService; - - /** @var IConfig */ - protected $config; - - /** - * @param ReminderService $reminderService - * @param IConfig $config - */ - public function __construct(ReminderService $reminderService, - IConfig $config) { + public function __construct( + protected ReminderService $reminderService, + protected IConfig $config, + ) { parent::__construct(); - $this->reminderService = $reminderService; - $this->config = $config; } /** @@ -62,24 +34,20 @@ class SendEventReminders extends Command { ->setDescription('Sends event reminders'); } - /** - * @param InputInterface $input - * @param OutputInterface $output - */ protected function execute(InputInterface $input, OutputInterface $output): int { if ($this->config->getAppValue('dav', 'sendEventReminders', 'yes') !== 'yes') { $output->writeln('<error>Sending event reminders disabled!</error>'); $output->writeln('<info>Please run "php occ config:app:set dav sendEventReminders --value yes"'); - return 1; + return self::FAILURE; } if ($this->config->getAppValue('dav', 'sendEventRemindersMode', 'backgroundjob') !== 'occ') { $output->writeln('<error>Sending event reminders mode set to background-job!</error>'); $output->writeln('<info>Please run "php occ config:app:set dav sendEventRemindersMode --value occ"'); - return 1; + return self::FAILURE; } $this->reminderService->processReminders(); - return 0; + return self::SUCCESS; } } diff --git a/apps/dav/lib/Command/SetAbsenceCommand.php b/apps/dav/lib/Command/SetAbsenceCommand.php new file mode 100644 index 00000000000..bf91a163f95 --- /dev/null +++ b/apps/dav/lib/Command/SetAbsenceCommand.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\DAV\Command; + +use OCA\DAV\Service\AbsenceService; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class SetAbsenceCommand extends Command { + + public function __construct( + private IUserManager $userManager, + private AbsenceService $absenceService, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->setName('dav:absence:set'); + $this->addArgument( + 'user-id', + InputArgument::REQUIRED, + 'User ID of the affected account' + ); + $this->addArgument( + 'first-day', + InputArgument::REQUIRED, + 'Inclusive start day formatted as YYYY-MM-DD' + ); + $this->addArgument( + 'last-day', + InputArgument::REQUIRED, + 'Inclusive end day formatted as YYYY-MM-DD' + ); + $this->addArgument( + 'short-message', + InputArgument::REQUIRED, + 'Short message' + ); + $this->addArgument( + 'message', + InputArgument::REQUIRED, + 'Message' + ); + $this->addArgument( + 'replacement-user-id', + InputArgument::OPTIONAL, + 'Replacement user id' + ); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + $userId = $input->getArgument('user-id'); + + $user = $this->userManager->get($userId); + if ($user === null) { + $output->writeln('<error>User not found</error>'); + return 1; + } + + $replacementUserId = $input->getArgument('replacement-user-id'); + if ($replacementUserId === null) { + $replacementUser = null; + } else { + $replacementUser = $this->userManager->get($replacementUserId); + if ($replacementUser === null) { + $output->writeln('<error>Replacement user not found</error>'); + return 2; + } + } + + $this->absenceService->createOrUpdateAbsence( + $user, + $input->getArgument('first-day'), + $input->getArgument('last-day'), + $input->getArgument('short-message'), + $input->getArgument('message'), + $replacementUser?->getUID(), + $replacementUser?->getDisplayName(), + ); + + return 0; + } + +} diff --git a/apps/dav/lib/Command/SyncBirthdayCalendar.php b/apps/dav/lib/Command/SyncBirthdayCalendar.php index 6de5357bfde..db1ebb6ecb5 100644 --- a/apps/dav/lib/Command/SyncBirthdayCalendar.php +++ b/apps/dav/lib/Command/SyncBirthdayCalendar.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author 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\Command; @@ -35,30 +18,15 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class SyncBirthdayCalendar extends Command { - - /** @var BirthdayService */ - private $birthdayService; - - /** @var IConfig */ - private $config; - - /** @var IUserManager */ - private $userManager; - - /** - * @param IUserManager $userManager - * @param IConfig $config - * @param BirthdayService $birthdayService - */ - public function __construct(IUserManager $userManager, IConfig $config, - BirthdayService $birthdayService) { + public function __construct( + private IUserManager $userManager, + private IConfig $config, + private BirthdayService $birthdayService, + ) { parent::__construct(); - $this->birthdayService = $birthdayService; - $this->config = $config; - $this->userManager = $userManager; } - protected function configure() { + protected function configure(): void { $this ->setName('dav:sync-birthday-calendar') ->setDescription('Synchronizes the birthday calendar') @@ -67,10 +35,6 @@ class SyncBirthdayCalendar extends Command { 'User for whom the birthday calendar will be synchronized'); } - /** - * @param InputInterface $input - * @param OutputInterface $output - */ protected function execute(InputInterface $input, OutputInterface $output): int { $this->verifyEnabled(); @@ -89,12 +53,12 @@ class SyncBirthdayCalendar extends Command { $output->writeln("Start birthday calendar sync for $user"); $this->birthdayService->syncUser($user); - return 0; + return self::SUCCESS; } - $output->writeln("Start birthday calendar sync for all users ..."); + $output->writeln('Start birthday calendar sync for all users ...'); $p = new ProgressBar($output); $p->start(); - $this->userManager->callForSeenUsers(function ($user) use ($p) { + $this->userManager->callForSeenUsers(function ($user) use ($p): void { $p->advance(); $userId = $user->getUID(); @@ -109,10 +73,10 @@ class SyncBirthdayCalendar extends Command { $p->finish(); $output->writeln(''); - return 0; + return self::SUCCESS; } - protected function verifyEnabled() { + protected function verifyEnabled(): void { $isEnabled = $this->config->getAppValue('dav', 'generateBirthdayCalendar', 'yes'); if ($isEnabled !== 'yes') { diff --git a/apps/dav/lib/Command/SyncSystemAddressBook.php b/apps/dav/lib/Command/SyncSystemAddressBook.php index 272cca5a08e..54edba01e05 100644 --- a/apps/dav/lib/Command/SyncSystemAddressBook.php +++ b/apps/dav/lib/Command/SyncSystemAddressBook.php @@ -1,67 +1,44 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.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: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Command; use OCA\DAV\CardDAV\SyncService; +use OCP\IConfig; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class SyncSystemAddressBook extends Command { - - /** @var SyncService */ - private $syncService; - - /** - * @param SyncService $syncService - */ - public function __construct(SyncService $syncService) { + public function __construct( + private SyncService $syncService, + private IConfig $config, + ) { parent::__construct(); - $this->syncService = $syncService; } - protected function configure() { + protected function configure(): void { $this ->setName('dav:sync-system-addressbook') ->setDescription('Synchronizes users to the system addressbook'); } - /** - * @param InputInterface $input - * @param OutputInterface $output - */ protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('Syncing users ...'); $progress = new ProgressBar($output); $progress->start(); - $this->syncService->syncInstance(function () use ($progress) { + $this->syncService->syncInstance(function () use ($progress): void { $progress->advance(); }); $progress->finish(); $output->writeln(''); - return 0; + $this->config->setAppValue('dav', 'needs_system_address_book_sync', 'no'); + return self::SUCCESS; } } diff --git a/apps/dav/lib/Comments/CommentNode.php b/apps/dav/lib/Comments/CommentNode.php index af76027671e..5dbefa82d93 100644 --- a/apps/dav/lib/Comments/CommentNode.php +++ b/apps/dav/lib/Comments/CommentNode.php @@ -1,34 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Comments; use OCP\Comments\IComment; use OCP\Comments\ICommentsManager; use OCP\Comments\MessageTooLongException; -use OCP\ILogger; use OCP\IUserManager; use OCP\IUserSession; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\MethodNotAllowed; @@ -46,57 +30,30 @@ class CommentNode implements \Sabre\DAV\INode, \Sabre\DAV\IProperties { public const PROPERTY_NAME_MENTION_ID = '{http://owncloud.org/ns}mentionId'; public const PROPERTY_NAME_MENTION_DISPLAYNAME = '{http://owncloud.org/ns}mentionDisplayName'; - /** @var IComment */ - public $comment; - - /** @var ICommentsManager */ - protected $commentsManager; - - /** @var ILogger */ - protected $logger; - /** @var array list of properties with key being their name and value their setter */ protected $properties = []; - /** @var IUserManager */ - protected $userManager; - - /** @var IUserSession */ - protected $userSession; - /** * CommentNode constructor. - * - * @param ICommentsManager $commentsManager - * @param IComment $comment - * @param IUserManager $userManager - * @param IUserSession $userSession - * @param ILogger $logger */ public function __construct( - ICommentsManager $commentsManager, - IComment $comment, - IUserManager $userManager, - IUserSession $userSession, - ILogger $logger + protected ICommentsManager $commentsManager, + public IComment $comment, + protected IUserManager $userManager, + protected IUserSession $userSession, + protected LoggerInterface $logger, ) { - $this->commentsManager = $commentsManager; - $this->comment = $comment; - $this->logger = $logger; - $methods = get_class_methods($this->comment); $methods = array_filter($methods, function ($name) { - return strpos($name, 'get') === 0; + return str_starts_with($name, 'get'); }); foreach ($methods as $getter) { if ($getter === 'getMentions') { continue; // special treatment } - $name = '{'.self::NS_OWNCLOUD.'}' . lcfirst(substr($getter, 3)); + $name = '{' . self::NS_OWNCLOUD . '}' . lcfirst(substr($getter, 3)); $this->properties[$name] = $getter; } - $this->userManager = $userManager; - $this->userSession = $userSession; } /** @@ -172,10 +129,8 @@ class CommentNode implements \Sabre\DAV\INode, \Sabre\DAV\IProperties { /** * Returns the last modification time, as a unix timestamp - * - * @return int */ - public function getLastModified() { + public function getLastModified(): ?int { return null; } @@ -194,7 +149,7 @@ class CommentNode implements \Sabre\DAV\INode, \Sabre\DAV\IProperties { $this->commentsManager->save($this->comment); return true; } catch (\Exception $e) { - $this->logger->logException($e, ['app' => 'dav/comments']); + $this->logger->error($e->getMessage(), ['app' => 'dav/comments', 'exception' => $e]); if ($e instanceof MessageTooLongException) { $msg = 'Message exceeds allowed character limit of '; throw new BadRequest($msg . IComment::MAX_MESSAGE_LENGTH, 0, $e); @@ -287,7 +242,7 @@ class CommentNode implements \Sabre\DAV\INode, \Sabre\DAV\IProperties { try { $displayName = $this->commentsManager->resolveDisplayName($mention['type'], $mention['id']); } catch (\OutOfBoundsException $e) { - $this->logger->logException($e); + $this->logger->error($e->getMessage(), ['exception' => $e]); // No displayname, upon client's discretion what to display. $displayName = ''; } diff --git a/apps/dav/lib/Comments/CommentsPlugin.php b/apps/dav/lib/Comments/CommentsPlugin.php index a4932751897..2ab7d6ee018 100644 --- a/apps/dav/lib/Comments/CommentsPlugin.php +++ b/apps/dav/lib/Comments/CommentsPlugin.php @@ -1,32 +1,16 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Comments; +use OCP\AppFramework\Http; use OCP\Comments\IComment; use OCP\Comments\ICommentsManager; +use OCP\Comments\MessageTooLongException; use OCP\IUserSession; use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\NotFound; @@ -52,24 +36,19 @@ class CommentsPlugin extends ServerPlugin { public const REPORT_PARAM_OFFSET = '{http://owncloud.org/ns}offset'; public const REPORT_PARAM_TIMESTAMP = '{http://owncloud.org/ns}datetime'; - /** @var ICommentsManager */ - protected $commentsManager; - /** @var \Sabre\DAV\Server $server */ private $server; - /** @var \OCP\IUserSession */ - protected $userSession; - /** * Comments plugin * * @param ICommentsManager $commentsManager * @param IUserSession $userSession */ - public function __construct(ICommentsManager $commentsManager, IUserSession $userSession) { - $this->commentsManager = $commentsManager; - $this->userSession = $userSession; + public function __construct( + protected ICommentsManager $commentsManager, + protected IUserSession $userSession, + ) { } /** @@ -85,13 +64,13 @@ class CommentsPlugin extends ServerPlugin { */ public function initialize(Server $server) { $this->server = $server; - if (strpos($this->server->getRequestUri(), 'comments/') !== 0) { + if (!str_starts_with($this->server->getRequestUri(), 'comments/')) { return; } $this->server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; - $this->server->xml->classMap['DateTime'] = function (Writer $writer, \DateTime $value) { + $this->server->xml->classMap['DateTime'] = function (Writer $writer, \DateTime $value): void { $writer->write(\Sabre\HTTP\toDate($value)); }; @@ -130,7 +109,7 @@ class CommentsPlugin extends ServerPlugin { $response->setHeader('Content-Location', $url); // created - $response->setStatus(201); + $response->setStatus(Http::STATUS_CREATED); return false; } @@ -176,7 +155,7 @@ class CommentsPlugin extends ServerPlugin { } if (!is_null($args['datetime'])) { - $args['datetime'] = new \DateTime($args['datetime']); + $args['datetime'] = new \DateTime((string)$args['datetime']); } $results = $node->findChildren($args['limit'], $args['offset'], $args['datetime']); @@ -189,7 +168,7 @@ class CommentsPlugin extends ServerPlugin { $responses[] = new Response( $this->server->getBaseUri() . $nodePath, [200 => $resultSet[0][200]], - 200 + '200' ); } } @@ -199,7 +178,7 @@ class CommentsPlugin extends ServerPlugin { new MultiStatus($responses) ); - $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->setBody($xml); @@ -220,7 +199,7 @@ class CommentsPlugin extends ServerPlugin { */ private function createComment($objectType, $objectId, $data, $contentType = 'application/json') { if (explode(';', $contentType)[0] === 'application/json') { - $data = json_decode($data, true); + $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); } else { throw new UnsupportedMediaType(); } @@ -234,7 +213,7 @@ class CommentsPlugin extends ServerPlugin { } } if (is_null($actorId)) { - throw new BadRequest('Invalid actor "' . $actorType .'"'); + throw new BadRequest('Invalid actor "' . $actorType . '"'); } try { @@ -245,9 +224,9 @@ class CommentsPlugin extends ServerPlugin { return $comment; } catch (\InvalidArgumentException $e) { throw new BadRequest('Invalid input values', 0, $e); - } catch (\OCP\Comments\MessageTooLongException $e) { + } catch (MessageTooLongException $e) { $msg = 'Message exceeds allowed character limit of '; - throw new BadRequest($msg . \OCP\Comments\IComment::MAX_MESSAGE_LENGTH, 0, $e); + throw new BadRequest($msg . IComment::MAX_MESSAGE_LENGTH, 0, $e); } } } diff --git a/apps/dav/lib/Comments/EntityCollection.php b/apps/dav/lib/Comments/EntityCollection.php index d9b06e1240c..33c58ee44d2 100644 --- a/apps/dav/lib/Comments/EntityCollection.php +++ b/apps/dav/lib/Comments/EntityCollection.php @@ -1,33 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Comments; use OCP\Comments\ICommentsManager; use OCP\Comments\NotFoundException; -use OCP\ILogger; use OCP\IUserManager; use OCP\IUserSession; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\IProperties; use Sabre\DAV\PropPatch; @@ -43,27 +27,21 @@ use Sabre\DAV\PropPatch; class EntityCollection extends RootCollection implements IProperties { public const PROPERTY_NAME_READ_MARKER = '{http://owncloud.org/ns}readMarker'; - /** @var string */ - protected $id; - - /** @var ILogger */ - protected $logger; - /** * @param string $id * @param string $name * @param ICommentsManager $commentsManager * @param IUserManager $userManager * @param IUserSession $userSession - * @param ILogger $logger + * @param LoggerInterface $logger */ public function __construct( - $id, + protected $id, $name, ICommentsManager $commentsManager, IUserManager $userManager, IUserSession $userSession, - ILogger $logger + protected LoggerInterface $logger, ) { foreach (['id', 'name'] as $property) { $$property = trim($$property); @@ -71,10 +49,8 @@ class EntityCollection extends RootCollection implements IProperties { throw new \InvalidArgumentException('"' . $property . '" parameter must be non-empty string'); } } - $this->id = $id; $this->name = $name; $this->commentsManager = $commentsManager; - $this->logger = $logger; $this->userManager = $userManager; $this->userSession = $userSession; } @@ -131,7 +107,7 @@ class EntityCollection extends RootCollection implements IProperties { * @param \DateTime|null $datetime * @return CommentNode[] */ - public function findChildren($limit = 0, $offset = 0, \DateTime $datetime = null) { + public function findChildren($limit = 0, $offset = 0, ?\DateTime $datetime = null) { $comments = $this->commentsManager->getForObject($this->name, $this->id, $limit, $offset, $datetime); $result = []; foreach ($comments as $comment) { @@ -163,12 +139,9 @@ class EntityCollection extends RootCollection implements IProperties { /** * Sets the read marker to the specified date for the logged in user - * - * @param \DateTime $value - * @return bool */ - public function setReadMarker($value) { - $dateTime = new \DateTime($value); + public function setReadMarker(?string $value): bool { + $dateTime = new \DateTime($value ?? 'now'); $user = $this->userSession->getUser(); $this->commentsManager->setReadMark($this->name, $this->id, $dateTime, $user); return true; diff --git a/apps/dav/lib/Comments/EntityTypeCollection.php b/apps/dav/lib/Comments/EntityTypeCollection.php index c9df2a068d7..1c8533ca375 100644 --- a/apps/dav/lib/Comments/EntityTypeCollection.php +++ b/apps/dav/lib/Comments/EntityTypeCollection.php @@ -1,32 +1,16 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Comments; use OCP\Comments\ICommentsManager; -use OCP\ILogger; use OCP\IUserManager; use OCP\IUserSession; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\DAV\Exception\NotFound; @@ -42,42 +26,21 @@ use Sabre\DAV\Exception\NotFound; * @package OCA\DAV\Comments */ class EntityTypeCollection extends RootCollection { - - /** @var ILogger */ - protected $logger; - - /** @var IUserManager */ - protected $userManager; - - /** @var \Closure */ - protected $childExistsFunction; - - /** - * @param string $name - * @param ICommentsManager $commentsManager - * @param IUserManager $userManager - * @param IUserSession $userSession - * @param ILogger $logger - * @param \Closure $childExistsFunction - */ public function __construct( - $name, + string $name, ICommentsManager $commentsManager, - IUserManager $userManager, + protected IUserManager $userManager, IUserSession $userSession, - ILogger $logger, - \Closure $childExistsFunction + protected LoggerInterface $logger, + protected \Closure $childExistsFunction, ) { $name = trim($name); - if (empty($name) || !is_string($name)) { + if (empty($name)) { throw new \InvalidArgumentException('"name" parameter must be non-empty string'); } $this->name = $name; $this->commentsManager = $commentsManager; - $this->logger = $logger; - $this->userManager = $userManager; $this->userSession = $userSession; - $this->childExistsFunction = $childExistsFunction; } /** diff --git a/apps/dav/lib/Comments/RootCollection.php b/apps/dav/lib/Comments/RootCollection.php index e8e890696eb..493d73ec531 100644 --- a/apps/dav/lib/Comments/RootCollection.php +++ b/apps/dav/lib/Comments/RootCollection.php @@ -1,81 +1,35 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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\Comments; use OCP\Comments\CommentsEntityEvent; use OCP\Comments\ICommentsManager; -use OCP\ILogger; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IUserManager; use OCP\IUserSession; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotAuthenticated; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\ICollection; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; class RootCollection implements ICollection { - /** @var EntityTypeCollection[]|null */ - private $entityTypeCollections; - - /** @var ICommentsManager */ - protected $commentsManager; - - /** @var string */ - protected $name = 'comments'; - - /** @var ILogger */ - protected $logger; + private ?array $entityTypeCollections = null; + protected string $name = 'comments'; - /** @var IUserManager */ - protected $userManager; - - /** @var IUserSession */ - protected $userSession; - - /** @var EventDispatcherInterface */ - protected $dispatcher; - - /** - * @param ICommentsManager $commentsManager - * @param IUserManager $userManager - * @param IUserSession $userSession - * @param EventDispatcherInterface $dispatcher - * @param ILogger $logger - */ public function __construct( - ICommentsManager $commentsManager, - IUserManager $userManager, - IUserSession $userSession, - EventDispatcherInterface $dispatcher, - ILogger $logger) { - $this->commentsManager = $commentsManager; - $this->logger = $logger; - $this->userManager = $userManager; - $this->userSession = $userSession; - $this->dispatcher = $dispatcher; + protected ICommentsManager $commentsManager, + protected IUserManager $userManager, + protected IUserSession $userSession, + protected IEventDispatcher $dispatcher, + protected LoggerInterface $logger, + ) { } /** @@ -94,7 +48,8 @@ class RootCollection implements ICollection { throw new NotAuthenticated(); } - $event = new CommentsEntityEvent(CommentsEntityEvent::EVENT_ENTITY); + $event = new CommentsEntityEvent(); + $this->dispatcher->dispatchTyped($event); $this->dispatcher->dispatch(CommentsEntityEvent::EVENT_ENTITY, $event); $this->entityTypeCollections = []; @@ -157,6 +112,7 @@ class RootCollection implements ICollection { */ public function getChildren() { $this->initCollections(); + assert(!is_null($this->entityTypeCollections)); return $this->entityTypeCollections; } @@ -168,6 +124,7 @@ class RootCollection implements ICollection { */ public function childExists($name) { $this->initCollections(); + assert(!is_null($this->entityTypeCollections)); return isset($this->entityTypeCollections[$name]); } @@ -204,7 +161,7 @@ class RootCollection implements ICollection { /** * Returns the last modification time, as a unix timestamp * - * @return int + * @return ?int */ public function getLastModified() { return null; diff --git a/apps/dav/lib/Connector/LegacyDAVACL.php b/apps/dav/lib/Connector/LegacyDAVACL.php index da570b235de..40ce53b8ab0 100644 --- a/apps/dav/lib/Connector/LegacyDAVACL.php +++ b/apps/dav/lib/Connector/LegacyDAVACL.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector; diff --git a/apps/dav/lib/Connector/LegacyPublicAuth.php b/apps/dav/lib/Connector/LegacyPublicAuth.php new file mode 100644 index 00000000000..03d18853de0 --- /dev/null +++ b/apps/dav/lib/Connector/LegacyPublicAuth.php @@ -0,0 +1,102 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector; + +use OCA\DAV\Connector\Sabre\PublicAuth; +use OCP\Defaults; +use OCP\IRequest; +use OCP\ISession; +use OCP\Security\Bruteforce\IThrottler; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IManager; +use OCP\Share\IShare; +use Sabre\DAV\Auth\Backend\AbstractBasic; + +/** + * Class PublicAuth + * + * @package OCA\DAV\Connector + */ +class LegacyPublicAuth extends AbstractBasic { + private const BRUTEFORCE_ACTION = 'legacy_public_webdav_auth'; + + private ?IShare $share = null; + + public function __construct( + private IRequest $request, + private IManager $shareManager, + private ISession $session, + private IThrottler $throttler, + ) { + // setup realm + $defaults = new Defaults(); + $this->realm = $defaults->getName() ?: 'Nextcloud'; + } + + /** + * Validates a username and password + * + * This method should return true or false depending on if login + * succeeded. + * + * @param string $username + * @param string $password + * + * @return bool + * @throws \Sabre\DAV\Exception\NotAuthenticated + */ + protected function validateUserPass($username, $password) { + $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION); + + try { + $share = $this->shareManager->getShareByToken($username); + } catch (ShareNotFound $e) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } + + $this->share = $share; + + \OC_User::setIncognitoMode(true); + + // check if the share is password protected + if ($share->getPassword() !== null) { + if ($share->getShareType() === IShare::TYPE_LINK + || $share->getShareType() === IShare::TYPE_EMAIL + || $share->getShareType() === IShare::TYPE_CIRCLE) { + if ($this->shareManager->checkPassword($share, $password)) { + return true; + } elseif ($this->session->exists(PublicAuth::DAV_AUTHENTICATED) + && $this->session->get(PublicAuth::DAV_AUTHENTICATED) === $share->getId()) { + return true; + } else { + if (in_array('XMLHttpRequest', explode(',', $this->request->getHeader('X-Requested-With')))) { + // do not re-authenticate over ajax, use dummy auth name to prevent browser popup + http_response_code(401); + header('WWW-Authenticate: DummyBasic realm="' . $this->realm . '"'); + throw new \Sabre\DAV\Exception\NotAuthenticated('Cannot authenticate over ajax calls'); + } + + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } + } elseif ($share->getShareType() === IShare::TYPE_REMOTE) { + return true; + } else { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } + } + return true; + } + + public function getShare(): IShare { + assert($this->share !== null); + return $this->share; + } +} diff --git a/apps/dav/lib/Connector/PublicAuth.php b/apps/dav/lib/Connector/PublicAuth.php deleted file mode 100644 index 426cbf871d7..00000000000 --- a/apps/dav/lib/Connector/PublicAuth.php +++ /dev/null @@ -1,147 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Maxence Lange <maxence@artificial-owl.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ -namespace OCA\DAV\Connector; - -use OC\Security\Bruteforce\Throttler; -use OCP\IRequest; -use OCP\ISession; -use OCP\Share\Exceptions\ShareNotFound; -use OCP\Share\IManager; -use OCP\Share\IShare; -use Sabre\DAV\Auth\Backend\AbstractBasic; - -/** - * Class PublicAuth - * - * @package OCA\DAV\Connector - */ -class PublicAuth extends AbstractBasic { - private const BRUTEFORCE_ACTION = 'public_webdav_auth'; - - /** @var \OCP\Share\IShare */ - private $share; - - /** @var IManager */ - private $shareManager; - - /** @var ISession */ - private $session; - - /** @var IRequest */ - private $request; - - /** @var Throttler */ - private $throttler; - - /** - * @param IRequest $request - * @param IManager $shareManager - * @param ISession $session - * @param Throttler $throttler - */ - public function __construct(IRequest $request, - IManager $shareManager, - ISession $session, - Throttler $throttler) { - $this->request = $request; - $this->shareManager = $shareManager; - $this->session = $session; - $this->throttler = $throttler; - - // setup realm - $defaults = new \OCP\Defaults(); - $this->realm = $defaults->getName(); - } - - /** - * Validates a username and password - * - * This method should return true or false depending on if login - * succeeded. - * - * @param string $username - * @param string $password - * - * @return bool - * @throws \Sabre\DAV\Exception\NotAuthenticated - */ - protected function validateUserPass($username, $password) { - $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION); - - try { - $share = $this->shareManager->getShareByToken($username); - } catch (ShareNotFound $e) { - $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); - return false; - } - - $this->share = $share; - - \OC_User::setIncognitoMode(true); - - // check if the share is password protected - if ($share->getPassword() !== null) { - if ($share->getShareType() === IShare::TYPE_LINK - || $share->getShareType() === IShare::TYPE_EMAIL - || $share->getShareType() === IShare::TYPE_CIRCLE) { - if ($this->shareManager->checkPassword($share, $password)) { - return true; - } elseif ($this->session->exists('public_link_authenticated') - && $this->session->get('public_link_authenticated') === (string)$share->getId()) { - return true; - } else { - if (in_array('XMLHttpRequest', explode(',', $this->request->getHeader('X-Requested-With')))) { - // do not re-authenticate over ajax, use dummy auth name to prevent browser popup - http_response_code(401); - header('WWW-Authenticate: DummyBasic realm="' . $this->realm . '"'); - throw new \Sabre\DAV\Exception\NotAuthenticated('Cannot authenticate over ajax calls'); - } - - $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); - return false; - } - } elseif ($share->getShareType() === IShare::TYPE_REMOTE) { - return true; - } else { - $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); - return false; - } - } else { - return true; - } - } - - /** - * @return \OCP\Share\IShare - */ - public function getShare() { - return $this->share; - } -} diff --git a/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php b/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php index 6c3600fa5eb..0e2b1c58748 100644 --- a/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php +++ b/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php @@ -1,28 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Bastien Durel <bastien@durel.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; @@ -54,7 +34,7 @@ class AnonymousOptionsPlugin extends ServerPlugin { * @return bool */ public function isRequestInRoot($path) { - return $path === '' || (is_string($path) && strpos($path, '/') === false); + return $path === '' || (is_string($path) && !str_contains($path, '/')); } /** diff --git a/apps/dav/lib/Connector/Sabre/AppEnabledPlugin.php b/apps/dav/lib/Connector/Sabre/AppEnabledPlugin.php deleted file mode 100644 index 244e5de0683..00000000000 --- a/apps/dav/lib/Connector/Sabre/AppEnabledPlugin.php +++ /dev/null @@ -1,88 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Robin Appelman <robin@icewind.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/> - * - */ -namespace OCA\DAV\Connector\Sabre; - -use OCP\App\IAppManager; -use Sabre\DAV\Exception\Forbidden; -use Sabre\DAV\ServerPlugin; - -/** - * Plugin to check if an app is enabled for the current user - */ -class AppEnabledPlugin extends ServerPlugin { - - /** - * Reference to main server object - * - * @var \Sabre\DAV\Server - */ - private $server; - - /** - * @var string - */ - private $app; - - /** - * @var \OCP\App\IAppManager - */ - private $appManager; - - /** - * @param string $app - * @param \OCP\App\IAppManager $appManager - */ - public function __construct($app, IAppManager $appManager) { - $this->app = $app; - $this->appManager = $appManager; - } - - /** - * 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 \Sabre\DAV\Server $server - * @return void - */ - public function initialize(\Sabre\DAV\Server $server) { - $this->server = $server; - $this->server->on('beforeMethod:*', [$this, 'checkAppEnabled'], 30); - } - - /** - * This method is called before any HTTP after auth and checks if the user has access to the app - * - * @throws \Sabre\DAV\Exception\Forbidden - * @return bool - */ - public function checkAppEnabled() { - if (!$this->appManager->isEnabledForUser($this->app)) { - throw new Forbidden(); - } - } -} diff --git a/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php b/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php new file mode 100644 index 00000000000..9cff113140a --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php @@ -0,0 +1,112 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * A plugin which tries to work-around peculiarities of the MacOS DAV client + * apps. The following problems are addressed: + * + * - OSX calendar client sends REPORT requests to a random principal + * collection but expects to find all principals (forgot to set + * {DAV:}principal-property-search flag?) + */ +class AppleQuirksPlugin extends ServerPlugin { + + /* + private const OSX_CALENDAR_AGENT = 'CalendarAgent'; + private const OSX_DATAACCESSD_AGENT = 'dataaccessd'; + private const OSX_ACCOUNTSD_AGENT = 'accountsd'; + private const OSX_CONTACTS_AGENT = 'AddressBookCore'; + */ + + private const OSX_AGENT_PREFIX = 'macOS'; + + /** @var bool */ + private $isMacOSDavAgent = false; + + /** + * Sets up the plugin. + * + * This method is automatically called by the server class. + * + * @return void + */ + public function initialize(Server $server) { + $server->on('beforeMethod:REPORT', [$this, 'beforeReport'], 0); + $server->on('report', [$this, 'report'], 0); + } + + /** + * Triggered before any method is handled. + * + * @return void + */ + public function beforeReport(RequestInterface $request, ResponseInterface $response) { + $userAgent = $request->getRawServerValue('HTTP_USER_AGENT') ?? 'unknown'; + $this->isMacOSDavAgent = $this->isMacOSUserAgent($userAgent); + } + + /** + * This method handles HTTP REPORT requests. + * + * @param string $reportName + * @param mixed $report + * @param mixed $path + * + * @return bool + */ + public function report($reportName, $report, $path) { + if ($reportName == '{DAV:}principal-property-search' && $this->isMacOSDavAgent) { + /** @var \Sabre\DAVACL\Xml\Request\PrincipalPropertySearchReport $report */ + $report->applyToPrincipalCollectionSet = true; + } + return true; + } + + /** + * Check whether the given $userAgent string pretends to originate from OSX. + * + * @param string $userAgent + * + * @return bool + */ + protected function isMacOSUserAgent(string $userAgent):bool { + return str_starts_with(self::OSX_AGENT_PREFIX, $userAgent); + } + + /** + * Decode the given OSX DAV agent string. + * + * @param string $agent + * + * @return null|array + */ + protected function decodeMacOSAgentString(string $userAgent):?array { + // OSX agent string is like: macOS/13.2.1 (22D68) dataaccessd/1.0 + if (preg_match('|^' . self::OSX_AGENT_PREFIX . '/([0-9]+)\\.([0-9]+)\\.([0-9]+)\s+\((\w+)\)\s+([^/]+)/([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?$|i', $userAgent, $matches)) { + return [ + 'macOSVersion' => [ + 'major' => $matches[1], + 'minor' => $matches[2], + 'patch' => $matches[3], + ], + 'macOSAgent' => $matches[5], + 'macOSAgentVersion' => [ + 'major' => $matches[6], + 'minor' => $matches[7] ?? null, + 'patch' => $matches[8] ?? null, + ], + ]; + } + return null; + } +} diff --git a/apps/dav/lib/Connector/Sabre/Auth.php b/apps/dav/lib/Connector/Sabre/Auth.php index df4e3c65ce0..a174920946a 100644 --- a/apps/dav/lib/Connector/Sabre/Auth.php +++ b/apps/dav/lib/Connector/Sabre/Auth.php @@ -1,46 +1,26 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Markus Goetz <markus@woboq.com> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use Exception; use OC\Authentication\Exceptions\PasswordLoginForbiddenException; use OC\Authentication\TwoFactorAuth\Manager; -use OC\Security\Bruteforce\Throttler; use OC\User\Session; use OCA\DAV\Connector\Sabre\Exception\PasswordLoginForbidden; +use OCA\DAV\Connector\Sabre\Exception\TooManyRequests; +use OCP\AppFramework\Http; +use OCP\Defaults; use OCP\IRequest; use OCP\ISession; +use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\Bruteforce\MaxDelayReached; +use OCP\Server; +use Psr\Log\LoggerInterface; use Sabre\DAV\Auth\Backend\AbstractBasic; use Sabre\DAV\Exception\NotAuthenticated; use Sabre\DAV\Exception\ServiceUnavailable; @@ -49,44 +29,21 @@ use Sabre\HTTP\ResponseInterface; class Auth extends AbstractBasic { public const DAV_AUTHENTICATED = 'AUTHENTICATED_TO_DAV_BACKEND'; - - /** @var ISession */ - private $session; - /** @var Session */ - private $userSession; - /** @var IRequest */ - private $request; - /** @var string */ - private $currentUser; - /** @var Manager */ - private $twoFactorManager; - /** @var Throttler */ - private $throttler; - - /** - * @param ISession $session - * @param Session $userSession - * @param IRequest $request - * @param Manager $twoFactorManager - * @param Throttler $throttler - * @param string $principalPrefix - */ - public function __construct(ISession $session, - Session $userSession, - IRequest $request, - Manager $twoFactorManager, - Throttler $throttler, - $principalPrefix = 'principals/users/') { - $this->session = $session; - $this->userSession = $userSession; - $this->twoFactorManager = $twoFactorManager; - $this->request = $request; - $this->throttler = $throttler; + private ?string $currentUser = null; + + public function __construct( + private ISession $session, + private Session $userSession, + private IRequest $request, + private Manager $twoFactorManager, + private IThrottler $throttler, + string $principalPrefix = 'principals/users/', + ) { $this->principalPrefix = $principalPrefix; // setup realm - $defaults = new \OCP\Defaults(); - $this->realm = $defaults->getName(); + $defaults = new Defaults(); + $this->realm = $defaults->getName() ?: 'Nextcloud'; } /** @@ -96,13 +53,10 @@ class Auth extends AbstractBasic { * account was changed. * * @see https://github.com/owncloud/core/issues/13245 - * - * @param string $username - * @return bool */ - public function isDavAuthenticated($username) { - return !is_null($this->session->get(self::DAV_AUTHENTICATED)) && - $this->session->get(self::DAV_AUTHENTICATED) === $username; + public function isDavAuthenticated(string $username): bool { + return !is_null($this->session->get(self::DAV_AUTHENTICATED)) + && $this->session->get(self::DAV_AUTHENTICATED) === $username; } /** @@ -117,17 +71,14 @@ class Auth extends AbstractBasic { * @throws PasswordLoginForbidden */ protected function validateUserPass($username, $password) { - if ($this->userSession->isLoggedIn() && - $this->isDavAuthenticated($this->userSession->getUser()->getUID()) + if ($this->userSession->isLoggedIn() + && $this->isDavAuthenticated($this->userSession->getUser()->getUID()) ) { - \OC_Util::setupFS($this->userSession->getUser()->getUID()); $this->session->close(); return true; } else { - \OC_Util::setupFS(); //login hooks may need early access to the filesystem try { if ($this->userSession->logClientIn($username, $password, $this->request, $this->throttler)) { - \OC_Util::setupFS($this->userSession->getUser()->getUID()); $this->session->set(self::DAV_AUTHENTICATED, $this->userSession->getUser()->getUID()); $this->session->close(); return true; @@ -138,14 +89,15 @@ class Auth extends AbstractBasic { } catch (PasswordLoginForbiddenException $ex) { $this->session->close(); throw new PasswordLoginForbidden(); + } catch (MaxDelayReached $ex) { + $this->session->close(); + throw new TooManyRequests(); } } } /** - * @param RequestInterface $request - * @param ResponseInterface $response - * @return array + * @return array{bool, string} * @throws NotAuthenticated * @throws ServiceUnavailable */ @@ -157,19 +109,18 @@ class Auth extends AbstractBasic { } catch (Exception $e) { $class = get_class($e); $msg = $e->getMessage(); - \OC::$server->getLogger()->logException($e); + Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); throw new ServiceUnavailable("$class: $msg"); } } /** * Checks whether a CSRF check is required on the request - * - * @return bool */ - private function requiresCSRFCheck() { - // GET requires no check at all - if ($this->request->getMethod() === 'GET') { + private function requiresCSRFCheck(): bool { + + $methodsWithoutCsrf = ['GET', 'HEAD', 'OPTIONS']; + if (in_array($this->request->getMethod(), $methodsWithoutCsrf)) { return false; } @@ -193,8 +144,8 @@ class Auth extends AbstractBasic { } // If logged-in AND DAV authenticated no check is required - if ($this->userSession->isLoggedIn() && - $this->isDavAuthenticated($this->userSession->getUser()->getUID())) { + if ($this->userSession->isLoggedIn() + && $this->isDavAuthenticated($this->userSession->getUser()->getUID())) { return false; } @@ -202,21 +153,19 @@ class Auth extends AbstractBasic { } /** - * @param RequestInterface $request - * @param ResponseInterface $response - * @return array + * @return array{bool, string} * @throws NotAuthenticated */ - private function auth(RequestInterface $request, ResponseInterface $response) { + private function auth(RequestInterface $request, ResponseInterface $response): array { $forcedLogout = false; - if (!$this->request->passesCSRFCheck() && - $this->requiresCSRFCheck()) { + if (!$this->request->passesCSRFCheck() + && $this->requiresCSRFCheck()) { // In case of a fail with POST we need to recheck the credentials if ($this->request->getMethod() === 'POST') { $forcedLogout = true; } else { - $response->setStatus(401); + $response->setStatus(Http::STATUS_UNAUTHORIZED); throw new \Sabre\DAV\Exception\NotAuthenticated('CSRF check not passed.'); } } @@ -229,10 +178,10 @@ class Auth extends AbstractBasic { } if ( //Fix for broken webdav clients - ($this->userSession->isLoggedIn() && is_null($this->session->get(self::DAV_AUTHENTICATED))) || + ($this->userSession->isLoggedIn() && is_null($this->session->get(self::DAV_AUTHENTICATED))) //Well behaved clients that only send the cookie are allowed - ($this->userSession->isLoggedIn() && $this->session->get(self::DAV_AUTHENTICATED) === $this->userSession->getUser()->getUID() && $request->getHeader('Authorization') === null) || - \OC_User::handleApacheAuth() + || ($this->userSession->isLoggedIn() && $this->session->get(self::DAV_AUTHENTICATED) === $this->userSession->getUser()->getUID() && empty($request->getHeader('Authorization'))) + || \OC_User::handleApacheAuth() ) { $user = $this->userSession->getUser()->getUID(); $this->currentUser = $user; @@ -241,18 +190,16 @@ class Auth extends AbstractBasic { } } - if (!$this->userSession->isLoggedIn() && in_array('XMLHttpRequest', explode(',', $request->getHeader('X-Requested-With') ?? ''))) { - // do not re-authenticate over ajax, use dummy auth name to prevent browser popup - $response->addHeader('WWW-Authenticate','DummyBasic realm="' . $this->realm . '"'); - $response->setStatus(401); - throw new \Sabre\DAV\Exception\NotAuthenticated('Cannot authenticate over ajax calls'); - } - $data = parent::check($request, $response); if ($data[0] === true) { $startPos = strrpos($data[1], '/') + 1; $user = $this->userSession->getUser()->getUID(); $data[1] = substr_replace($data[1], $user, $startPos); + } elseif (in_array('XMLHttpRequest', explode(',', $request->getHeader('X-Requested-With') ?? ''))) { + // For ajax requests use dummy auth name to prevent browser popup in case of invalid creditials + $response->addHeader('WWW-Authenticate', 'DummyBasic realm="' . $this->realm . '"'); + $response->setStatus(Http::STATUS_UNAUTHORIZED); + throw new \Sabre\DAV\Exception\NotAuthenticated('Cannot authenticate over ajax calls'); } return $data; } diff --git a/apps/dav/lib/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index d28a9cfdb84..23453ae8efb 100644 --- a/apps/dav/lib/Connector/Sabre/BearerAuth.php +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -1,28 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * - * @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\Connector\Sabre; +use OCP\AppFramework\Http; +use OCP\Defaults; +use OCP\IConfig; use OCP\IRequest; use OCP\ISession; use OCP\IUserSession; @@ -31,33 +17,16 @@ use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class BearerAuth extends AbstractBearer { - /** @var IUserSession */ - private $userSession; - /** @var ISession */ - private $session; - /** @var IRequest */ - private $request; - /** @var string */ - private $principalPrefix; - - /** - * @param IUserSession $userSession - * @param ISession $session - * @param string $principalPrefix - * @param IRequest $request - */ - public function __construct(IUserSession $userSession, - ISession $session, - IRequest $request, - $principalPrefix = 'principals/users/') { - $this->userSession = $userSession; - $this->session = $session; - $this->request = $request; - $this->principalPrefix = $principalPrefix; - + public function __construct( + private IUserSession $userSession, + private ISession $session, + private IRequest $request, + private IConfig $config, + private string $principalPrefix = 'principals/users/', + ) { // setup realm - $defaults = new \OCP\Defaults(); - $this->realm = $defaults->getName(); + $defaults = new Defaults(); + $this->realm = $defaults->getName() ?: 'Nextcloud'; } private function setupUserFs($userId) { @@ -90,7 +59,15 @@ class BearerAuth extends AbstractBearer { * @param RequestInterface $request * @param ResponseInterface $response */ - public function challenge(RequestInterface $request, ResponseInterface $response) { - $response->setStatus(401); + public function challenge(RequestInterface $request, ResponseInterface $response): void { + // Legacy ownCloud clients still authenticate via OAuth2 + $enableOcClients = $this->config->getSystemValueBool('oauth2.enable_oc_clients', false); + $userAgent = $request->getHeader('User-Agent'); + if ($enableOcClients && $userAgent !== null && str_contains($userAgent, 'mirall')) { + parent::challenge($request, $response); + return; + } + + $response->setStatus(Http::STATUS_UNAUTHORIZED); } } diff --git a/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php b/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php index 8e2ea4d4e16..21358406a4a 100644 --- a/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php +++ b/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php @@ -1,31 +1,16 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; +use OCA\Theming\ThemingDefaults; use OCP\IConfig; +use OCP\IRequest; +use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\HTTP\RequestInterface; @@ -36,23 +21,18 @@ use Sabre\HTTP\RequestInterface; * @package OCA\DAV\Connector\Sabre */ class BlockLegacyClientPlugin extends ServerPlugin { - /** @var \Sabre\DAV\Server */ - protected $server; - /** @var IConfig */ - protected $config; + protected ?Server $server = null; - /** - * @param IConfig $config - */ - public function __construct(IConfig $config) { - $this->config = $config; + public function __construct( + private IConfig $config, + private ThemingDefaults $themingDefaults, + ) { } /** - * @param \Sabre\DAV\Server $server * @return void */ - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(Server $server) { $this->server = $server; $this->server->on('beforeMethod:*', [$this, 'beforeHandler'], 200); } @@ -69,14 +49,26 @@ class BlockLegacyClientPlugin extends ServerPlugin { return; } - $minimumSupportedDesktopVersion = $this->config->getSystemValue('minimum.supported.desktop.version', '2.0.0'); + $minimumSupportedDesktopVersion = $this->config->getSystemValueString('minimum.supported.desktop.version', '3.1.0'); + $maximumSupportedDesktopVersion = $this->config->getSystemValueString('maximum.supported.desktop.version', '99.99.99'); + + // Check if the client is a desktop client + preg_match(IRequest::USER_AGENT_CLIENT_DESKTOP, $userAgent, $versionMatches); + + // If the client is a desktop client and the version is too old, block it + if (isset($versionMatches[1]) && version_compare($versionMatches[1], $minimumSupportedDesktopVersion) === -1) { + $customClientDesktopLink = htmlspecialchars($this->themingDefaults->getSyncClientUrl()); + $minimumSupportedDesktopVersion = htmlspecialchars($minimumSupportedDesktopVersion); + + throw new \Sabre\DAV\Exception\Forbidden("This version of the client is unsupported. Upgrade to <a href=\"$customClientDesktopLink\">version $minimumSupportedDesktopVersion or later</a>."); + } + + // If the client is a desktop client and the version is too new, block it + if (isset($versionMatches[1]) && version_compare($versionMatches[1], $maximumSupportedDesktopVersion) === 1) { + $customClientDesktopLink = htmlspecialchars($this->themingDefaults->getSyncClientUrl()); + $maximumSupportedDesktopVersion = htmlspecialchars($maximumSupportedDesktopVersion); - // Match on the mirall version which is in scheme "Mozilla/5.0 (%1) mirall/%2" or - // "mirall/%1" for older releases - preg_match("/(?:mirall\\/)([\d.]+)/i", $userAgent, $versionMatches); - if (isset($versionMatches[1]) && - version_compare($versionMatches[1], $minimumSupportedDesktopVersion) === -1) { - throw new \Sabre\DAV\Exception\Forbidden('Unsupported client version.'); + throw new \Sabre\DAV\Exception\Forbidden("This version of the client is unsupported. Downgrade to <a href=\"$customClientDesktopLink\">version $maximumSupportedDesktopVersion or earlier</a>."); } } } diff --git a/apps/dav/lib/Connector/Sabre/CachingTree.php b/apps/dav/lib/Connector/Sabre/CachingTree.php index eb1233d3540..5d72b530f58 100644 --- a/apps/dav/lib/Connector/Sabre/CachingTree.php +++ b/apps/dav/lib/Connector/Sabre/CachingTree.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; @@ -28,24 +11,25 @@ use Sabre\DAV\Tree; class CachingTree extends Tree { /** * Store a node in the cache - * - * @param Node $node - * @param null|string $path */ - public function cacheNode(Node $node, $path = null) { + public function cacheNode(Node $node, ?string $path = null): void { if (is_null($path)) { $path = $node->getPath(); } $this->cache[trim($path, '/')] = $node; } + /** + * @param string $path + * @return void + */ public function markDirty($path) { // We don't care enough about sub-paths // flushing the entire cache $path = trim($path, '/'); foreach ($this->cache as $nodePath => $node) { - $nodePath = (string) $nodePath; - if ('' === $path || $nodePath == $path || 0 === strpos($nodePath, $path.'/')) { + $nodePath = (string)$nodePath; + if ($path === '' || $nodePath == $path || str_starts_with($nodePath, $path . '/')) { unset($this->cache[$nodePath]); } } diff --git a/apps/dav/lib/Connector/Sabre/ChecksumList.php b/apps/dav/lib/Connector/Sabre/ChecksumList.php index 74cdc98ef4f..75d1d718de1 100644 --- a/apps/dav/lib/Connector/Sabre/ChecksumList.php +++ b/apps/dav/lib/Connector/Sabre/ChecksumList.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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\Connector\Sabre; @@ -35,17 +20,14 @@ class ChecksumList implements XmlSerializable { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** @var string[] of TYPE:CHECKSUM */ - private $checksums; + private array $checksums; - /** - * @param string $checksum - */ - public function __construct($checksum) { - $this->checksums = explode(',', $checksum); + public function __construct(string $checksum) { + $this->checksums = explode(' ', $checksum); } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * Use the $writer argument to write its own xml serialization. * diff --git a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php index 3247259357f..18009080585 100644 --- a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php +++ b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php @@ -2,38 +2,22 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; +use OCP\AppFramework\Http; +use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class ChecksumUpdatePlugin extends ServerPlugin { - /** - * @var \Sabre\DAV\Server - */ - protected $server; + protected ?Server $server = null; - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(Server $server) { $this->server = $server; $server->on('method:PATCH', [$this, 'httpPatch']); } @@ -42,19 +26,7 @@ class ChecksumUpdatePlugin extends ServerPlugin { return 'checksumupdate'; } - public function getHTTPMethods($path): array { - $tree = $this->server->tree; - - if ($tree->nodeExists($path)) { - $node = $tree->getNodeForPath($path); - if ($node instanceof File) { - return ['PATCH']; - } - } - - return []; - } - + /** @return string[] */ public function getFeatures(): array { return ['nextcloud-checksum-update']; } @@ -74,7 +46,7 @@ class ChecksumUpdatePlugin extends ServerPlugin { $node->setChecksum($checksum); $response->addHeader('OC-Checksum', $checksum); $response->setHeader('Content-Length', '0'); - $response->setStatus(204); + $response->setStatus(Http::STATUS_NO_CONTENT); return false; } diff --git a/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php index 82980553fa8..e4b6c2636da 100644 --- a/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php @@ -1,33 +1,17 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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\Connector\Sabre; use OCP\Comments\ICommentsManager; use OCP\IUserSession; use Sabre\DAV\PropFind; +use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; class CommentPropertiesPlugin extends ServerPlugin { @@ -35,20 +19,13 @@ class CommentPropertiesPlugin extends ServerPlugin { public const PROPERTY_NAME_COUNT = '{http://owncloud.org/ns}comments-count'; public const PROPERTY_NAME_UNREAD = '{http://owncloud.org/ns}comments-unread'; - /** @var \Sabre\DAV\Server */ - protected $server; - - /** @var ICommentsManager */ - private $commentsManager; - - /** @var IUserSession */ - private $userSession; - - private $cachedUnreadCount = []; + protected ?Server $server = null; + private array $cachedUnreadCount = []; - public function __construct(ICommentsManager $commentsManager, IUserSession $userSession) { - $this->commentsManager = $commentsManager; - $this->userSession = $userSession; + public function __construct( + private ICommentsManager $commentsManager, + private IUserSession $userSession, + ) { } /** @@ -67,7 +44,7 @@ class CommentPropertiesPlugin extends ServerPlugin { $this->server->on('propFind', [$this, 'handleGetProperties']); } - private function cacheDirectory(Directory $directory) { + private function cacheDirectory(Directory $directory): void { $children = $directory->getChildren(); $ids = []; @@ -84,7 +61,7 @@ class CommentPropertiesPlugin extends ServerPlugin { $ids[] = (string)$id; } - $ids[] = (string) $directory->getId(); + $ids[] = (string)$directory->getId(); $unread = $this->commentsManager->getNumberOfUnreadCommentsForObjects('files', $ids, $this->userSession->getUser()); foreach ($unread as $id => $count) { @@ -102,62 +79,52 @@ class CommentPropertiesPlugin extends ServerPlugin { */ public function handleGetProperties( PropFind $propFind, - \Sabre\DAV\INode $node + \Sabre\DAV\INode $node, ) { if (!($node instanceof File) && !($node instanceof Directory)) { return; } // need prefetch ? - if ($node instanceof \OCA\DAV\Connector\Sabre\Directory + if ($node instanceof Directory && $propFind->getDepth() !== 0 && !is_null($propFind->getStatus(self::PROPERTY_NAME_UNREAD)) ) { $this->cacheDirectory($node); } - $propFind->handle(self::PROPERTY_NAME_COUNT, function () use ($node) { + $propFind->handle(self::PROPERTY_NAME_COUNT, function () use ($node): int { return $this->commentsManager->getNumberOfCommentsForObject('files', (string)$node->getId()); }); - $propFind->handle(self::PROPERTY_NAME_HREF, function () use ($node) { + $propFind->handle(self::PROPERTY_NAME_HREF, function () use ($node): ?string { return $this->getCommentsLink($node); }); - $propFind->handle(self::PROPERTY_NAME_UNREAD, function () use ($node) { - if (isset($this->cachedUnreadCount[$node->getId()])) { - return $this->cachedUnreadCount[$node->getId()]; - } - return $this->getUnreadCount($node); + $propFind->handle(self::PROPERTY_NAME_UNREAD, function () use ($node): ?int { + return $this->cachedUnreadCount[$node->getId()] ?? $this->getUnreadCount($node); }); } /** - * returns a reference to the comments node - * - * @param Node $node - * @return mixed|string + * Returns a reference to the comments node */ - public function getCommentsLink(Node $node) { + public function getCommentsLink(Node $node): ?string { $href = $this->server->getBaseUri(); $entryPoint = strpos($href, '/remote.php/'); if ($entryPoint === false) { // in case we end up somewhere else, unexpectedly. return null; } - $commentsPart = 'dav/comments/files/' . rawurldecode($node->getId()); - $href = substr_replace($href, $commentsPart, $entryPoint + strlen('/remote.php/')); - return $href; + $commentsPart = 'dav/comments/files/' . rawurldecode((string)$node->getId()); + return substr_replace($href, $commentsPart, $entryPoint + strlen('/remote.php/')); } /** - * returns the number of unread comments for the currently logged in user + * Returns the number of unread comments for the currently logged in user * on the given file or directory node - * - * @param Node $node - * @return Int|null */ - public function getUnreadCount(Node $node) { + public function getUnreadCount(Node $node): ?int { $user = $this->userSession->getUser(); if (is_null($user)) { return null; diff --git a/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php b/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php index 029e631f4d9..609ac295b4c 100644 --- a/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php +++ b/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php @@ -1,30 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Server; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; @@ -34,9 +18,8 @@ use Sabre\HTTP\ResponseInterface; * or mangle Etag headers. */ class CopyEtagHeaderPlugin extends \Sabre\DAV\ServerPlugin { + private ?Server $server = null; - /** @var \Sabre\DAV\Server */ - private $server; /** * This initializes the plugin. * diff --git a/apps/dav/lib/Connector/Sabre/DavAclPlugin.php b/apps/dav/lib/Connector/Sabre/DavAclPlugin.php index 6842975835d..100d719ef01 100644 --- a/apps/dav/lib/Connector/Sabre/DavAclPlugin.php +++ b/apps/dav/lib/Connector/Sabre/DavAclPlugin.php @@ -1,33 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author 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\Connector\Sabre; +use OCA\DAV\CalDAV\CachedSubscription; +use OCA\DAV\CalDAV\Calendar; use OCA\DAV\CardDAV\AddressBook; use Sabre\CalDAV\Principal\User; +use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\INode; use Sabre\DAV\PropFind; @@ -58,23 +42,38 @@ class DavAclPlugin extends \Sabre\DAVACL\Plugin { case AddressBook::class: $type = 'Addressbook'; break; + case Calendar::class: + case CachedSubscription::class: + $type = 'Calendar'; + break; default: $type = 'Node'; break; } - throw new NotFound( - sprintf( - "%s with name '%s' could not be found", - $type, - $node->getName() - ) - ); + + if ($this->getCurrentUserPrincipal() === $node->getOwner()) { + throw new Forbidden('Access denied'); + } else { + throw new NotFound( + sprintf( + "%s with name '%s' could not be found", + $type, + $node->getName() + ) + ); + } + } return $access; } public function propFind(PropFind $propFind, INode $node) { + if ($node instanceof Node) { + // files don't use dav acls + return; + } + // If the node is neither readable nor writable then fail unless its of // the standard user-principal if (!($node instanceof User)) { @@ -94,8 +93,23 @@ class DavAclPlugin extends \Sabre\DAVACL\Plugin { $path = $request->getPath(); // prevent the plugin from causing an unneeded overhead for file requests - if (strpos($path, 'files/') !== 0) { - parent::beforeMethod($request, $response); + if (str_starts_with($path, 'files/')) { + return; + } + + parent::beforeMethod($request, $response); + + if (!str_starts_with($path, 'addressbooks/') && !str_starts_with($path, 'calendars/')) { + return; + } + + [$parentName] = \Sabre\Uri\split($path); + if ($request->getMethod() === 'REPORT') { + // is calendars/users/bob or addressbooks/users/bob readable? + $this->checkPrivileges($parentName, '{DAV:}read'); + } elseif ($request->getMethod() === 'MKCALENDAR' || $request->getMethod() === 'MKCOL') { + // is calendars/users/bob or addressbooks/users/bob writeable? + $this->checkPrivileges($parentName, '{DAV:}write'); } } } diff --git a/apps/dav/lib/Connector/Sabre/Directory.php b/apps/dav/lib/Connector/Sabre/Directory.php index ed98b5050f8..fe09c3f423f 100644 --- a/apps/dav/lib/Connector/Sabre/Directory.php +++ b/apps/dav/lib/Connector/Sabre/Directory.php @@ -1,52 +1,36 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use OC\Files\Mount\MoveableMount; use OC\Files\View; -use OC\Metadata\FileMetadata; -use OC\Metadata\MetadataGroup; +use OCA\DAV\AppInfo\Application; use OCA\DAV\Connector\Sabre\Exception\FileLocked; use OCA\DAV\Connector\Sabre\Exception\Forbidden; use OCA\DAV\Connector\Sabre\Exception\InvalidPath; +use OCA\DAV\Storage\PublicShareWrapper; +use OCP\App\IAppManager; +use OCP\Constants; use OCP\Files\FileInfo; use OCP\Files\Folder; use OCP\Files\ForbiddenException; use OCP\Files\InvalidPathException; +use OCP\Files\Mount\IMountManager; +use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\StorageNotAvailableException; +use OCP\IL10N; +use OCP\IRequest; +use OCP\L10N\IFactory; use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; +use OCP\Server; +use OCP\Share\IManager as IShareManager; use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Locked; @@ -55,41 +39,26 @@ use Sabre\DAV\Exception\ServiceUnavailable; use Sabre\DAV\IFile; use Sabre\DAV\INode; -class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuota, \Sabre\DAV\IMoveTarget, \Sabre\DAV\ICopyTarget { - +class Directory extends Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuota, \Sabre\DAV\IMoveTarget, \Sabre\DAV\ICopyTarget { /** * Cached directory content - * - * @var \OCP\Files\FileInfo[] - */ - private $dirContent; - - /** - * Cached quota info - * - * @var array + * @var FileInfo[] */ - private $quotaInfo; + private ?array $dirContent = null; - /** - * @var ObjectTree|null - */ - private $tree; - - /** @var array<string, array<int, FileMetadata>> */ - private array $metadata = []; + /** Cached quota info */ + private ?array $quotaInfo = null; /** * Sets up the node, expects a full path name - * - * @param \OC\Files\View $view - * @param \OCP\Files\FileInfo $info - * @param ObjectTree|null $tree - * @param \OCP\Share\IManager $shareManager */ - public function __construct(View $view, FileInfo $info, $tree = null, $shareManager = null) { + public function __construct( + View $view, + FileInfo $info, + private ?CachingTree $tree = null, + ?IShareManager $shareManager = null, + ) { parent::__construct($view, $info, $shareManager); - $this->tree = $tree; } /** @@ -126,22 +95,8 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol */ public function createFile($name, $data = null) { try { - // for chunked upload also updating a existing file is a "createFile" - // because we create all the chunks before re-assemble them to the existing file. - if (isset($_SERVER['HTTP_OC_CHUNKED'])) { - - // exit if we can't create a new file and we don't updatable existing file - $chunkInfo = \OC_FileChunking::decodeName($name); - if (!$this->fileView->isCreatable($this->path) && - !$this->fileView->isUpdatable($this->path . '/' . $chunkInfo['name']) - ) { - throw new \Sabre\DAV\Exception\Forbidden(); - } - } else { - // For non-chunked upload it is enough to check if we can create a new file - if (!$this->fileView->isCreatable($this->path)) { - throw new \Sabre\DAV\Exception\Forbidden(); - } + if (!$this->fileView->isCreatable($this->path)) { + throw new \Sabre\DAV\Exception\Forbidden(); } $this->fileView->verifyPath($this->path, $name); @@ -155,18 +110,18 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol 'type' => FileInfo::TYPE_FILE ], null); } - $node = new \OCA\DAV\Connector\Sabre\File($this->fileView, $info); + $node = new File($this->fileView, $info); // only allow 1 process to upload a file at once but still allow reading the file while writing the part file $node->acquireLock(ILockingProvider::LOCK_SHARED); - $this->fileView->lockFile($path . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); + $this->fileView->lockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); $result = $node->put($data); - $this->fileView->unlockFile($path . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); + $this->fileView->unlockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); $node->releaseLock(ILockingProvider::LOCK_SHARED); return $result; - } catch (\OCP\Files\StorageNotAvailableException $e) { + } catch (StorageNotAvailableException $e) { throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), $e->getCode(), $e); } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage(), false, $ex); @@ -197,12 +152,12 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol if (!$this->fileView->mkdir($newPath)) { throw new \Sabre\DAV\Exception\Forbidden('Could not create directory ' . $newPath); } - } catch (\OCP\Files\StorageNotAvailableException $e) { - throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage()); + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), 0, $e); } catch (InvalidPathException $ex) { - throw new InvalidPath($ex->getMessage()); + throw new InvalidPath($ex->getMessage(), false, $ex); } catch (ForbiddenException $ex) { - throw new Forbidden($ex->getMessage(), $ex->getRetry()); + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); } catch (LockedException $e) { throw new FileLocked($e->getMessage(), $e->getCode(), $e); } @@ -212,14 +167,27 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol * Returns a specific child node, referenced by its name * * @param string $name - * @param \OCP\Files\FileInfo $info + * @param FileInfo $info * @return \Sabre\DAV\INode * @throws InvalidPath * @throws \Sabre\DAV\Exception\NotFound * @throws \Sabre\DAV\Exception\ServiceUnavailable */ - public function getChild($name, $info = null) { - if (!$this->info->isReadable()) { + public function getChild($name, $info = null, ?IRequest $request = null, ?IL10N $l10n = null) { + $storage = $this->info->getStorage(); + $allowDirectory = false; + + // Checking if we're in a file drop + // If we are, then only PUT and MKCOL are allowed (see plugin) + // so we are safe to return the directory without a risk of + // leaking files and folders structure. + if ($storage instanceof PublicShareWrapper) { + $share = $storage->getShare(); + $allowDirectory = ($share->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ; + } + + // For file drop we need to be allowed to read the directory with the nickname + if (!$allowDirectory && !$this->info->isReadable()) { // avoid detecting files through this way throw new NotFound(); } @@ -227,14 +195,14 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol $path = $this->path . '/' . $name; if (is_null($info)) { try { - $this->fileView->verifyPath($this->path, $name); + $this->fileView->verifyPath($this->path, $name, true); $info = $this->fileView->getFileInfo($path); - } catch (\OCP\Files\StorageNotAvailableException $e) { - throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage()); + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), 0, $e); } catch (InvalidPathException $ex) { - throw new InvalidPath($ex->getMessage()); + throw new InvalidPath($ex->getMessage(), false, $ex); } catch (ForbiddenException $e) { - throw new \Sabre\DAV\Exception\Forbidden(); + throw new \Sabre\DAV\Exception\Forbidden($e->getMessage(), $e->getCode(), $e); } } @@ -245,7 +213,12 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol if ($info->getMimeType() === FileInfo::MIMETYPE_FOLDER) { $node = new \OCA\DAV\Connector\Sabre\Directory($this->fileView, $info, $this->tree, $this->shareManager); } else { - $node = new \OCA\DAV\Connector\Sabre\File($this->fileView, $info, $this->shareManager); + // In case reading a directory was allowed but it turns out the node was a not a directory, reject it now. + if (!$this->info->isReadable()) { + throw new NotFound(); + } + + $node = new File($this->fileView, $info, $this->shareManager, $request, $l10n); } if ($this->tree) { $this->tree->cacheNode($node); @@ -258,7 +231,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol * * @return \Sabre\DAV\INode[] * @throws \Sabre\DAV\Exception\Locked - * @throws \OCA\DAV\Connector\Sabre\Exception\Forbidden + * @throws Forbidden */ public function getChildren() { if (!is_null($this->dirContent)) { @@ -268,7 +241,11 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol if (!$this->info->isReadable()) { // return 403 instead of 404 because a 404 would make // the caller believe that the collection itself does not exist - throw new Forbidden('No read permissions'); + if (Server::get(IAppManager::class)->isEnabledForAnyone('files_accesscontrol')) { + throw new Forbidden('No read permissions. This might be caused by files_accesscontrol, check your configured rules'); + } else { + throw new Forbidden('No read permissions'); + } } $folderContent = $this->getNode()->getDirectoryListing(); } catch (LockedException $e) { @@ -276,8 +253,11 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol } $nodes = []; + $request = Server::get(IRequest::class); + $l10nFactory = Server::get(IFactory::class); + $l10n = $l10nFactory->get(Application::APP_ID); foreach ($folderContent as $info) { - $node = $this->getChild($info->getName(), $info); + $node = $this->getChild($info->getName(), $info, $request, $l10n); $nodes[] = $node; } $this->dirContent = $nodes; @@ -326,21 +306,29 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol } } + private function getLogger(): LoggerInterface { + return Server::get(LoggerInterface::class); + } + /** * Returns available diskspace information * * @return array */ public function getQuotaInfo() { - /** @var LoggerInterface $logger */ - $logger = \OC::$server->get(LoggerInterface::class); if ($this->quotaInfo) { return $this->quotaInfo; } + $relativePath = $this->fileView->getRelativePath($this->info->getPath()); + if ($relativePath === null) { + $this->getLogger()->warning('error while getting quota as the relative path cannot be found'); + return [0, 0]; + } + try { - $storageInfo = \OC_Helper::getStorageInfo($this->info->getPath(), $this->info, false); - if ($storageInfo['quota'] === \OCP\Files\FileInfo::SPACE_UNLIMITED) { - $free = \OCP\Files\FileInfo::SPACE_UNLIMITED; + $storageInfo = \OC_Helper::getStorageInfo($relativePath, $this->info, false); + if ($storageInfo['quota'] === FileInfo::SPACE_UNLIMITED) { + $free = FileInfo::SPACE_UNLIMITED; } else { $free = $storageInfo['free']; } @@ -349,14 +337,14 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol $free ]; return $this->quotaInfo; - } catch (\OCP\Files\NotFoundException $e) { - $logger->warning("error while getting quota into", ['exception' => $e]); + } catch (NotFoundException $e) { + $this->getLogger()->warning('error while getting quota into', ['exception' => $e]); return [0, 0]; - } catch (\OCP\Files\StorageNotAvailableException $e) { - $logger->warning("error while getting quota into", ['exception' => $e]); + } catch (StorageNotAvailableException $e) { + $this->getLogger()->warning('error while getting quota into', ['exception' => $e]); return [0, 0]; } catch (NotPermittedException $e) { - $logger->warning("error while getting quota into", ['exception' => $e]); + $this->getLogger()->warning('error while getting quota into', ['exception' => $e]); return [0, 0]; } } @@ -396,10 +384,6 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol throw new BadRequest('Incompatible node types'); } - if (!$this->fileView) { - throw new ServiceUnavailable('filesystem not setup'); - } - $destinationPath = $this->getPath() . '/' . $targetName; @@ -417,7 +401,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol $sourcePath = $sourceNode->getPath(); $isMovableMount = false; - $sourceMount = \OC::$server->getMountManager()->find($this->fileView->getAbsolutePath($sourcePath)); + $sourceMount = Server::get(IMountManager::class)->find($this->fileView->getAbsolutePath($sourcePath)); $internalPath = $sourceMount->getInternalPath($this->fileView->getAbsolutePath($sourcePath)); if ($sourceMount instanceof MoveableMount && $internalPath === '') { $isMovableMount = true; @@ -457,9 +441,9 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol throw new \Sabre\DAV\Exception\Forbidden(''); } } catch (StorageNotAvailableException $e) { - throw new ServiceUnavailable($e->getMessage()); + throw new ServiceUnavailable($e->getMessage(), $e->getCode(), $e); } catch (ForbiddenException $ex) { - throw new Forbidden($ex->getMessage(), $ex->getRetry()); + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); } catch (LockedException $e) { throw new FileLocked($e->getMessage(), $e->getCode(), $e); } @@ -470,20 +454,34 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol public function copyInto($targetName, $sourcePath, INode $sourceNode) { if ($sourceNode instanceof File || $sourceNode instanceof Directory) { - $destinationPath = $this->getPath() . '/' . $targetName; - $sourcePath = $sourceNode->getPath(); + try { + $destinationPath = $this->getPath() . '/' . $targetName; + $sourcePath = $sourceNode->getPath(); - if (!$this->fileView->isCreatable($this->getPath())) { - throw new \Sabre\DAV\Exception\Forbidden(); - } + if (!$this->fileView->isCreatable($this->getPath())) { + throw new \Sabre\DAV\Exception\Forbidden(); + } - try { - $this->fileView->verifyPath($this->getPath(), $targetName); - } catch (InvalidPathException $ex) { - throw new InvalidPath($ex->getMessage()); - } + try { + $this->fileView->verifyPath($this->getPath(), $targetName); + } catch (InvalidPathException $ex) { + throw new InvalidPath($ex->getMessage()); + } + + $copyOkay = $this->fileView->copy($sourcePath, $destinationPath); - return $this->fileView->copy($sourcePath, $destinationPath); + if (!$copyOkay) { + throw new \Sabre\DAV\Exception\Forbidden('Copy did not proceed'); + } + + return true; + } catch (StorageNotAvailableException $e) { + throw new ServiceUnavailable($e->getMessage(), $e->getCode(), $e); + } catch (ForbiddenException $ex) { + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } } return false; diff --git a/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php b/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php index 3a8469e99b4..f6baceb748b 100644 --- a/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php +++ b/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php @@ -1,31 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wickert <cwickert@suse.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; +use OCP\AppFramework\Http; +use Sabre\DAV\Server; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; @@ -43,8 +26,7 @@ use Sabre\HTTP\ResponseInterface; * @package OCA\DAV\Connector\Sabre */ class DummyGetResponsePlugin extends \Sabre\DAV\ServerPlugin { - /** @var \Sabre\DAV\Server */ - protected $server; + protected ?Server $server = null; /** * @param \Sabre\DAV\Server $server @@ -61,13 +43,13 @@ class DummyGetResponsePlugin extends \Sabre\DAV\ServerPlugin { * @return false */ public function httpGet(RequestInterface $request, ResponseInterface $response) { - $string = 'This is the WebDAV interface. It can only be accessed by ' . - 'WebDAV clients such as the Nextcloud desktop sync client.'; - $stream = fopen('php://memory','r+'); + $string = 'This is the WebDAV interface. It can only be accessed by ' + . 'WebDAV clients such as the Nextcloud desktop sync client.'; + $stream = fopen('php://memory', 'r+'); fwrite($stream, $string); rewind($stream); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setBody($stream); return false; diff --git a/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php b/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php index c4cd6db190a..1e1e4aaed04 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php +++ b/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php @@ -1,23 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me> - * - * @author Louis Chemineau <louis@chmn.me> - * - * @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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; diff --git a/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php b/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php index 4fc3399ca81..60b3b06ea01 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php +++ b/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; diff --git a/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php b/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php index 36063da8d65..38708e945e9 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php +++ b/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php @@ -1,36 +1,22 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Owen Winkler <a_github@midnightcircus.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; use Exception; +use OCP\Files\LockNotAcquiredException; class FileLocked extends \Sabre\DAV\Exception { - public function __construct($message = "", $code = 0, Exception $previous = null) { - if ($previous instanceof \OCP\Files\LockNotAcquiredException) { + /** + * @param string $message + * @param int $code + */ + public function __construct($message = '', $code = 0, ?Exception $previous = null) { + if ($previous instanceof LockNotAcquiredException) { $message = sprintf('Target file %s is locked by another process.', $previous->path); } parent::__construct($message, $code, $previous); diff --git a/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php b/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php index aabd5fda2fb..95d4b3ab514 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php +++ b/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; @@ -26,18 +11,16 @@ class Forbidden extends \Sabre\DAV\Exception\Forbidden { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * @var bool - */ - private $retry; - - /** * @param string $message * @param bool $retry * @param \Exception $previous */ - public function __construct($message, $retry = false, \Exception $previous = null) { + public function __construct( + $message, + private $retry = false, + ?\Exception $previous = null, + ) { parent::__construct($message, 0, $previous); - $this->retry = $retry; } /** @@ -48,17 +31,17 @@ class Forbidden extends \Sabre\DAV\Exception\Forbidden { * @param \DOMElement $errorNode * @return void */ - public function serialize(\Sabre\DAV\Server $server,\DOMElement $errorNode) { + public function serialize(\Sabre\DAV\Server $server, \DOMElement $errorNode) { // set ownCloud namespace $errorNode->setAttribute('xmlns:o', self::NS_OWNCLOUD); // adding the retry node - $error = $errorNode->ownerDocument->createElementNS('o:','o:retry', var_export($this->retry, true)); + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:retry', var_export($this->retry, true)); $errorNode->appendChild($error); // adding the message node - $error = $errorNode->ownerDocument->createElementNS('o:','o:reason', $this->getMessage()); + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:reason', $this->getMessage()); $errorNode->appendChild($error); } } diff --git a/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php b/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php index c504483d45a..dfc08aa8b88 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php +++ b/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; @@ -29,18 +13,16 @@ class InvalidPath extends Exception { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * @var bool - */ - private $retry; - - /** * @param string $message * @param bool $retry * @param \Exception|null $previous */ - public function __construct($message, $retry = false, \Exception $previous = null) { + public function __construct( + $message, + private $retry = false, + ?\Exception $previous = null, + ) { parent::__construct($message, 0, $previous); - $this->retry = $retry; } /** @@ -60,17 +42,17 @@ class InvalidPath extends Exception { * @param \DOMElement $errorNode * @return void */ - public function serialize(\Sabre\DAV\Server $server,\DOMElement $errorNode) { + public function serialize(\Sabre\DAV\Server $server, \DOMElement $errorNode) { // set ownCloud namespace $errorNode->setAttribute('xmlns:o', self::NS_OWNCLOUD); // adding the retry node - $error = $errorNode->ownerDocument->createElementNS('o:','o:retry', var_export($this->retry, true)); + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:retry', var_export($this->retry, true)); $errorNode->appendChild($error); // adding the message node - $error = $errorNode->ownerDocument->createElementNS('o:','o:reason', $this->getMessage()); + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:reason', $this->getMessage()); $errorNode->appendChild($error); } } diff --git a/apps/dav/lib/Connector/Sabre/Exception/PasswordLoginForbidden.php b/apps/dav/lib/Connector/Sabre/Exception/PasswordLoginForbidden.php index 7c00d15f627..f5cc117fafc 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/PasswordLoginForbidden.php +++ b/apps/dav/lib/Connector/Sabre/Exception/PasswordLoginForbidden.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; diff --git a/apps/dav/lib/Connector/Sabre/Exception/TooManyRequests.php b/apps/dav/lib/Connector/Sabre/Exception/TooManyRequests.php new file mode 100644 index 00000000000..67455fc9474 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Exception/TooManyRequests.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Connector\Sabre\Exception; + +use DOMElement; +use Sabre\DAV\Exception\NotAuthenticated; +use Sabre\DAV\Server; + +class TooManyRequests extends NotAuthenticated { + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + + public function getHTTPCode() { + return 429; + } + + /** + * This method allows the exception to include additional information + * into the WebDAV error response + * + * @param Server $server + * @param DOMElement $errorNode + * @return void + */ + public function serialize(Server $server, DOMElement $errorNode) { + + // set ownCloud namespace + $errorNode->setAttribute('xmlns:o', self::NS_OWNCLOUD); + + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:hint', 'too many requests'); + $errorNode->appendChild($error); + } +} diff --git a/apps/dav/lib/Connector/Sabre/Exception/UnsupportedMediaType.php b/apps/dav/lib/Connector/Sabre/Exception/UnsupportedMediaType.php index a7e935d2497..c5fbfa3a16c 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/UnsupportedMediaType.php +++ b/apps/dav/lib/Connector/Sabre/Exception/UnsupportedMediaType.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; diff --git a/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php b/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php index b4df1f582db..686386dbfef 100644 --- a/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php +++ b/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php @@ -1,36 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use OCA\DAV\Connector\Sabre\Exception\FileLocked; use OCA\DAV\Connector\Sabre\Exception\PasswordLoginForbidden; +use OCA\DAV\Exception\ServerMaintenanceMode; use OCP\Files\StorageNotAvailableException; -use OCP\ILogger; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Conflict; use Sabre\DAV\Exception\Forbidden; @@ -41,7 +22,6 @@ use Sabre\DAV\Exception\NotFound; use Sabre\DAV\Exception\NotImplemented; use Sabre\DAV\Exception\PreconditionFailed; use Sabre\DAV\Exception\RequestedRangeNotSatisfiable; -use Sabre\DAV\Exception\ServiceUnavailable; class ExceptionLoggerPlugin extends \Sabre\DAV\ServerPlugin { protected $nonFatalExceptions = [ @@ -65,6 +45,9 @@ class ExceptionLoggerPlugin extends \Sabre\DAV\ServerPlugin { // forbidden can be expected when trying to upload to // read-only folders for example Forbidden::class => true, + // our forbidden is expected when access control is blocking + // an item in a folder + \OCA\DAV\Connector\Sabre\Exception\Forbidden::class => true, // Happens when an external storage or federated share is temporarily // not available StorageNotAvailableException::class => true, @@ -81,21 +64,16 @@ class ExceptionLoggerPlugin extends \Sabre\DAV\ServerPlugin { FileLocked::class => true, // An invalid range is requested RequestedRangeNotSatisfiable::class => true, + ServerMaintenanceMode::class => true, ]; - /** @var string */ - private $appName; - - /** @var ILogger */ - private $logger; - /** - * @param string $loggerAppName app name to use when logging - * @param ILogger $logger + * @param string $appName app name to use when logging */ - public function __construct($loggerAppName, $logger) { - $this->appName = $loggerAppName; - $this->logger = $logger; + public function __construct( + private string $appName, + private LoggerInterface $logger, + ) { } /** @@ -115,23 +93,20 @@ class ExceptionLoggerPlugin extends \Sabre\DAV\ServerPlugin { /** * Log exception - * */ public function logException(\Throwable $ex) { $exceptionClass = get_class($ex); - $level = ILogger::FATAL; - if (isset($this->nonFatalExceptions[$exceptionClass]) || - ( - $exceptionClass === ServiceUnavailable::class && - $ex->getMessage() === 'System in maintenance mode.' - ) - ) { - $level = ILogger::DEBUG; + if (isset($this->nonFatalExceptions[$exceptionClass])) { + $this->logger->debug($ex->getMessage(), [ + 'app' => $this->appName, + 'exception' => $ex, + ]); + return; } - $this->logger->logException($ex, [ + $this->logger->critical($ex->getMessage(), [ 'app' => $this->appName, - 'level' => $level, + 'exception' => $ex, ]); } } diff --git a/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php b/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php index d05cd6d2e6d..b0c5a079ce1 100644 --- a/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php @@ -1,33 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; +use OCP\AppFramework\Http; use Sabre\DAV\INode; use Sabre\DAV\Locks\LockInfo; use Sabre\DAV\PropFind; +use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\DAV\Xml\Property\LockDiscovery; use Sabre\DAV\Xml\Property\SupportedLock; @@ -47,11 +31,11 @@ use Sabre\HTTP\ResponseInterface; * @package OCA\DAV\Connector\Sabre */ class FakeLockerPlugin extends ServerPlugin { - /** @var \Sabre\DAV\Server */ + /** @var Server */ private $server; /** {@inheritDoc} */ - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(Server $server) { $this->server = $server; $this->server->on('method:LOCK', [$this, 'fakeLockProvider'], 1); $this->server->on('method:UNLOCK', [$this, 'fakeUnlockProvider'], 1); @@ -90,7 +74,7 @@ class FakeLockerPlugin extends ServerPlugin { */ public function propFind(PropFind $propFind, INode $node) { $propFind->handle('{DAV:}supportedlock', function () { - return new SupportedLock(true); + return new SupportedLock(); }); $propFind->handle('{DAV:}lockdiscovery', function () use ($propFind) { return new LockDiscovery([]); @@ -108,7 +92,7 @@ class FakeLockerPlugin extends ServerPlugin { if (isset($fileCondition['tokens'])) { foreach ($fileCondition['tokens'] as &$token) { if (isset($token['token'])) { - if (substr($token['token'], 0, 16) === 'opaquelocktoken:') { + if (str_starts_with($token['token'], 'opaquelocktoken:')) { $token['validToken'] = true; } } @@ -125,19 +109,19 @@ class FakeLockerPlugin extends ServerPlugin { * @return bool */ public function fakeLockProvider(RequestInterface $request, - ResponseInterface $response) { + ResponseInterface $response) { $lockInfo = new LockInfo(); $lockInfo->token = md5($request->getPath()); $lockInfo->uri = $request->getPath(); - $lockInfo->depth = \Sabre\DAV\Server::DEPTH_INFINITY; + $lockInfo->depth = Server::DEPTH_INFINITY; $lockInfo->timeout = 1800; $body = $this->server->xml->write('{DAV:}prop', [ - '{DAV:}lockdiscovery' => - new LockDiscovery([$lockInfo]) + '{DAV:}lockdiscovery' + => new LockDiscovery([$lockInfo]) ]); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setBody($body); return false; @@ -151,8 +135,8 @@ class FakeLockerPlugin extends ServerPlugin { * @return bool */ public function fakeUnlockProvider(RequestInterface $request, - ResponseInterface $response) { - $response->setStatus(204); + ResponseInterface $response) { + $response->setStatus(Http::STATUS_NO_CONTENT); $response->setHeader('Content-Length', '0'); return false; } diff --git a/apps/dav/lib/Connector/Sabre/File.php b/apps/dav/lib/Connector/Sabre/File.php index 6c379984995..d2a71eb3e7b 100644 --- a/apps/dav/lib/Connector/Sabre/File.php +++ b/apps/dav/lib/Connector/Sabre/File.php @@ -1,40 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Jakob Sack <mail@jakobsack.de> - * @author Jan-Philipp Litza <jplitza@users.noreply.github.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Owen Winkler <a_github@midnightcircus.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Semih Serhat Karakaya <karakayasemi@itu.edu.tr> - * @author Stefan Schneider <stefan.schneider@squareweave.com.au> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -43,67 +12,71 @@ use OC\AppFramework\Http\Request; use OC\Files\Filesystem; use OC\Files\Stream\HashWrapper; use OC\Files\View; -use OC\Metadata\FileMetadata; use OCA\DAV\AppInfo\Application; use OCA\DAV\Connector\Sabre\Exception\EntityTooLarge; use OCA\DAV\Connector\Sabre\Exception\FileLocked; use OCA\DAV\Connector\Sabre\Exception\Forbidden as DAVForbiddenException; use OCA\DAV\Connector\Sabre\Exception\UnsupportedMediaType; -use OCA\DAV\Connector\Sabre\Exception\BadGateway; +use OCP\App\IAppManager; use OCP\Encryption\Exceptions\GenericEncryptionException; +use OCP\Files; use OCP\Files\EntityTooLargeException; use OCP\Files\FileInfo; use OCP\Files\ForbiddenException; use OCP\Files\GenericFileException; +use OCP\Files\IMimeTypeDetector; use OCP\Files\InvalidContentException; use OCP\Files\InvalidPathException; use OCP\Files\LockNotAcquiredException; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; -use OCP\Files\Storage; +use OCP\Files\Storage\IWriteStreamStorage; use OCP\Files\StorageNotAvailableException; +use OCP\IConfig; use OCP\IL10N; -use OCP\ILogger; +use OCP\IRequest; use OCP\L10N\IFactory as IL10NFactory; use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; +use OCP\Server; use OCP\Share\IManager; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception; use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; -use Sabre\DAV\Exception\NotImplemented; use Sabre\DAV\Exception\ServiceUnavailable; use Sabre\DAV\IFile; class File extends Node implements IFile { - protected $request; - + protected IRequest $request; protected IL10N $l10n; - /** @var array<string, FileMetadata> */ - private array $metadata = []; - /** * Sets up the node, expects a full path name * - * @param \OC\Files\View $view - * @param \OCP\Files\FileInfo $info - * @param \OCP\Share\IManager $shareManager - * @param \OC\AppFramework\Http\Request $request + * @param View $view + * @param FileInfo $info + * @param ?\OCP\Share\IManager $shareManager + * @param ?IRequest $request + * @param ?IL10N $l10n */ - public function __construct(View $view, FileInfo $info, IManager $shareManager = null, Request $request = null) { + public function __construct(View $view, FileInfo $info, ?IManager $shareManager = null, ?IRequest $request = null, ?IL10N $l10n = null) { parent::__construct($view, $info, $shareManager); - // Querying IL10N directly results in a dependency loop - /** @var IL10NFactory $l10nFactory */ - $l10nFactory = \OC::$server->get(IL10NFactory::class); - $this->l10n = $l10nFactory->get(Application::APP_ID); + if ($l10n) { + $this->l10n = $l10n; + } else { + // Querying IL10N directly results in a dependency loop + /** @var IL10NFactory $l10nFactory */ + $l10nFactory = Server::get(IL10NFactory::class); + $this->l10n = $l10nFactory->get(Application::APP_ID); + } if (isset($request)) { $this->request = $request; } else { - $this->request = \OC::$server->getRequest(); + $this->request = Server::get(IRequest::class); } } @@ -124,7 +97,7 @@ class File extends Node implements IFile { * different object on a subsequent GET you are strongly recommended to not * return an ETag, and just return null. * - * @param resource $data + * @param resource|string $data * * @throws Forbidden * @throws UnsupportedMediaType @@ -138,7 +111,7 @@ class File extends Node implements IFile { public function put($data) { try { $exists = $this->fileView->file_exists($this->path); - if ($this->info && $exists && !$this->info->isUpdateable()) { + if ($exists && !$this->info->isUpdateable()) { throw new Forbidden(); } } catch (StorageNotAvailableException $e) { @@ -148,24 +121,18 @@ class File extends Node implements IFile { // verify path of the target $this->verifyPath(); - // chunked handling - if (isset($_SERVER['HTTP_OC_CHUNKED'])) { - try { - return $this->createFileChunked($data); - } catch (\Exception $e) { - $this->convertToSabreException($e); - } - } - - /** @var Storage $partStorage */ [$partStorage] = $this->fileView->resolvePath($this->path); + if ($partStorage === null) { + throw new ServiceUnavailable($this->l10n->t('Failed to get storage for file')); + } $needsPartFile = $partStorage->needsPartFile() && (strlen($this->path) > 1); - $view = \OC\Files\Filesystem::getView(); + $view = Filesystem::getView(); if ($needsPartFile) { + $transferId = \rand(); // mark file as partial while uploading (ignored by the scanner) - $partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . rand() . '.part'; + $partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . $transferId . '.part'; if (!$view->isCreatable($partFilePath) && $view->isUpdatable($this->path)) { $needsPartFile = false; @@ -181,10 +148,11 @@ class File extends Node implements IFile { } // the part file and target file might be on a different storage in case of a single file storage (e.g. single file share) - /** @var \OC\Files\Storage\Storage $partStorage */ [$partStorage, $internalPartPath] = $this->fileView->resolvePath($partFilePath); - /** @var \OC\Files\Storage\Storage $storage */ [$storage, $internalPath] = $this->fileView->resolvePath($this->path); + if ($partStorage === null || $storage === null) { + throw new ServiceUnavailable($this->l10n->t('Failed to get storage for file')); + } try { if (!$needsPartFile) { try { @@ -215,93 +183,97 @@ class File extends Node implements IFile { $data = $tmpData; } - $data = HashWrapper::wrap($data, 'md5', function ($hash) { - $this->header('X-Hash-MD5: ' . $hash); - }); - $data = HashWrapper::wrap($data, 'sha1', function ($hash) { - $this->header('X-Hash-SHA1: ' . $hash); - }); - $data = HashWrapper::wrap($data, 'sha256', function ($hash) { - $this->header('X-Hash-SHA256: ' . $hash); - }); - - if ($partStorage->instanceOfStorage(Storage\IWriteStreamStorage::class)) { - $isEOF = false; - $wrappedData = CallbackWrapper::wrap($data, null, null, null, null, function ($stream) use (&$isEOF) { - $isEOF = feof($stream); - }); + if ($this->request->getHeader('X-HASH') !== '') { + $hash = $this->request->getHeader('X-HASH'); + if ($hash === 'all' || $hash === 'md5') { + $data = HashWrapper::wrap($data, 'md5', function ($hash): void { + $this->header('X-Hash-MD5: ' . $hash); + }); + } - $result = true; - $count = -1; - try { - $count = $partStorage->writeStream($internalPartPath, $wrappedData); - } catch (GenericFileException $e) { - $result = false; - } catch (BadGateway $e) { - throw $e; + if ($hash === 'all' || $hash === 'sha1') { + $data = HashWrapper::wrap($data, 'sha1', function ($hash): void { + $this->header('X-Hash-SHA1: ' . $hash); + }); } + if ($hash === 'all' || $hash === 'sha256') { + $data = HashWrapper::wrap($data, 'sha256', function ($hash): void { + $this->header('X-Hash-SHA256: ' . $hash); + }); + } + } + + $lengthHeader = $this->request->getHeader('content-length'); + $expected = $lengthHeader !== '' ? (int)$lengthHeader : null; + + if ($partStorage->instanceOfStorage(IWriteStreamStorage::class)) { + $isEOF = false; + $wrappedData = CallbackWrapper::wrap($data, null, null, null, null, function ($stream) use (&$isEOF): void { + $isEOF = feof($stream); + }); - if ($result === false) { - $result = $isEOF; - if (is_resource($wrappedData)) { - $result = feof($wrappedData); + $result = is_resource($wrappedData); + if ($result) { + $count = -1; + try { + /** @var IWriteStreamStorage $partStorage */ + $count = $partStorage->writeStream($internalPartPath, $wrappedData, $expected); + } catch (GenericFileException $e) { + $logger = Server::get(LoggerInterface::class); + $logger->error('Error while writing stream to storage: ' . $e->getMessage(), ['exception' => $e, 'app' => 'webdav']); + $result = $isEOF; + if (is_resource($wrappedData)) { + $result = feof($wrappedData); + } } } } else { $target = $partStorage->fopen($internalPartPath, 'wb'); if ($target === false) { - \OC::$server->getLogger()->error('\OC\Files\Filesystem::fopen() failed', ['app' => 'webdav']); + Server::get(LoggerInterface::class)->error('\OC\Files\Filesystem::fopen() failed', ['app' => 'webdav']); // because we have no clue about the cause we can only throw back a 500/Internal Server Error throw new Exception($this->l10n->t('Could not write file contents')); } - [$count, $result] = \OC_Helper::streamCopy($data, $target); + [$count, $result] = Files::streamCopy($data, $target, true); fclose($target); } - - if ($result === false) { - $expected = -1; - if (isset($_SERVER['CONTENT_LENGTH'])) { - $expected = $_SERVER['CONTENT_LENGTH']; - } - if ($expected !== "0") { - throw new Exception( - $this->l10n->t( - 'Error while copying file to target location (copied: %1$s, expected filesize: %2$s)', - [ - $this->l10n->n('%n byte', '%n bytes', $count), - $this->l10n->n('%n byte', '%n bytes', $expected), - ], - ) - ); - } + if ($result === false && $expected !== null) { + throw new Exception( + $this->l10n->t( + 'Error while copying file to target location (copied: %1$s, expected filesize: %2$s)', + [ + $this->l10n->n('%n byte', '%n bytes', $count), + $this->l10n->n('%n byte', '%n bytes', $expected), + ], + ) + ); } // if content length is sent by client: // double check if the file was fully received // compare expected and actual size - if (isset($_SERVER['CONTENT_LENGTH']) && $_SERVER['REQUEST_METHOD'] === 'PUT') { - $expected = (int)$_SERVER['CONTENT_LENGTH']; - if ($count !== $expected) { - throw new BadRequest( - $this->l10n->t( - 'Expected filesize of %1$s but read (from Nextcloud client) and wrote (to Nextcloud storage) %2$s. Could either be a network problem on the sending side or a problem writing to the storage on the server side.', - [ - $this->l10n->n('%n byte', '%n bytes', $expected), - $this->l10n->n('%n byte', '%n bytes', $count), - ], - ) - ); - } + if ($expected !== null + && $expected !== $count + && $this->request->getMethod() === 'PUT' + ) { + throw new BadRequest( + $this->l10n->t( + 'Expected filesize of %1$s but read (from Nextcloud client) and wrote (to Nextcloud storage) %2$s. Could either be a network problem on the sending side or a problem writing to the storage on the server side.', + [ + $this->l10n->n('%n byte', '%n bytes', $expected), + $this->l10n->n('%n byte', '%n bytes', $count), + ], + ) + ); } } catch (\Exception $e) { - $context = []; - if ($e instanceof LockedException) { - $context['level'] = ILogger::DEBUG; + Server::get(LoggerInterface::class)->debug($e->getMessage(), ['exception' => $e]); + } else { + Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); } - \OC::$server->getLogger()->logException($e, $context); if ($needsPartFile) { $partStorage->unlink($internalPartPath); } @@ -340,7 +312,7 @@ class File extends Node implements IFile { $renameOkay = $storage->moveFromStorage($partStorage, $internalPartPath, $internalPath); $fileExists = $storage->file_exists($internalPath); if ($renameOkay === false || $fileExists === false) { - \OC::$server->getLogger()->error('renaming part file to final file failed $renameOkay: ' . ($renameOkay ? 'true' : 'false') . ', $fileExists: ' . ($fileExists ? 'true' : 'false') . ')', ['app' => 'webdav']); + Server::get(LoggerInterface::class)->error('renaming part file to final file failed $renameOkay: ' . ($renameOkay ? 'true' : 'false') . ', $fileExists: ' . ($fileExists ? 'true' : 'false') . ')', ['app' => 'webdav']); throw new Exception($this->l10n->t('Could not rename part file to final file')); } } catch (ForbiddenException $ex) { @@ -364,8 +336,9 @@ class File extends Node implements IFile { } // allow sync clients to send the mtime along in a header - if (isset($this->request->server['HTTP_X_OC_MTIME'])) { - $mtime = $this->sanitizeMtime($this->request->server['HTTP_X_OC_MTIME']); + $mtimeHeader = $this->request->getHeader('x-oc-mtime'); + if ($mtimeHeader !== '') { + $mtime = $this->sanitizeMtime($mtimeHeader); if ($this->fileView->touch($this->path, $mtime)) { $this->header('X-OC-MTime: accepted'); } @@ -376,8 +349,9 @@ class File extends Node implements IFile { ]; // allow sync clients to send the creation time along in a header - if (isset($this->request->server['HTTP_X_OC_CTIME'])) { - $ctime = $this->sanitizeMtime($this->request->server['HTTP_X_OC_CTIME']); + $ctimeHeader = $this->request->getHeader('x-oc-ctime'); + if ($ctimeHeader) { + $ctime = $this->sanitizeMtime($ctimeHeader); $fileInfoUpdate['creation_time'] = $ctime; $this->header('X-OC-CTime: accepted'); } @@ -390,8 +364,9 @@ class File extends Node implements IFile { $this->refreshInfo(); - if (isset($this->request->server['HTTP_OC_CHECKSUM'])) { - $checksum = trim($this->request->server['HTTP_OC_CHECKSUM']); + $checksumHeader = $this->request->getHeader('oc-checksum'); + if ($checksumHeader) { + $checksum = trim($checksumHeader); $this->setChecksum($checksum); } elseif ($this->getChecksum() !== null && $this->getChecksum() !== '') { $this->setChecksum(''); @@ -404,61 +379,68 @@ class File extends Node implements IFile { } private function getPartFileBasePath($path) { - $partFileInStorage = \OC::$server->getConfig()->getSystemValue('part_file_in_storage', true); + $partFileInStorage = Server::get(IConfig::class)->getSystemValue('part_file_in_storage', true); if ($partFileInStorage) { - return $path; + $filename = basename($path); + // hash does not need to be secure but fast and semi unique + $hashedFilename = hash('xxh128', $filename); + return substr($path, 0, strlen($path) - strlen($filename)) . $hashedFilename; } else { - return md5($path); // will place it in the root of the view with a unique name + // will place the .part file in the users root directory + // therefor we need to make the name (semi) unique - hash does not need to be secure but fast. + return hash('xxh128', $path); } } - /** - * @param string $path - */ - private function emitPreHooks($exists, $path = null) { + private function emitPreHooks(bool $exists, ?string $path = null): bool { if (is_null($path)) { $path = $this->path; } $hookPath = Filesystem::getView()->getRelativePath($this->fileView->getAbsolutePath($path)); + if ($hookPath === null) { + // We only trigger hooks from inside default view + return true; + } $run = true; if (!$exists) { - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_create, [ - \OC\Files\Filesystem::signal_param_path => $hookPath, - \OC\Files\Filesystem::signal_param_run => &$run, + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_create, [ + Filesystem::signal_param_path => $hookPath, + Filesystem::signal_param_run => &$run, ]); } else { - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_update, [ - \OC\Files\Filesystem::signal_param_path => $hookPath, - \OC\Files\Filesystem::signal_param_run => &$run, + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_update, [ + Filesystem::signal_param_path => $hookPath, + Filesystem::signal_param_run => &$run, ]); } - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_write, [ - \OC\Files\Filesystem::signal_param_path => $hookPath, - \OC\Files\Filesystem::signal_param_run => &$run, + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_write, [ + Filesystem::signal_param_path => $hookPath, + Filesystem::signal_param_run => &$run, ]); return $run; } - /** - * @param string $path - */ - private function emitPostHooks($exists, $path = null) { + private function emitPostHooks(bool $exists, ?string $path = null): void { if (is_null($path)) { $path = $this->path; } $hookPath = Filesystem::getView()->getRelativePath($this->fileView->getAbsolutePath($path)); + if ($hookPath === null) { + // We only trigger hooks from inside default view + return; + } if (!$exists) { - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_post_create, [ - \OC\Files\Filesystem::signal_param_path => $hookPath + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_create, [ + Filesystem::signal_param_path => $hookPath ]); } else { - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_post_update, [ - \OC\Files\Filesystem::signal_param_path => $hookPath + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_update, [ + Filesystem::signal_param_path => $hookPath ]); } - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_post_write, [ - \OC\Files\Filesystem::signal_param_path => $hookPath + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_write, [ + Filesystem::signal_param_path => $hookPath ]); } @@ -476,14 +458,31 @@ class File extends Node implements IFile { // do a if the file did not exist throw new NotFound(); } + $path = ltrim($this->path, '/'); try { - $res = $this->fileView->fopen(ltrim($this->path, '/'), 'rb'); + $res = $this->fileView->fopen($path, 'rb'); } catch (\Exception $e) { $this->convertToSabreException($e); } + if ($res === false) { - throw new ServiceUnavailable($this->l10n->t('Could not open file')); + if ($this->fileView->file_exists($path)) { + throw new ServiceUnavailable($this->l10n->t('Could not open file: %1$s, file does seem to exist', [$path])); + } else { + throw new ServiceUnavailable($this->l10n->t('Could not open file: %1$s, file doesn\'t seem to exist', [$path])); + } } + + // comparing current file size with the one in DB + // if different, fix DB and refresh cache. + if ($this->getSize() !== $this->fileView->filesize($this->getPath())) { + $logger = Server::get(LoggerInterface::class); + $logger->warning('fixing cached size of file id=' . $this->getId()); + + $this->getFileInfo()->getStorage()->getUpdater()->update($this->getFileInfo()->getInternalPath()); + $this->refreshInfo(); + } + return $res; } catch (GenericEncryptionException $e) { // returning 503 will allow retry of the operation at a later point in time @@ -533,20 +532,19 @@ class File extends Node implements IFile { $mimeType = $this->info->getMimetype(); // PROPFIND needs to return the correct mime type, for consistency with the web UI - if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'PROPFIND') { + if ($this->request->getMethod() === 'PROPFIND') { return $mimeType; } - return \OC::$server->getMimeTypeDetector()->getSecureMimeType($mimeType); + return Server::get(IMimeTypeDetector::class)->getSecureMimeType($mimeType); } /** * @return array|bool */ public function getDirectDownload() { - if (\OCP\App::isEnabled('encryption')) { + if (Server::get(IAppManager::class)->isEnabledForUser('encryption')) { return []; } - /** @var \OCP\Files\Storage $storage */ [$storage, $internalPath] = $this->fileView->resolvePath($this->path); if (is_null($storage)) { return []; @@ -556,132 +554,6 @@ class File extends Node implements IFile { } /** - * @param resource $data - * @return null|string - * @throws Exception - * @throws BadRequest - * @throws NotImplemented - * @throws ServiceUnavailable - */ - private function createFileChunked($data) { - [$path, $name] = \Sabre\Uri\split($this->path); - - $info = \OC_FileChunking::decodeName($name); - if (empty($info)) { - throw new NotImplemented($this->l10n->t('Invalid chunk name')); - } - - $chunk_handler = new \OC_FileChunking($info); - $bytesWritten = $chunk_handler->store($info['index'], $data); - - //detect aborted upload - if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'PUT') { - if (isset($_SERVER['CONTENT_LENGTH'])) { - $expected = (int)$_SERVER['CONTENT_LENGTH']; - if ($bytesWritten !== $expected) { - $chunk_handler->remove($info['index']); - throw new BadRequest( - $this->l10n->t( - 'Expected filesize of %1$s but read (from Nextcloud client) and wrote (to Nextcloud storage) %2$s. Could either be a network problem on the sending side or a problem writing to the storage on the server side.', - [ - $this->l10n->n('%n byte', '%n bytes', $expected), - $this->l10n->n('%n byte', '%n bytes', $bytesWritten), - ], - ) - ); - } - } - } - - if ($chunk_handler->isComplete()) { - /** @var Storage $storage */ - [$storage,] = $this->fileView->resolvePath($path); - $needsPartFile = $storage->needsPartFile(); - $partFile = null; - - $targetPath = $path . '/' . $info['name']; - /** @var \OC\Files\Storage\Storage $targetStorage */ - [$targetStorage, $targetInternalPath] = $this->fileView->resolvePath($targetPath); - - $exists = $this->fileView->file_exists($targetPath); - - try { - $this->fileView->lockFile($targetPath, ILockingProvider::LOCK_SHARED); - - $this->emitPreHooks($exists, $targetPath); - $this->fileView->changeLock($targetPath, ILockingProvider::LOCK_EXCLUSIVE); - /** @var \OC\Files\Storage\Storage $targetStorage */ - [$targetStorage, $targetInternalPath] = $this->fileView->resolvePath($targetPath); - - if ($needsPartFile) { - // we first assembly the target file as a part file - $partFile = $this->getPartFileBasePath($path . '/' . $info['name']) . '.ocTransferId' . $info['transferid'] . '.part'; - /** @var \OC\Files\Storage\Storage $targetStorage */ - [$partStorage, $partInternalPath] = $this->fileView->resolvePath($partFile); - - - $chunk_handler->file_assemble($partStorage, $partInternalPath); - - // here is the final atomic rename - $renameOkay = $targetStorage->moveFromStorage($partStorage, $partInternalPath, $targetInternalPath); - $fileExists = $targetStorage->file_exists($targetInternalPath); - if ($renameOkay === false || $fileExists === false) { - \OC::$server->getLogger()->error('\OC\Files\Filesystem::rename() failed', ['app' => 'webdav']); - // only delete if an error occurred and the target file was already created - if ($fileExists) { - // set to null to avoid double-deletion when handling exception - // stray part file - $partFile = null; - $targetStorage->unlink($targetInternalPath); - } - $this->fileView->changeLock($targetPath, ILockingProvider::LOCK_SHARED); - throw new Exception($this->l10n->t('Could not rename part file assembled from chunks')); - } - } else { - // assemble directly into the final file - $chunk_handler->file_assemble($targetStorage, $targetInternalPath); - } - - // allow sync clients to send the mtime along in a header - if (isset($this->request->server['HTTP_X_OC_MTIME'])) { - $mtime = $this->sanitizeMtime($this->request->server['HTTP_X_OC_MTIME']); - if ($targetStorage->touch($targetInternalPath, $mtime)) { - $this->header('X-OC-MTime: accepted'); - } - } - - // since we skipped the view we need to scan and emit the hooks ourselves - $targetStorage->getUpdater()->update($targetInternalPath); - - $this->fileView->changeLock($targetPath, ILockingProvider::LOCK_SHARED); - - $this->emitPostHooks($exists, $targetPath); - - // FIXME: should call refreshInfo but can't because $this->path is not the of the final file - $info = $this->fileView->getFileInfo($targetPath); - - if (isset($this->request->server['HTTP_OC_CHECKSUM'])) { - $checksum = trim($this->request->server['HTTP_OC_CHECKSUM']); - $this->fileView->putFileInfo($targetPath, ['checksum' => $checksum]); - } elseif ($info->getChecksum() !== null && $info->getChecksum() !== '') { - $this->fileView->putFileInfo($this->path, ['checksum' => '']); - } - - $this->fileView->unlockFile($targetPath, ILockingProvider::LOCK_SHARED); - - return $info->getEtag(); - } catch (\Exception $e) { - if ($partFile !== null) { - $targetStorage->unlink($targetInternalPath); - } - $this->convertToSabreException($e); - } - } - - return null; - } - - /** * Convert the given exception to a SabreException instance * * @param \Exception $e @@ -737,9 +609,6 @@ class File extends Node implements IFile { * @return string|null */ public function getChecksum() { - if (!$this->info) { - return null; - } return $this->info->getChecksum(); } @@ -761,16 +630,4 @@ class File extends Node implements IFile { public function getNode(): \OCP\Files\File { return $this->node; } - - public function getMetadata(string $group): FileMetadata { - return $this->metadata[$group]; - } - - public function setMetadata(string $group, FileMetadata $metadata): void { - $this->metadata[$group] = $metadata; - } - - public function hasMetadata(string $group) { - return array_key_exists($group, $this->metadata); - } } diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index 9c4f912610b..843383a0452 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -1,61 +1,45 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Michael Jobst <mjobst+github@tecratech.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use OC\AppFramework\Http\Request; -use OC\Metadata\IMetadataManager; +use OC\FilesMetadata\Model\FilesMetadata; +use OC\User\NoUserException; +use OCA\DAV\Connector\Sabre\Exception\InvalidPath; +use OCA\Files_Sharing\External\Mount as SharingExternalMount; +use OCP\Accounts\IAccountManager; use OCP\Constants; use OCP\Files\ForbiddenException; +use OCP\Files\IFilenameValidator; +use OCP\Files\InvalidPathException; +use OCP\Files\Storage\ISharedStorage; use OCP\Files\StorageNotAvailableException; +use OCP\FilesMetadata\Exceptions\FilesMetadataException; +use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException; +use OCP\FilesMetadata\IFilesMetadataManager; +use OCP\FilesMetadata\Model\IMetadataValueWrapper; use OCP\IConfig; use OCP\IPreview; use OCP\IRequest; use OCP\IUserSession; -use Psr\Log\LoggerInterface; +use OCP\L10N\IFactory; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\IFile; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; +use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\DAV\Tree; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; -use Sabre\Uri; class FilesPlugin extends ServerPlugin { - // namespace public const NS_OWNCLOUD = 'http://owncloud.org/ns'; public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; @@ -64,93 +48,55 @@ class FilesPlugin extends ServerPlugin { public const PERMISSIONS_PROPERTYNAME = '{http://owncloud.org/ns}permissions'; public const SHARE_PERMISSIONS_PROPERTYNAME = '{http://open-collaboration-services.org/ns}share-permissions'; public const OCM_SHARE_PERMISSIONS_PROPERTYNAME = '{http://open-cloud-mesh.org/ns}share-permissions'; + public const SHARE_ATTRIBUTES_PROPERTYNAME = '{http://nextcloud.org/ns}share-attributes'; public const DOWNLOADURL_PROPERTYNAME = '{http://owncloud.org/ns}downloadURL'; public const SIZE_PROPERTYNAME = '{http://owncloud.org/ns}size'; public const GETETAG_PROPERTYNAME = '{DAV:}getetag'; public const LASTMODIFIED_PROPERTYNAME = '{DAV:}lastmodified'; public const CREATIONDATE_PROPERTYNAME = '{DAV:}creationdate'; + public const DISPLAYNAME_PROPERTYNAME = '{DAV:}displayname'; public const OWNER_ID_PROPERTYNAME = '{http://owncloud.org/ns}owner-id'; public const OWNER_DISPLAY_NAME_PROPERTYNAME = '{http://owncloud.org/ns}owner-display-name'; public const CHECKSUMS_PROPERTYNAME = '{http://owncloud.org/ns}checksums'; public const DATA_FINGERPRINT_PROPERTYNAME = '{http://owncloud.org/ns}data-fingerprint'; public const HAS_PREVIEW_PROPERTYNAME = '{http://nextcloud.org/ns}has-preview'; public const MOUNT_TYPE_PROPERTYNAME = '{http://nextcloud.org/ns}mount-type'; - public const IS_ENCRYPTED_PROPERTYNAME = '{http://nextcloud.org/ns}is-encrypted'; + public const MOUNT_ROOT_PROPERTYNAME = '{http://nextcloud.org/ns}is-mount-root'; + public const IS_FEDERATED_PROPERTYNAME = '{http://nextcloud.org/ns}is-federated'; public const METADATA_ETAG_PROPERTYNAME = '{http://nextcloud.org/ns}metadata_etag'; public const UPLOAD_TIME_PROPERTYNAME = '{http://nextcloud.org/ns}upload_time'; public const CREATION_TIME_PROPERTYNAME = '{http://nextcloud.org/ns}creation_time'; public const SHARE_NOTE = '{http://nextcloud.org/ns}note'; + public const SHARE_HIDE_DOWNLOAD_PROPERTYNAME = '{http://nextcloud.org/ns}hide-download'; public const SUBFOLDER_COUNT_PROPERTYNAME = '{http://nextcloud.org/ns}contained-folder-count'; public const SUBFILE_COUNT_PROPERTYNAME = '{http://nextcloud.org/ns}contained-file-count'; - public const FILE_METADATA_SIZE = '{http://nextcloud.org/ns}file-metadata-size'; - - /** - * Reference to main server object - * - * @var \Sabre\DAV\Server - */ - private $server; - - /** - * @var Tree - */ - private $tree; - - /** - * @var IUserSession - */ - private $userSession; - - /** - * Whether this is public webdav. - * If true, some returned information will be stripped off. - * - * @var bool - */ - private $isPublic; + public const FILE_METADATA_PREFIX = '{http://nextcloud.org/ns}metadata-'; + public const HIDDEN_PROPERTYNAME = '{http://nextcloud.org/ns}hidden'; - /** - * @var bool - */ - private $downloadAttachment; - - /** - * @var IConfig - */ - private $config; - - /** - * @var IRequest - */ - private $request; - - /** - * @var IPreview - */ - private $previewManager; + /** Reference to main server object */ + private ?Server $server = null; /** * @param Tree $tree * @param IConfig $config * @param IRequest $request * @param IPreview $previewManager - * @param bool $isPublic + * @param IUserSession $userSession + * @param bool $isPublic Whether this is public WebDAV. If true, some returned information will be stripped off. * @param bool $downloadAttachment + * @return void */ - public function __construct(Tree $tree, - IConfig $config, - IRequest $request, - IPreview $previewManager, - IUserSession $userSession, - $isPublic = false, - $downloadAttachment = true) { - $this->tree = $tree; - $this->config = $config; - $this->request = $request; - $this->userSession = $userSession; - $this->isPublic = $isPublic; - $this->downloadAttachment = $downloadAttachment; - $this->previewManager = $previewManager; + public function __construct( + private Tree $tree, + private IConfig $config, + private IRequest $request, + private IPreview $previewManager, + private IUserSession $userSession, + private IFilenameValidator $validator, + private IAccountManager $accountManager, + private bool $isPublic = false, + private bool $downloadAttachment = true, + ) { } /** @@ -161,10 +107,9 @@ class FilesPlugin extends ServerPlugin { * * This method should set up the required event subscriptions. * - * @param \Sabre\DAV\Server $server * @return void */ - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(Server $server) { $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; $server->xml->namespaceMap[self::NS_NEXTCLOUD] = 'nc'; $server->protectedProperties[] = self::FILEID_PROPERTYNAME; @@ -172,6 +117,7 @@ class FilesPlugin extends ServerPlugin { $server->protectedProperties[] = self::PERMISSIONS_PROPERTYNAME; $server->protectedProperties[] = self::SHARE_PERMISSIONS_PROPERTYNAME; $server->protectedProperties[] = self::OCM_SHARE_PERMISSIONS_PROPERTYNAME; + $server->protectedProperties[] = self::SHARE_ATTRIBUTES_PROPERTYNAME; $server->protectedProperties[] = self::SIZE_PROPERTYNAME; $server->protectedProperties[] = self::DOWNLOADURL_PROPERTYNAME; $server->protectedProperties[] = self::OWNER_ID_PROPERTYNAME; @@ -180,7 +126,7 @@ class FilesPlugin extends ServerPlugin { $server->protectedProperties[] = self::DATA_FINGERPRINT_PROPERTYNAME; $server->protectedProperties[] = self::HAS_PREVIEW_PROPERTYNAME; $server->protectedProperties[] = self::MOUNT_TYPE_PROPERTYNAME; - $server->protectedProperties[] = self::IS_ENCRYPTED_PROPERTYNAME; + $server->protectedProperties[] = self::IS_FEDERATED_PROPERTYNAME; $server->protectedProperties[] = self::SHARE_NOTE; // normally these cannot be changed (RFC4918), but we want them modifiable through PROPPATCH @@ -194,40 +140,79 @@ class FilesPlugin extends ServerPlugin { $this->server->on('afterWriteContent', [$this, 'sendFileIdHeader']); $this->server->on('afterMethod:GET', [$this,'httpGet']); $this->server->on('afterMethod:GET', [$this, 'handleDownloadToken']); - $this->server->on('afterResponse', function ($request, ResponseInterface $response) { + $this->server->on('afterResponse', function ($request, ResponseInterface $response): void { $body = $response->getBody(); if (is_resource($body)) { fclose($body); } }); $this->server->on('beforeMove', [$this, 'checkMove']); + $this->server->on('beforeCopy', [$this, 'checkCopy']); } /** - * Plugin that checks if a move can actually be performed. + * Plugin that checks if a copy can actually be performed. * * @param string $source source path - * @param string $destination destination path - * @throws Forbidden - * @throws NotFound + * @param string $target target path + * @throws NotFound If the source does not exist + * @throws InvalidPath If the target is invalid */ - public function checkMove($source, $destination) { + public function checkCopy($source, $target): void { $sourceNode = $this->tree->getNodeForPath($source); if (!$sourceNode instanceof Node) { return; } - [$sourceDir,] = \Sabre\Uri\split($source); - [$destinationDir,] = \Sabre\Uri\split($destination); - if ($sourceDir !== $destinationDir) { - $sourceNodeFileInfo = $sourceNode->getFileInfo(); - if ($sourceNodeFileInfo === null) { - throw new NotFound($source . ' does not exist'); + // Ensure source exists + $sourceNodeFileInfo = $sourceNode->getFileInfo(); + if ($sourceNodeFileInfo === null) { + throw new NotFound($source . ' does not exist'); + } + // Ensure the target name is valid + try { + [$targetPath, $targetName] = \Sabre\Uri\split($target); + $this->validator->validateFilename($targetName); + } catch (InvalidPathException $e) { + throw new InvalidPath($e->getMessage(), false); + } + // Ensure the target path is valid + $segments = array_slice(explode('/', $targetPath), 2); + foreach ($segments as $segment) { + if ($this->validator->isFilenameValid($segment) === false) { + $l = \OCP\Server::get(IFactory::class)->get('dav'); + throw new InvalidPath($l->t('Invalid target path')); } + } + } - if (!$sourceNodeFileInfo->isDeletable()) { - throw new Forbidden($source . " cannot be deleted"); - } + /** + * Plugin that checks if a move can actually be performed. + * + * @param string $source source path + * @param string $target target path + * @throws Forbidden If the source is not deletable + * @throws NotFound If the source does not exist + * @throws InvalidPath If the target name is invalid + */ + public function checkMove(string $source, string $target): void { + $sourceNode = $this->tree->getNodeForPath($source); + if (!$sourceNode instanceof Node) { + return; + } + + // First check copyable (move only needs additional delete permission) + $this->checkCopy($source, $target); + + // The source needs to be deletable for moving + $sourceNodeFileInfo = $sourceNode->getFileInfo(); + if (!$sourceNodeFileInfo->isDeletable()) { + throw new Forbidden($source . ' cannot be deleted'); + } + + // The source is not allowed to be the parent of the target + if (str_starts_with($source, $target . '/')) { + throw new Forbidden($source . ' cannot be moved to it\'s parent'); } } @@ -272,8 +257,8 @@ class FilesPlugin extends ServerPlugin { // adds a 'Content-Disposition: attachment' header in case no disposition // header has been set before - if ($this->downloadAttachment && - $response->getHeader('Content-Disposition') === null) { + if ($this->downloadAttachment + && $response->getHeader('Content-Disposition') === null) { $filename = $node->getName(); if ($this->request->isUserAgent( [ @@ -288,7 +273,7 @@ class FilesPlugin extends ServerPlugin { } } - if ($node instanceof \OCA\DAV\Connector\Sabre\File) { + if ($node instanceof File) { //Add OC-Checksum header $checksum = $node->getChecksum(); if ($checksum !== null && $checksum !== '') { @@ -308,7 +293,7 @@ class FilesPlugin extends ServerPlugin { public function handleGetProperties(PropFind $propFind, \Sabre\DAV\INode $node) { $httpRequest = $this->server->httpRequest; - if ($node instanceof \OCA\DAV\Connector\Sabre\Node) { + if ($node instanceof Node) { /** * This was disabled, because it made dir listing throw an exception, * so users were unable to navigate into folders where one subitem @@ -347,7 +332,7 @@ class FilesPlugin extends ServerPlugin { ); }); - $propFind->handle(self::OCM_SHARE_PERMISSIONS_PROPERTYNAME, function () use ($node, $httpRequest) { + $propFind->handle(self::OCM_SHARE_PERMISSIONS_PROPERTYNAME, function () use ($node, $httpRequest): ?string { $user = $this->userSession->getUser(); if ($user === null) { return null; @@ -356,14 +341,18 @@ class FilesPlugin extends ServerPlugin { $user->getUID() ); $ocmPermissions = $this->ncPermissions2ocmPermissions($ncPermissions); - return json_encode($ocmPermissions); + return json_encode($ocmPermissions, JSON_THROW_ON_ERROR); + }); + + $propFind->handle(self::SHARE_ATTRIBUTES_PROPERTYNAME, function () use ($node, $httpRequest) { + return json_encode($node->getShareAttributes(), JSON_THROW_ON_ERROR); }); - $propFind->handle(self::GETETAG_PROPERTYNAME, function () use ($node) { + $propFind->handle(self::GETETAG_PROPERTYNAME, function () use ($node): string { return $node->getETag(); }); - $propFind->handle(self::OWNER_ID_PROPERTYNAME, function () use ($node) { + $propFind->handle(self::OWNER_ID_PROPERTYNAME, function () use ($node): ?string { $owner = $node->getOwner(); if (!$owner) { return null; @@ -371,36 +360,79 @@ class FilesPlugin extends ServerPlugin { return $owner->getUID(); } }); - $propFind->handle(self::OWNER_DISPLAY_NAME_PROPERTYNAME, function () use ($node) { + $propFind->handle(self::OWNER_DISPLAY_NAME_PROPERTYNAME, function () use ($node): ?string { $owner = $node->getOwner(); if (!$owner) { return null; - } else { + } + + // Get current user to see if we're in a public share or not + $user = $this->userSession->getUser(); + + // If the user is logged in, we can return the display name + if ($user !== null) { return $owner->getDisplayName(); } + + // Check if the user published their display name + try { + $ownerAccount = $this->accountManager->getAccount($owner); + } catch (NoUserException) { + // do not lock process if owner is not local + return null; + } + + $ownerNameProperty = $ownerAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME); + + // Since we are not logged in, we need to have at least the published scope + if ($ownerNameProperty->getScope() === IAccountManager::SCOPE_PUBLISHED) { + return $owner->getDisplayName(); + } + + return null; }); $propFind->handle(self::HAS_PREVIEW_PROPERTYNAME, function () use ($node) { - return json_encode($this->previewManager->isAvailable($node->getFileInfo())); + return json_encode($this->previewManager->isAvailable($node->getFileInfo()), JSON_THROW_ON_ERROR); }); - $propFind->handle(self::SIZE_PROPERTYNAME, function () use ($node) { + $propFind->handle(self::SIZE_PROPERTYNAME, function () use ($node): int|float { return $node->getSize(); }); $propFind->handle(self::MOUNT_TYPE_PROPERTYNAME, function () use ($node) { return $node->getFileInfo()->getMountPoint()->getMountType(); }); - $propFind->handle(self::SHARE_NOTE, function () use ($node, $httpRequest) { + /** + * This is a special property which is used to determine if a node + * is a mount root or not, e.g. a shared folder. + * If so, then the node can only be unshared and not deleted. + * @see https://github.com/nextcloud/server/blob/cc75294eb6b16b916a342e69998935f89222619d/lib/private/Files/View.php#L696-L698 + */ + $propFind->handle(self::MOUNT_ROOT_PROPERTYNAME, function () use ($node) { + return $node->getNode()->getInternalPath() === '' ? 'true' : 'false'; + }); + + $propFind->handle(self::SHARE_NOTE, function () use ($node): ?string { $user = $this->userSession->getUser(); - if ($user === null) { - return null; - } return $node->getNoteFromShare( - $user->getUID() + $user?->getUID() ); }); - $propFind->handle(self::DATA_FINGERPRINT_PROPERTYNAME, function () use ($node) { + $propFind->handle(self::SHARE_HIDE_DOWNLOAD_PROPERTYNAME, function () use ($node) { + $storage = $node->getNode()->getStorage(); + if ($storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ + return match($storage->getShare()->getHideDownload()) { + true => 'true', + false => 'false', + }; + } else { + return null; + } + }); + + $propFind->handle(self::DATA_FINGERPRINT_PROPERTYNAME, function () { return $this->config->getSystemValue('data-fingerprint', ''); }); $propFind->handle(self::CREATIONDATE_PROPERTYNAME, function () use ($node) { @@ -411,9 +443,34 @@ class FilesPlugin extends ServerPlugin { $propFind->handle(self::CREATION_TIME_PROPERTYNAME, function () use ($node) { return $node->getFileInfo()->getCreationTime(); }); + + foreach ($node->getFileInfo()->getMetadata() as $metadataKey => $metadataValue) { + $propFind->handle(self::FILE_METADATA_PREFIX . $metadataKey, $metadataValue); + } + + $propFind->handle(self::HIDDEN_PROPERTYNAME, function () use ($node) { + $isLivePhoto = isset($node->getFileInfo()->getMetadata()['files-live-photo']); + $isMovFile = $node->getFileInfo()->getMimetype() === 'video/quicktime'; + return ($isLivePhoto && $isMovFile) ? 'true' : 'false'; + }); + + /** + * Return file/folder name as displayname. The primary reason to + * implement it this way is to avoid costly fallback to + * CustomPropertiesBackend (esp. visible when querying all files + * in a folder). + */ + $propFind->handle(self::DISPLAYNAME_PROPERTYNAME, function () use ($node) { + return $node->getName(); + }); + + $propFind->handle(self::IS_FEDERATED_PROPERTYNAME, function () use ($node) { + return $node->getFileInfo()->getMountPoint() + instanceof SharingExternalMount; + }); } - if ($node instanceof \OCA\DAV\Connector\Sabre\File) { + if ($node instanceof File) { $propFind->handle(self::DOWNLOADURL_PROPERTYNAME, function () use ($node) { try { $directDownloadUrl = $node->getDirectDownload(); @@ -440,29 +497,6 @@ class FilesPlugin extends ServerPlugin { $propFind->handle(self::UPLOAD_TIME_PROPERTYNAME, function () use ($node) { return $node->getFileInfo()->getUploadTime(); }); - - if ($this->config->getSystemValueBool('enable_file_metadata', true)) { - $propFind->handle(self::FILE_METADATA_SIZE, function () use ($node) { - if (!str_starts_with($node->getFileInfo()->getMimetype(), 'image')) { - return json_encode((object)[]); - } - - if ($node->hasMetadata('size')) { - $sizeMetadata = $node->getMetadata('size'); - } else { - // This code path should not be called since we try to preload - // the metadata when loading the folder or the search results - // in one go - $metadataManager = \OC::$server->get(IMetadataManager::class); - $sizeMetadata = $metadataManager->fetchMetadataFor('size', [$node->getId()])[$node->getId()]; - - // TODO would be nice to display this in the profiler... - \OC::$server->get(LoggerInterface::class)->debug('Inefficient fetching of metadata'); - } - - return json_encode((object)$sizeMetadata->getMetadata()); - }); - } } if ($node instanceof Directory) { @@ -470,37 +504,8 @@ class FilesPlugin extends ServerPlugin { return $node->getSize(); }); - $propFind->handle(self::IS_ENCRYPTED_PROPERTYNAME, function () use ($node) { - return $node->getFileInfo()->isEncrypted() ? '1' : '0'; - }); - $requestProperties = $propFind->getRequestedProperties(); - // TODO detect dynamically which metadata groups are requested and - // preload all of them and not just size - if ($this->config->getSystemValueBool('enable_file_metadata', true) - && in_array(self::FILE_METADATA_SIZE, $requestProperties, true)) { - // Preloading of the metadata - $fileIds = []; - foreach ($node->getChildren() as $child) { - /** @var \OCP\Files\Node|Node $child */ - if (str_starts_with($child->getFileInfo()->getMimeType(), 'image/')) { - /** @var File $child */ - $fileIds[] = $child->getFileInfo()->getId(); - } - } - /** @var IMetaDataManager $metadataManager */ - $metadataManager = \OC::$server->get(IMetadataManager::class); - $preloadedMetadata = $metadataManager->fetchMetadataFor('size', $fileIds); - foreach ($node->getChildren() as $child) { - /** @var \OCP\Files\Node|Node $child */ - if (str_starts_with($child->getFileInfo()->getMimeType(), 'image')) { - /** @var File $child */ - $child->setMetadata('size', $preloadedMetadata[$child->getFileInfo()->getId()]); - } - } - } - if (in_array(self::SUBFILE_COUNT_PROPERTYNAME, $requestProperties, true) || in_array(self::SUBFOLDER_COUNT_PROPERTYNAME, $requestProperties, true)) { $nbFiles = 0; @@ -536,8 +541,8 @@ class FilesPlugin extends ServerPlugin { $ocmPermissions[] = 'read'; } - if (($ncPermissions & Constants::PERMISSION_CREATE) || - ($ncPermissions & Constants::PERMISSION_UPDATE)) { + if (($ncPermissions & Constants::PERMISSION_CREATE) + || ($ncPermissions & Constants::PERMISSION_UPDATE)) { $ocmPermissions[] = 'write'; } @@ -554,7 +559,7 @@ class FilesPlugin extends ServerPlugin { */ public function handleUpdateProperties($path, PropPatch $propPatch) { $node = $this->tree->getNodeForPath($path); - if (!($node instanceof \OCA\DAV\Connector\Sabre\Node)) { + if (!($node instanceof Node)) { return; } @@ -569,10 +574,7 @@ class FilesPlugin extends ServerPlugin { if (empty($etag)) { return false; } - if ($node->setEtag($etag) !== -1) { - return true; - } - return false; + return $node->setEtag($etag) !== -1; }); $propPatch->handle(self::CREATIONDATE_PROPERTYNAME, function ($time) use ($node) { if (empty($time)) { @@ -586,36 +588,154 @@ class FilesPlugin extends ServerPlugin { if (empty($time)) { return false; } - $node->setCreationTime((int) $time); + $node->setCreationTime((int)$time); return true; }); + + $this->handleUpdatePropertiesMetadata($propPatch, $node); + + /** + * Disable modification of the displayname property for files and + * folders via PROPPATCH. See PROPFIND for more information. + */ + $propPatch->handle(self::DISPLAYNAME_PROPERTYNAME, function ($displayName) { + return 403; + }); } + /** - * @param string $filePath - * @param \Sabre\DAV\INode $node - * @throws \Sabre\DAV\Exception\BadRequest + * handle the update of metadata from PROPPATCH requests + * + * @param PropPatch $propPatch + * @param Node $node + * + * @throws FilesMetadataException */ - public function sendFileIdHeader($filePath, \Sabre\DAV\INode $node = null) { - // chunked upload handling - if (isset($_SERVER['HTTP_OC_CHUNKED'])) { - [$path, $name] = \Sabre\Uri\split($filePath); - $info = \OC_FileChunking::decodeName($name); - if (!empty($info)) { - $filePath = $path . '/' . $info['name']; + private function handleUpdatePropertiesMetadata(PropPatch $propPatch, Node $node): void { + $userId = $this->userSession->getUser()?->getUID(); + if ($userId === null) { + return; + } + + $accessRight = $this->getMetadataFileAccessRight($node, $userId); + $filesMetadataManager = $this->initFilesMetadataManager(); + $knownMetadata = $filesMetadataManager->getKnownMetadata(); + + foreach ($propPatch->getRemainingMutations() as $mutation) { + if (!str_starts_with($mutation, self::FILE_METADATA_PREFIX)) { + continue; } + + $propPatch->handle( + $mutation, + function (mixed $value) use ($accessRight, $knownMetadata, $node, $mutation, $filesMetadataManager): bool { + /** @var FilesMetadata $metadata */ + $metadata = $filesMetadataManager->getMetadata((int)$node->getFileId(), true); + $metadata->setStorageId($node->getNode()->getStorage()->getCache()->getNumericStorageId()); + $metadataKey = substr($mutation, strlen(self::FILE_METADATA_PREFIX)); + + // confirm metadata key is editable via PROPPATCH + if ($knownMetadata->getEditPermission($metadataKey) < $accessRight) { + throw new FilesMetadataException('you do not have enough rights to update \'' . $metadataKey . '\' on this node'); + } + + if ($value === null) { + $metadata->unset($metadataKey); + $filesMetadataManager->saveMetadata($metadata); + return true; + } + + // If the metadata is unknown, it defaults to string. + try { + $type = $knownMetadata->getType($metadataKey); + } catch (FilesMetadataNotFoundException) { + $type = IMetadataValueWrapper::TYPE_STRING; + } + + switch ($type) { + case IMetadataValueWrapper::TYPE_STRING: + $metadata->setString($metadataKey, $value, $knownMetadata->isIndex($metadataKey)); + break; + case IMetadataValueWrapper::TYPE_INT: + $metadata->setInt($metadataKey, $value, $knownMetadata->isIndex($metadataKey)); + break; + case IMetadataValueWrapper::TYPE_FLOAT: + $metadata->setFloat($metadataKey, $value); + break; + case IMetadataValueWrapper::TYPE_BOOL: + $metadata->setBool($metadataKey, $value, $knownMetadata->isIndex($metadataKey)); + break; + case IMetadataValueWrapper::TYPE_ARRAY: + $metadata->setArray($metadataKey, $value); + break; + case IMetadataValueWrapper::TYPE_STRING_LIST: + $metadata->setStringList($metadataKey, $value, $knownMetadata->isIndex($metadataKey)); + break; + case IMetadataValueWrapper::TYPE_INT_LIST: + $metadata->setIntList($metadataKey, $value, $knownMetadata->isIndex($metadataKey)); + break; + } + + $filesMetadataManager->saveMetadata($metadata); + + return true; + } + ); } + } - // we get the node for the given $filePath here because in case of afterCreateFile $node is the parent folder - if (!$this->server->tree->nodeExists($filePath)) { - return; + /** + * init default internal metadata + * + * @return IFilesMetadataManager + */ + private function initFilesMetadataManager(): IFilesMetadataManager { + /** @var IFilesMetadataManager $manager */ + $manager = \OCP\Server::get(IFilesMetadataManager::class); + $manager->initMetadata('files-live-photo', IMetadataValueWrapper::TYPE_STRING, false, IMetadataValueWrapper::EDIT_REQ_OWNERSHIP); + + return $manager; + } + + /** + * based on owner and shares, returns the bottom limit to update related metadata + * + * @param Node $node + * @param string $userId + * + * @return int + */ + private function getMetadataFileAccessRight(Node $node, string $userId): int { + if ($node->getOwner()?->getUID() === $userId) { + return IMetadataValueWrapper::EDIT_REQ_OWNERSHIP; + } else { + $filePermissions = $node->getSharePermissions($userId); + if ($filePermissions & Constants::PERMISSION_UPDATE) { + return IMetadataValueWrapper::EDIT_REQ_WRITE_PERMISSION; + } } - $node = $this->server->tree->getNodeForPath($filePath); - if ($node instanceof \OCA\DAV\Connector\Sabre\Node) { - $fileId = $node->getFileId(); - if (!is_null($fileId)) { - $this->server->httpResponse->setHeader('OC-FileId', $fileId); + + return IMetadataValueWrapper::EDIT_REQ_READ_PERMISSION; + } + + /** + * @param string $filePath + * @param ?\Sabre\DAV\INode $node + * @return void + * @throws \Sabre\DAV\Exception\BadRequest + */ + public function sendFileIdHeader($filePath, ?\Sabre\DAV\INode $node = null) { + // we get the node for the given $filePath here because in case of afterCreateFile $node is the parent folder + try { + $node = $this->server->tree->getNodeForPath($filePath); + if ($node instanceof Node) { + $fileId = $node->getFileId(); + if (!is_null($fileId)) { + $this->server->httpResponse->setHeader('OC-FileId', $fileId); + } } + } catch (NotFound) { } } } diff --git a/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php b/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php index 4876e9ad8f3..b59d1373af5 100644 --- a/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php @@ -1,35 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use OC\Files\View; +use OCA\Circles\Api\v1\Circles; use OCP\App\IAppManager; +use OCP\AppFramework\Http; use OCP\Files\Folder; +use OCP\Files\Node as INode; use OCP\IGroupManager; use OCP\ITagManager; use OCP\IUserSession; @@ -45,9 +28,9 @@ use Sabre\DAV\Xml\Element\Response; use Sabre\DAV\Xml\Response\MultiStatus; class FilesReportPlugin extends ServerPlugin { - // namespace public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; public const REPORT_NAME = '{http://owncloud.org/ns}filter-files'; public const SYSTEMTAG_PROPERTYNAME = '{http://owncloud.org/ns}systemtag'; public const CIRCLE_PROPERTYNAME = '{http://owncloud.org/ns}circle'; @@ -60,55 +43,8 @@ class FilesReportPlugin extends ServerPlugin { private $server; /** - * @var Tree - */ - private $tree; - - /** - * @var View - */ - private $fileView; - - /** - * @var ISystemTagManager - */ - private $tagManager; - - /** - * @var ISystemTagObjectMapper - */ - private $tagMapper; - - /** - * Manager for private tags - * - * @var ITagManager - */ - private $fileTagger; - - /** - * @var IUserSession - */ - private $userSession; - - /** - * @var IGroupManager - */ - private $groupManager; - - /** - * @var Folder - */ - private $userFolder; - - /** - * @var IAppManager - */ - private $appManager; - - /** * @param Tree $tree - * @param View $view + * @param View $fileView * @param ISystemTagManager $tagManager * @param ISystemTagObjectMapper $tagMapper * @param ITagManager $fileTagger manager for private tags @@ -117,25 +53,20 @@ class FilesReportPlugin extends ServerPlugin { * @param Folder $userFolder * @param IAppManager $appManager */ - public function __construct(Tree $tree, - View $view, - ISystemTagManager $tagManager, - ISystemTagObjectMapper $tagMapper, - ITagManager $fileTagger, - IUserSession $userSession, - IGroupManager $groupManager, - Folder $userFolder, - IAppManager $appManager + public function __construct( + private Tree $tree, + private View $fileView, + private ISystemTagManager $tagManager, + private ISystemTagObjectMapper $tagMapper, + /** + * Manager for private tags + */ + private ITagManager $fileTagger, + private IUserSession $userSession, + private IGroupManager $groupManager, + private Folder $userFolder, + private IAppManager $appManager, ) { - $this->tree = $tree; - $this->fileView = $view; - $this->tagManager = $tagManager; - $this->tagMapper = $tagMapper; - $this->fileTagger = $fileTagger; - $this->userSession = $userSession; - $this->groupManager = $groupManager; - $this->userFolder = $userFolder; - $this->appManager = $appManager; } /** @@ -186,6 +117,7 @@ class FilesReportPlugin extends ServerPlugin { } $ns = '{' . $this::NS_OWNCLOUD . '}'; + $ncns = '{' . $this::NS_NEXTCLOUD . '}'; $requestedProps = []; $filterRules = []; @@ -199,6 +131,14 @@ class FilesReportPlugin extends ServerPlugin { foreach ($reportProps['value'] as $propVal) { $requestedProps[] = $propVal['name']; } + } elseif ($name === '{DAV:}limit') { + foreach ($reportProps['value'] as $propVal) { + if ($propVal['name'] === '{DAV:}nresults') { + $limit = (int)$propVal['value']; + } elseif ($propVal['name'] === $ncns . 'firstresult') { + $offset = (int)$propVal['value']; + } + } } } @@ -209,13 +149,32 @@ class FilesReportPlugin extends ServerPlugin { // gather all file ids matching filter try { - $resultFileIds = $this->processFilterRules($filterRules); + $resultFileIds = $this->processFilterRulesForFileIDs($filterRules); + // no logic in circles and favorites for paging, we always have all results, and slice later on + $resultFileIds = array_slice($resultFileIds, $offset ?? 0, $limit ?? null); + // fetching nodes has paging on DB level – therefore we cannot mix and slice the results, similar + // to user backends. I.e. the final result may return more results than requested. + $resultNodes = $this->processFilterRulesForFileNodes($filterRules, $limit ?? null, $offset ?? null); } catch (TagNotFoundException $e) { - throw new PreconditionFailed('Cannot filter by non-existing tag', 0, $e); + throw new PreconditionFailed('Cannot filter by non-existing tag'); + } + + $results = []; + foreach ($resultNodes as $entry) { + if ($entry) { + $results[] = $this->wrapNode($entry); + } } // find sabre nodes by file id, restricted to the root node path - $results = $this->findNodesByFileIds($reportTargetNode, $resultFileIds); + $additionalNodes = $this->findNodesByFileIds($reportTargetNode, $resultFileIds); + if ($additionalNodes && $results) { + $results = array_uintersect($results, $additionalNodes, function (Node $a, Node $b): int { + return $a->getId() - $b->getId(); + }); + } elseif (!$results && $additionalNodes) { + $results = $additionalNodes; + } $filesUri = $this->getFilesBaseUri($uri, $reportTargetNode->getPath()); $responses = $this->prepareResponses($filesUri, $requestedProps, $results); @@ -225,7 +184,7 @@ class FilesReportPlugin extends ServerPlugin { new MultiStatus($responses) ); - $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->setBody($xml); @@ -261,19 +220,13 @@ class FilesReportPlugin extends ServerPlugin { * * @param array $filterRules * @return array array of unique file id results - * - * @throws TagNotFoundException whenever a tag was not found */ - protected function processFilterRules($filterRules) { + protected function processFilterRulesForFileIDs(array $filterRules): array { $ns = '{' . $this::NS_OWNCLOUD . '}'; - $resultFileIds = null; - $systemTagIds = []; + $resultFileIds = []; $circlesIds = []; $favoriteFilter = null; foreach ($filterRules as $filterRule) { - if ($filterRule['name'] === $ns . 'systemtag') { - $systemTagIds[] = $filterRule['value']; - } if ($filterRule['name'] === self::CIRCLE_PROPERTYNAME) { $circlesIds[] = $filterRule['value']; } @@ -289,15 +242,6 @@ class FilesReportPlugin extends ServerPlugin { } } - if (!empty($systemTagIds)) { - $fileIds = $this->getSystemTagFileIds($systemTagIds); - if (empty($resultFileIds)) { - $resultFileIds = $fileIds; - } else { - $resultFileIds = array_intersect($fileIds, $resultFileIds); - } - } - if (!empty($circlesIds)) { $fileIds = $this->getCirclesFileIds($circlesIds); if (empty($resultFileIds)) { @@ -310,47 +254,46 @@ class FilesReportPlugin extends ServerPlugin { return $resultFileIds; } - private function getSystemTagFileIds($systemTagIds) { - $resultFileIds = null; - - // check user permissions, if applicable - if (!$this->isAdmin()) { - // check visibility/permission - $tags = $this->tagManager->getTagsByIds($systemTagIds); - $unknownTagIds = []; - foreach ($tags as $tag) { - if (!$tag->isUserVisible()) { - $unknownTagIds[] = $tag->getId(); - } - } - - if (!empty($unknownTagIds)) { - throw new TagNotFoundException('Tag with ids ' . implode(', ', $unknownTagIds) . ' not found'); + protected function processFilterRulesForFileNodes(array $filterRules, ?int $limit, ?int $offset): array { + $systemTagIds = []; + foreach ($filterRules as $filterRule) { + if ($filterRule['name'] === self::SYSTEMTAG_PROPERTYNAME) { + $systemTagIds[] = $filterRule['value']; } } - // fetch all file ids and intersect them - foreach ($systemTagIds as $systemTagId) { - $fileIds = $this->tagMapper->getObjectIdsForTags($systemTagId, 'files'); + $nodes = []; - if (empty($fileIds)) { - // This tag has no files, nothing can ever show up - return []; - } + if (!empty($systemTagIds)) { + $tags = $this->tagManager->getTagsByIds($systemTagIds, $this->userSession->getUser()); - // first run ? - if ($resultFileIds === null) { - $resultFileIds = $fileIds; - } else { - $resultFileIds = array_intersect($resultFileIds, $fileIds); + // For we run DB queries per tag and require intersection, we cannot apply limit and offset for DB queries on multi tag search. + $oneTagSearch = count($tags) === 1; + $dbLimit = $oneTagSearch ? $limit ?? 0 : 0; + $dbOffset = $oneTagSearch ? $offset ?? 0 : 0; + + foreach ($tags as $tag) { + $tagName = $tag->getName(); + $tmpNodes = $this->userFolder->searchBySystemTag($tagName, $this->userSession->getUser()->getUID(), $dbLimit, $dbOffset); + if (count($nodes) === 0) { + $nodes = $tmpNodes; + } else { + $nodes = array_uintersect($nodes, $tmpNodes, function (INode $a, INode $b): int { + return $a->getId() - $b->getId(); + }); + } + if ($nodes === []) { + // there cannot be a common match when nodes are empty early. + return $nodes; + } } - if (empty($resultFileIds)) { - // Empty intersection, nothing can show up anymore - return []; + if (!$oneTagSearch && ($limit !== null || $offset !== null)) { + $nodes = array_slice($nodes, $offset, $limit); } } - return $resultFileIds; + + return $nodes; } /** @@ -362,7 +305,7 @@ class FilesReportPlugin extends ServerPlugin { if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) { return []; } - return \OCA\Circles\Api\v1\Circles::getFilesForCircles($circlesIds); + return Circles::getFilesForCircles($circlesIds); } @@ -370,7 +313,7 @@ class FilesReportPlugin extends ServerPlugin { * Prepare propfind response for the given nodes * * @param string $filesUri $filesUri URI leading to root of the files URI, - * with a leading slash but no trailing slash + * with a leading slash but no trailing slash * @param string[] $requestedProps requested properties * @param Node[] nodes nodes for which to fetch and prepare responses * @return Response[] @@ -393,7 +336,6 @@ class FilesReportPlugin extends ServerPlugin { $responses[] = new Response( rtrim($this->server->getBaseUri(), '/') . $filesUri . $node->getPath(), $result, - 200 ); } return $responses; @@ -406,28 +348,35 @@ class FilesReportPlugin extends ServerPlugin { * @param array $fileIds file ids * @return Node[] array of Sabre nodes */ - public function findNodesByFileIds($rootNode, $fileIds) { + public function findNodesByFileIds(Node $rootNode, array $fileIds): array { + if (empty($fileIds)) { + return []; + } $folder = $this->userFolder; if (trim($rootNode->getPath(), '/') !== '') { + /** @var Folder $folder */ $folder = $folder->get($rootNode->getPath()); } $results = []; foreach ($fileIds as $fileId) { - $entry = $folder->getById($fileId); + $entry = $folder->getFirstNodeById((int)$fileId); if ($entry) { - $entry = current($entry); - if ($entry instanceof \OCP\Files\File) { - $results[] = new File($this->fileView, $entry); - } elseif ($entry instanceof \OCP\Files\Folder) { - $results[] = new Directory($this->fileView, $entry); - } + $results[] = $this->wrapNode($entry); } } return $results; } + protected function wrapNode(INode $node): File|Directory { + if ($node instanceof \OCP\Files\File) { + return new File($this->fileView, $node); + } else { + return new Directory($this->fileView, $node); + } + } + /** * Returns whether the currently logged in user is an administrator */ diff --git a/apps/dav/lib/Connector/Sabre/LockPlugin.php b/apps/dav/lib/Connector/Sabre/LockPlugin.php index 6305b0ec138..6640771dc31 100644 --- a/apps/dav/lib/Connector/Sabre/LockPlugin.php +++ b/apps/dav/lib/Connector/Sabre/LockPlugin.php @@ -1,28 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Jaakko Salo <jaakkos@gmail.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Stefan Weil <sw@weilnetz.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-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -61,7 +42,7 @@ class LockPlugin extends ServerPlugin { public function getLock(RequestInterface $request) { // we can't listen on 'beforeMethod:PUT' due to order of operations with setting up the tree // so instead we limit ourselves to the PUT method manually - if ($request->getMethod() !== 'PUT' || isset($_SERVER['HTTP_OC_CHUNKED'])) { + if ($request->getMethod() !== 'PUT') { return; } try { @@ -84,7 +65,7 @@ class LockPlugin extends ServerPlugin { if ($this->isLocked === false) { return; } - if ($request->getMethod() !== 'PUT' || isset($_SERVER['HTTP_OC_CHUNKED'])) { + if ($request->getMethod() !== 'PUT') { return; } try { diff --git a/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php b/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php index e7e3b273b98..d5ab7f09dfa 100644 --- a/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php +++ b/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php @@ -1,32 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Valdnet <47037905+Valdnet@users.noreply.github.com> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; +use OCA\DAV\Exception\ServerMaintenanceMode; use OCP\IConfig; use OCP\IL10N; use OCP\Util; @@ -35,10 +16,7 @@ use Sabre\DAV\ServerPlugin; class MaintenancePlugin extends ServerPlugin { - /** @var IConfig */ - private $config; - - /** @var \OCP\IL10N */ + /** @var IL10N */ private $l10n; /** @@ -51,8 +29,10 @@ class MaintenancePlugin extends ServerPlugin { /** * @param IConfig $config */ - public function __construct(IConfig $config, IL10N $l10n) { - $this->config = $config; + public function __construct( + private IConfig $config, + IL10N $l10n, + ) { $this->l10n = \OC::$server->getL10N('dav'); } @@ -82,10 +62,10 @@ class MaintenancePlugin extends ServerPlugin { */ public function checkMaintenanceMode() { if ($this->config->getSystemValueBool('maintenance')) { - throw new ServiceUnavailable($this->l10n->t('System is in maintenance mode.')); + throw new ServerMaintenanceMode($this->l10n->t('System is in maintenance mode.')); } if (Util::needUpgrade()) { - throw new ServiceUnavailable($this->l10n->t('Upgrade needed')); + throw new ServerMaintenanceMode($this->l10n->t('Upgrade needed')); } return true; diff --git a/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php b/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php index 6700b1eb81b..e18ef58149a 100644 --- a/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php +++ b/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php @@ -1,23 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me> - * - * @author Louis Chemineau <louis@chmn.me> - * - * @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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; diff --git a/apps/dav/lib/Connector/Sabre/Node.php b/apps/dav/lib/Connector/Sabre/Node.php index e4517068f42..505e6b5eda4 100644 --- a/apps/dav/lib/Connector/Sabre/Node.php +++ b/apps/dav/lib/Connector/Sabre/Node.php @@ -1,37 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Klaas Freitag <freitag@owncloud.com> - * @author Markus Goetz <markus@woboq.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -40,20 +12,19 @@ use OC\Files\Node\File; use OC\Files\Node\Folder; use OC\Files\View; use OCA\DAV\Connector\Sabre\Exception\InvalidPath; +use OCP\Constants; +use OCP\Files\DavUtil; use OCP\Files\FileInfo; +use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\ISharedStorage; use OCP\Files\StorageNotAvailableException; -use OCP\Share\IShare; +use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; abstract class Node implements \Sabre\DAV\INode { - - /** - * @var \OC\Files\View - */ - protected $fileView; - /** * The path to the current node * @@ -68,10 +39,7 @@ abstract class Node implements \Sabre\DAV\INode { */ protected $property_cache = null; - /** - * @var \OCP\Files\FileInfo - */ - protected $info; + protected FileInfo $info; /** * @var IManager @@ -82,39 +50,45 @@ abstract class Node implements \Sabre\DAV\INode { /** * Sets up the node, expects a full path name - * - * @param \OC\Files\View $view - * @param \OCP\Files\FileInfo $info - * @param IManager $shareManager */ - public function __construct(View $view, FileInfo $info, IManager $shareManager = null) { - $this->fileView = $view; + public function __construct( + protected View $fileView, + FileInfo $info, + ?IManager $shareManager = null, + ) { $this->path = $this->fileView->getRelativePath($info->getPath()); $this->info = $info; if ($shareManager) { $this->shareManager = $shareManager; } else { - $this->shareManager = \OC::$server->getShareManager(); + $this->shareManager = Server::get(\OCP\Share\IManager::class); } if ($info instanceof Folder || $info instanceof File) { $this->node = $info; } else { - $root = \OC::$server->get(IRootFolder::class); + // The Node API assumes that the view passed doesn't have a fake root + $rootView = Server::get(View::class); + $root = Server::get(IRootFolder::class); if ($info->getType() === FileInfo::TYPE_FOLDER) { - $this->node = new Folder($root, $view, $this->path, $info); + $this->node = new Folder($root, $rootView, $this->fileView->getAbsolutePath($this->path), $info); } else { - $this->node = new File($root, $view, $this->path, $info); + $this->node = new File($root, $rootView, $this->fileView->getAbsolutePath($this->path), $info); } } } - protected function refreshInfo() { - $this->info = $this->fileView->getFileInfo($this->path); - $root = \OC::$server->get(IRootFolder::class); + protected function refreshInfo(): void { + $info = $this->fileView->getFileInfo($this->path); + if ($info === false) { + throw new \Sabre\DAV\Exception('Failed to get fileinfo for ' . $this->path); + } + $this->info = $info; + $root = Server::get(IRootFolder::class); + $rootView = Server::get(View::class); if ($this->info->getType() === FileInfo::TYPE_FOLDER) { - $this->node = new Folder($root, $this->fileView, $this->path, $this->info); + $this->node = new Folder($root, $rootView, $this->path, $this->info); } else { - $this->node = new File($root, $this->fileView, $this->path, $this->info); + $this->node = new File($root, $rootView, $this->path, $this->info); } } @@ -144,22 +118,21 @@ abstract class Node implements \Sabre\DAV\INode { * @throws \Sabre\DAV\Exception\Forbidden */ public function setName($name) { - - // rename is only allowed if the update privilege is granted - if (!($this->info->isUpdateable() || ($this->info->getMountPoint() instanceof MoveableMount && $this->info->getInternalPath() === ''))) { + // rename is only allowed if the delete privilege is granted + // (basically rename is a copy with delete of the original node) + if (!($this->info->isDeletable() || ($this->info->getMountPoint() instanceof MoveableMount && $this->info->getInternalPath() === ''))) { throw new \Sabre\DAV\Exception\Forbidden(); } [$parentPath,] = \Sabre\Uri\split($this->path); [, $newName] = \Sabre\Uri\split($name); + $newPath = $parentPath . '/' . $newName; // verify path of the target - $this->verifyPath(); - - $newPath = $parentPath . '/' . $newName; + $this->verifyPath($newPath); if (!$this->fileView->rename($this->path, $newPath)) { - throw new \Sabre\DAV\Exception('Failed to rename '. $this->path . ' to ' . $newPath); + throw new \Sabre\DAV\Exception('Failed to rename ' . $this->path . ' to ' . $newPath); } $this->path = $newPath; @@ -232,9 +205,10 @@ abstract class Node implements \Sabre\DAV\INode { /** * Returns the size of the node, in bytes * - * @return integer + * @psalm-suppress ImplementedReturnTypeMismatch \Sabre\DAV\IFile::getSize signature does not support 32bit + * @return int|float */ - public function getSize() { + public function getSize(): int|float { return $this->info->getSize(); } @@ -251,10 +225,8 @@ abstract class Node implements \Sabre\DAV\INode { * @return string|null */ public function getFileId() { - if ($this->info->getId()) { - $instanceId = \OC_Util::getInstanceId(); - $id = sprintf('%08d', $this->info->getId()); - return $id . $instanceId; + if ($id = $this->info->getId()) { + return DavUtil::getDavFileId($id); } return null; @@ -267,12 +239,15 @@ abstract class Node implements \Sabre\DAV\INode { return $this->info->getId(); } + public function getInternalPath(): string { + return $this->info->getInternalPath(); + } + /** * @param string $user * @return int */ public function getSharePermissions($user) { - // check of we access a federated share if ($user !== null) { try { @@ -289,8 +264,8 @@ abstract class Node implements \Sabre\DAV\INode { $storage = null; } - if ($storage && $storage->instanceOfStorage('\OCA\Files_Sharing\SharedStorage')) { - /** @var \OCA\Files_Sharing\SharedStorage $storage */ + if ($storage && $storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ $permissions = (int)$storage->getShare()->getPermissions(); } else { $permissions = $this->info->getPermissions(); @@ -303,98 +278,88 @@ abstract class Node implements \Sabre\DAV\INode { $mountpoint = $this->info->getMountPoint(); if (!($mountpoint instanceof MoveableMount)) { $mountpointpath = $mountpoint->getMountPoint(); - if (substr($mountpointpath, -1) === '/') { + if (str_ends_with($mountpointpath, '/')) { $mountpointpath = substr($mountpointpath, 0, -1); } if (!$mountpoint->getOption('readonly', false) && $mountpointpath === $this->info->getPath()) { - $permissions |= \OCP\Constants::PERMISSION_DELETE | \OCP\Constants::PERMISSION_UPDATE; + $permissions |= Constants::PERMISSION_DELETE | Constants::PERMISSION_UPDATE; } } /* * Files can't have create or delete permissions */ - if ($this->info->getType() === \OCP\Files\FileInfo::TYPE_FILE) { - $permissions &= ~(\OCP\Constants::PERMISSION_CREATE | \OCP\Constants::PERMISSION_DELETE); + if ($this->info->getType() === FileInfo::TYPE_FILE) { + $permissions &= ~(Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE); } return $permissions; } /** - * @param string $user - * @return string + * @return array */ - public function getNoteFromShare($user) { - if ($user === null) { - return ''; + public function getShareAttributes(): array { + try { + $storage = $this->node->getStorage(); + } catch (NotFoundException $e) { + return []; } - $types = [ - IShare::TYPE_USER, - IShare::TYPE_GROUP, - IShare::TYPE_CIRCLE, - IShare::TYPE_ROOM - ]; - - foreach ($types as $shareType) { - $shares = $this->shareManager->getSharedWith($user, $shareType, $this, -1); - foreach ($shares as $share) { - $note = $share->getNote(); - if ($share->getShareOwner() !== $user && !empty($note)) { - return $note; - } + $attributes = []; + if ($storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ + $attributes = $storage->getShare()->getAttributes(); + if ($attributes === null) { + return []; + } else { + return $attributes->toArray(); + } + } + + return $attributes; + } + + public function getNoteFromShare(?string $user): ?string { + try { + $storage = $this->node->getStorage(); + } catch (NotFoundException) { + return null; + } + + if ($storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ + $share = $storage->getShare(); + if ($user === $share->getShareOwner()) { + // Note is only for recipient not the owner + return null; } + return $share->getNote(); } - return ''; + return null; } /** * @return string */ public function getDavPermissions() { - $p = ''; - if ($this->info->isShared()) { - $p .= 'S'; - } - if ($this->info->isShareable()) { - $p .= 'R'; - } - if ($this->info->isMounted()) { - $p .= 'M'; - } - if ($this->info->isReadable()) { - $p .= 'G'; - } - if ($this->info->isDeletable()) { - $p .= 'D'; - } - if ($this->info->isUpdateable()) { - $p .= 'NV'; // Renameable, Moveable - } - if ($this->info->getType() === \OCP\Files\FileInfo::TYPE_FILE) { - if ($this->info->isUpdateable()) { - $p .= 'W'; - } - } else { - if ($this->info->isCreatable()) { - $p .= 'CK'; - } - } - return $p; + return DavUtil::getDavPermissions($this->info); } public function getOwner() { return $this->info->getOwner(); } - protected function verifyPath() { + protected function verifyPath(?string $path = null): void { try { - $fileName = basename($this->info->getPath()); - $this->fileView->verifyPath($this->path, $fileName); - } catch (\OCP\Files\InvalidPathException $ex) { + $path = $path ?? $this->info->getPath(); + $this->fileView->verifyPath( + dirname($path), + basename($path), + ); + } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage()); } } @@ -428,7 +393,7 @@ abstract class Node implements \Sabre\DAV\INode { return $this->node; } - protected function sanitizeMtime($mtimeFromRequest) { + protected function sanitizeMtime(string $mtimeFromRequest): int { return MtimeSanitizer::sanitizeMtime($mtimeFromRequest); } } diff --git a/apps/dav/lib/Connector/Sabre/ObjectTree.php b/apps/dav/lib/Connector/Sabre/ObjectTree.php index c129371e376..bfbdfb33db0 100644 --- a/apps/dav/lib/Connector/Sabre/ObjectTree.php +++ b/apps/dav/lib/Connector/Sabre/ObjectTree.php @@ -1,39 +1,22 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use OC\Files\FileInfo; use OC\Files\Storage\FailedStorage; +use OC\Files\Storage\Storage; +use OC\Files\View; use OCA\DAV\Connector\Sabre\Exception\FileLocked; use OCA\DAV\Connector\Sabre\Exception\Forbidden; use OCA\DAV\Connector\Sabre\Exception\InvalidPath; use OCP\Files\ForbiddenException; +use OCP\Files\InvalidPathException; +use OCP\Files\Mount\IMountManager; use OCP\Files\StorageInvalidException; use OCP\Files\StorageNotAvailableException; use OCP\Lock\LockedException; @@ -41,12 +24,12 @@ use OCP\Lock\LockedException; class ObjectTree extends CachingTree { /** - * @var \OC\Files\View + * @var View */ protected $fileView; /** - * @var \OCP\Files\Mount\IMountManager + * @var IMountManager */ protected $mountManager; @@ -58,45 +41,16 @@ class ObjectTree extends CachingTree { /** * @param \Sabre\DAV\INode $rootNode - * @param \OC\Files\View $view - * @param \OCP\Files\Mount\IMountManager $mountManager + * @param View $view + * @param IMountManager $mountManager */ - public function init(\Sabre\DAV\INode $rootNode, \OC\Files\View $view, \OCP\Files\Mount\IMountManager $mountManager) { + public function init(\Sabre\DAV\INode $rootNode, View $view, IMountManager $mountManager) { $this->rootNode = $rootNode; $this->fileView = $view; $this->mountManager = $mountManager; } /** - * If the given path is a chunked file name, converts it - * to the real file name. Only applies if the OC-CHUNKED header - * is present. - * - * @param string $path chunk file path to convert - * - * @return string path to real file - */ - private function resolveChunkFile($path) { - if (isset($_SERVER['HTTP_OC_CHUNKED'])) { - // resolve to real file name to find the proper node - [$dir, $name] = \Sabre\Uri\split($path); - if ($dir === '/' || $dir === '.') { - $dir = ''; - } - - $info = \OC_FileChunking::decodeName($name); - // only replace path if it was really the chunked file - if (isset($info['transferid'])) { - // getNodePath is called for multiple nodes within a chunk - // upload call - $path = $dir . '/' . $info['name']; - $path = ltrim($path, '/'); - } - } - return $path; - } - - /** * Returns the INode object for the requested path * * @param string $path @@ -120,7 +74,7 @@ class ObjectTree extends CachingTree { if ($path) { try { $this->fileView->verifyPath($path, basename($path)); - } catch (\OCP\Files\InvalidPathException $ex) { + } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage()); } } @@ -138,7 +92,7 @@ class ObjectTree extends CachingTree { $internalPath = $mount->getInternalPath($absPath); if ($storage && $storage->file_exists($internalPath)) { /** - * @var \OC\Files\Storage\Storage $storage + * @var Storage $storage */ // get data directly $data = $storage->getMetaData($internalPath); @@ -147,9 +101,6 @@ class ObjectTree extends CachingTree { $info = null; } } else { - // resolve chunk file name to real name, if applicable - $path = $this->resolveChunkFile($path); - // read from cache try { $info = $this->fileView->getFileInfo($path); @@ -173,9 +124,9 @@ class ObjectTree extends CachingTree { } if ($info->getType() === 'dir') { - $node = new \OCA\DAV\Connector\Sabre\Directory($this->fileView, $info, $this); + $node = new Directory($this->fileView, $info, $this); } else { - $node = new \OCA\DAV\Connector\Sabre\File($this->fileView, $info); + $node = new File($this->fileView, $info); } $this->cache[$path] = $node; @@ -222,7 +173,7 @@ class ObjectTree extends CachingTree { [$destinationDir, $destinationName] = \Sabre\Uri\split($destinationPath); try { $this->fileView->verifyPath($destinationDir, $destinationName); - } catch (\OCP\Files\InvalidPathException $ex) { + } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage()); } diff --git a/apps/dav/lib/Connector/Sabre/Principal.php b/apps/dav/lib/Connector/Sabre/Principal.php index c3f06f95783..d6ea9fd887d 100644 --- a/apps/dav/lib/Connector/Sabre/Principal.php +++ b/apps/dav/lib/Connector/Sabre/Principal.php @@ -1,46 +1,21 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2018, Georg Ehrke - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Christoph Seitz <christoph.seitz@posteo.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Maxence Lange <maxence@artificial-owl.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use OC\KnownUser\KnownUserService; +use OCA\Circles\Api\v1\Circles; use OCA\Circles\Exceptions\CircleNotFoundException; +use OCA\Circles\Model\Circle; use OCA\DAV\CalDAV\Proxy\ProxyMapper; use OCA\DAV\Traits\PrincipalProxyTrait; +use OCP\Accounts\IAccountManager; +use OCP\Accounts\IAccountProperty; +use OCP\Accounts\PropertyDoesNotExistException; use OCP\App\IAppManager; use OCP\AppFramework\QueryException; use OCP\Constants; @@ -58,21 +33,6 @@ use Sabre\DAVACL\PrincipalBackend\BackendInterface; class Principal implements BackendInterface { - /** @var IUserManager */ - private $userManager; - - /** @var IGroupManager */ - private $groupManager; - - /** @var IShareManager */ - private $shareManager; - - /** @var IUserSession */ - private $userSession; - - /** @var IAppManager */ - private $appManager; - /** @var string */ private $principalPrefix; @@ -82,38 +42,25 @@ class Principal implements BackendInterface { /** @var bool */ private $hasCircles; - /** @var ProxyMapper */ - private $proxyMapper; - /** @var KnownUserService */ private $knownUserService; - /** @var IConfig */ - private $config; - /** @var IFactory */ - private $languageFactory; - - public function __construct(IUserManager $userManager, - IGroupManager $groupManager, - IShareManager $shareManager, - IUserSession $userSession, - IAppManager $appManager, - ProxyMapper $proxyMapper, - KnownUserService $knownUserService, - IConfig $config, - IFactory $languageFactory, - string $principalPrefix = 'principals/users/') { - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->shareManager = $shareManager; - $this->userSession = $userSession; - $this->appManager = $appManager; + public function __construct( + private IUserManager $userManager, + private IGroupManager $groupManager, + private IAccountManager $accountManager, + private IShareManager $shareManager, + private IUserSession $userSession, + private IAppManager $appManager, + private ProxyMapper $proxyMapper, + KnownUserService $knownUserService, + private IConfig $config, + private IFactory $languageFactory, + string $principalPrefix = 'principals/users/', + ) { $this->principalPrefix = trim($principalPrefix, '/'); $this->hasGroups = $this->hasCircles = ($principalPrefix === 'principals/users/'); - $this->proxyMapper = $proxyMapper; $this->knownUserService = $knownUserService; - $this->config = $config; - $this->languageFactory = $languageFactory; } use PrincipalProxyTrait { @@ -200,6 +147,16 @@ class Principal implements BackendInterface { '{DAV:}displayname' => $group->getDisplayName(), ]; } + } elseif ($prefix === 'principals/system') { + return [ + 'uri' => 'principals/system/' . $name, + '{DAV:}displayname' => $this->languageFactory->get('dav')->t('Accounts'), + ]; + } elseif ($prefix === 'principals/shares') { + return [ + 'uri' => 'principals/shares/' . $name, + '{DAV:}displayname' => $name, + ]; } return null; } @@ -229,6 +186,9 @@ class Principal implements BackendInterface { if ($this->hasGroups || $needGroups) { $userGroups = $this->groupManager->getUserGroups($user); foreach ($userGroups as $userGroup) { + if ($userGroup->hideFromCollaboration()) { + continue; + } $groups[] = 'principals/groups/' . urlencode($userGroup->getGID()); } } @@ -247,6 +207,7 @@ class Principal implements BackendInterface { * @return int */ public function updatePrincipal($path, PropPatch $propPatch) { + // Updating schedule-default-calendar-URL is handled in CustomPropertiesBackend return 0; } @@ -270,6 +231,8 @@ class Principal implements BackendInterface { $limitEnumerationGroup = $this->shareManager->limitEnumerationToGroups(); $limitEnumerationPhone = $this->shareManager->limitEnumerationToPhone(); $allowEnumerationFullMatch = $this->shareManager->allowEnumerationFullMatch(); + $ignoreSecondDisplayName = $this->shareManager->ignoreSecondDisplayName(); + $matchEmail = $this->shareManager->matchEmail(); // If sharing is restricted to group members only, // return only members that have groups in common @@ -298,7 +261,7 @@ class Principal implements BackendInterface { switch ($prop) { case '{http://sabredav.org/ns}email-address': if (!$allowEnumeration) { - if ($allowEnumerationFullMatch) { + if ($allowEnumerationFullMatch && $matchEmail) { $users = $this->userManager->getByEmail($value); } else { $users = []; @@ -349,8 +312,9 @@ class Principal implements BackendInterface { if ($allowEnumerationFullMatch) { $lowerSearch = strtolower($value); $users = $this->userManager->searchDisplayName($value, $searchLimit); - $users = \array_filter($users, static function (IUser $user) use ($lowerSearch) { - return strtolower($user->getDisplayName()) === $lowerSearch; + $users = \array_filter($users, static function (IUser $user) use ($lowerSearch, $ignoreSecondDisplayName) { + $lowerDisplayName = strtolower($user->getDisplayName()); + return $lowerDisplayName === $lowerSearch || ($ignoreSecondDisplayName && trim(preg_replace('/ \(.*\)$/', '', $lowerDisplayName)) === $lowerSearch); }); } else { $users = []; @@ -471,7 +435,7 @@ class Principal implements BackendInterface { $restrictGroups = $this->groupManager->getUserGroupIds($user); } - if (strpos($uri, 'mailto:') === 0) { + if (str_starts_with($uri, 'mailto:')) { if ($principalPrefix === 'principals/users') { $users = $this->userManager->getByEmail(substr($uri, 7)); if (count($users) !== 1) { @@ -489,7 +453,7 @@ class Principal implements BackendInterface { return $this->principalPrefix . '/' . $user->getUID(); } } - if (substr($uri, 0, 10) === 'principal:') { + if (str_starts_with($uri, 'principal:')) { $principal = substr($uri, 10); $principal = $this->getPrincipalByPath($principal); if ($principal !== null) { @@ -503,6 +467,7 @@ class Principal implements BackendInterface { /** * @param IUser $user * @return array + * @throws PropertyDoesNotExistException */ protected function userToPrincipal($user) { $userId = $user->getUID(); @@ -514,11 +479,18 @@ class Principal implements BackendInterface { '{http://nextcloud.com/ns}language' => $this->languageFactory->getUserLanguage($user), ]; + $account = $this->accountManager->getAccount($user); + $alternativeEmails = array_map(fn (IAccountProperty $property) => 'mailto:' . $property->getValue(), $account->getPropertyCollection(IAccountManager::COLLECTION_EMAIL)->getProperties()); + $email = $user->getSystemEMailAddress(); if (!empty($email)) { $principal['{http://sabredav.org/ns}email-address'] = $email; } + if (!empty($alternativeEmails)) { + $principal['{DAV:}alternate-URI-set'] = $alternativeEmails; + } + return $principal; } @@ -536,7 +508,7 @@ class Principal implements BackendInterface { } try { - $circle = \OCA\Circles\Api\v1\Circles::detailsCircle($circleUniqueId, true); + $circle = Circles::detailsCircle($circleUniqueId, true); } catch (QueryException $ex) { return null; } catch (CircleNotFoundException $ex) { @@ -561,7 +533,7 @@ class Principal implements BackendInterface { * @param string $principal * @return array * @throws Exception - * @throws \OCP\AppFramework\QueryException + * @throws QueryException * @suppress PhanUndeclaredClassMethod */ public function getCircleMembership($principal):array { @@ -576,10 +548,10 @@ class Principal implements BackendInterface { throw new Exception('Principal not found'); } - $circles = \OCA\Circles\Api\v1\Circles::joinedCircles($name, true); + $circles = Circles::joinedCircles($name, true); $circles = array_map(function ($circle) { - /** @var \OCA\Circles\Model\Circle $circle */ + /** @var Circle $circle */ return 'principals/circles/' . urlencode($circle->getSingleId()); }, $circles); @@ -588,4 +560,44 @@ class Principal implements BackendInterface { return []; } + + /** + * Get all email addresses associated to a principal. + * + * @param array $principal Data from getPrincipal*() + * @return string[] All email addresses without the mailto: prefix + */ + public function getEmailAddressesOfPrincipal(array $principal): array { + $emailAddresses = []; + + if (isset($principal['{http://sabredav.org/ns}email-address'])) { + $emailAddresses[] = $principal['{http://sabredav.org/ns}email-address']; + } + + if (isset($principal['{DAV:}alternate-URI-set'])) { + foreach ($principal['{DAV:}alternate-URI-set'] as $address) { + if (str_starts_with($address, 'mailto:')) { + $emailAddresses[] = substr($address, 7); + } + } + } + + if (isset($principal['{urn:ietf:params:xml:ns:caldav}calendar-user-address-set'])) { + foreach ($principal['{urn:ietf:params:xml:ns:caldav}calendar-user-address-set'] as $address) { + if (str_starts_with($address, 'mailto:')) { + $emailAddresses[] = substr($address, 7); + } + } + } + + if (isset($principal['{http://calendarserver.org/ns/}email-address-set'])) { + foreach ($principal['{http://calendarserver.org/ns/}email-address-set'] as $address) { + if (str_starts_with($address, 'mailto:')) { + $emailAddresses[] = substr($address, 7); + } + } + } + + return array_values(array_unique($emailAddresses)); + } } diff --git a/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php new file mode 100644 index 00000000000..130d4562146 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types = 1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\DAV\Server as SabreServer; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * This plugin runs after requests and logs an error if a plugin is detected + * to be doing too many SQL requests. + */ +class PropFindMonitorPlugin extends ServerPlugin { + + /** + * A Plugin can scan up to this amount of nodes without an error being + * reported. + */ + public const THRESHOLD_NODES = 50; + + /** + * A plugin can use up to this amount of queries per node. + */ + public const THRESHOLD_QUERY_FACTOR = 1; + + private SabreServer $server; + + public function initialize(SabreServer $server): void { + $this->server = $server; + $this->server->on('afterResponse', [$this, 'afterResponse']); + } + + public function afterResponse( + RequestInterface $request, + ResponseInterface $response): void { + if (!$this->server instanceof Server) { + return; + } + + $pluginQueries = $this->server->getPluginQueries(); + if (empty($pluginQueries)) { + return; + } + $maxDepth = max(0, ...array_keys($pluginQueries)); + // entries at the top are usually not interesting + unset($pluginQueries[$maxDepth]); + + $logger = $this->server->getLogger(); + foreach ($pluginQueries as $depth => $propFinds) { + foreach ($propFinds as $pluginName => $propFind) { + [ + 'queries' => $queries, + 'nodes' => $nodes + ] = $propFind; + if ($queries === 0 || $nodes > $queries || $nodes < self::THRESHOLD_NODES + || $queries < $nodes * self::THRESHOLD_QUERY_FACTOR) { + continue; + } + $logger->error( + '{name} scanned {scans} nodes with {count} queries in depth {depth}/{maxDepth}. This is bad for performance, please report to the plugin developer!', [ + 'name' => $pluginName, + 'scans' => $nodes, + 'count' => $queries, + 'depth' => $depth, + 'maxDepth' => $maxDepth, + ] + ); + } + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php b/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php index d0251b2755f..15daf1f34b6 100644 --- a/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php +++ b/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; @@ -61,7 +44,7 @@ class PropfindCompressionPlugin extends ServerPlugin { return $response; } - if (strpos($header, 'gzip') !== false) { + if (str_contains($header, 'gzip')) { $body = $response->getBody(); if (is_string($body)) { $response->setHeader('Content-Encoding', 'gzip'); diff --git a/apps/dav/lib/Connector/Sabre/PublicAuth.php b/apps/dav/lib/Connector/Sabre/PublicAuth.php new file mode 100644 index 00000000000..2ca1c25e2f6 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PublicAuth.php @@ -0,0 +1,227 @@ +<?php + +declare(strict_types=1); + + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\DAV\Connector\Sabre; + +use OCP\Defaults; +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\Bruteforce\MaxDelayReached; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IManager; +use OCP\Share\IShare; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Auth\Backend\AbstractBasic; +use Sabre\DAV\Exception\NotAuthenticated; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Exception\PreconditionFailed; +use Sabre\DAV\Exception\ServiceUnavailable; +use Sabre\HTTP; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * Class PublicAuth + * + * @package OCA\DAV\Connector + */ +class PublicAuth extends AbstractBasic { + private const BRUTEFORCE_ACTION = 'public_dav_auth'; + public const DAV_AUTHENTICATED = 'public_link_authenticated'; + + private ?IShare $share = null; + + public function __construct( + private IRequest $request, + private IManager $shareManager, + private ISession $session, + private IThrottler $throttler, + private LoggerInterface $logger, + private IURLGenerator $urlGenerator, + ) { + // setup realm + $defaults = new Defaults(); + $this->realm = $defaults->getName(); + } + + /** + * @throws NotAuthenticated + * @throws MaxDelayReached + * @throws ServiceUnavailable + */ + public function check(RequestInterface $request, ResponseInterface $response): array { + try { + $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION); + + if (count($_COOKIE) > 0 && !$this->request->passesStrictCookieCheck() && $this->getShare()->getPassword() !== null) { + throw new PreconditionFailed('Strict cookie check failed'); + } + + $auth = new HTTP\Auth\Basic( + $this->realm, + $request, + $response + ); + + $userpass = $auth->getCredentials(); + // If authentication provided, checking its validity + if ($userpass && !$this->validateUserPass($userpass[0], $userpass[1])) { + return [false, 'Username or password was incorrect']; + } + + return $this->checkToken(); + } catch (NotAuthenticated|MaxDelayReached $e) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + throw $e; + } catch (PreconditionFailed $e) { + $response->setHeader( + 'Location', + $this->urlGenerator->linkToRoute( + 'files_sharing.share.showShare', + [ 'token' => $this->getToken() ], + ), + ); + throw $e; + } catch (\Exception $e) { + $class = get_class($e); + $msg = $e->getMessage(); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new ServiceUnavailable("$class: $msg"); + } + } + + /** + * Extract token from request url + * @throws NotFound + */ + private function getToken(): string { + $path = $this->request->getPathInfo() ?: ''; + // ['', 'dav', 'files', 'token'] + $splittedPath = explode('/', $path); + + if (count($splittedPath) < 4 || $splittedPath[3] === '') { + throw new NotFound(); + } + + return $splittedPath[3]; + } + + /** + * Check token validity + * + * @throws NotFound + * @throws NotAuthenticated + */ + private function checkToken(): array { + $token = $this->getToken(); + + try { + /** @var IShare $share */ + $share = $this->shareManager->getShareByToken($token); + } catch (ShareNotFound $e) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + throw new NotFound(); + } + + $this->share = $share; + \OC_User::setIncognitoMode(true); + + // If already authenticated + if ($this->session->exists(self::DAV_AUTHENTICATED) + && $this->session->get(self::DAV_AUTHENTICATED) === $share->getId()) { + return [true, $this->principalPrefix . $token]; + } + + // If the share is protected but user is not authenticated + if ($share->getPassword() !== null) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + throw new NotAuthenticated(); + } + + return [true, $this->principalPrefix . $token]; + } + + /** + * Validates a username and password + * + * This method should return true or false depending on if login + * succeeded. + * + * @param string $username + * @param string $password + * + * @return bool + * @throws NotAuthenticated + */ + protected function validateUserPass($username, $password) { + $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION); + + try { + $share = $this->getShare(); + } catch (ShareNotFound $e) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } + + \OC_User::setIncognitoMode(true); + + // check if the share is password protected + if ($share->getPassword() !== null) { + if ($share->getShareType() === IShare::TYPE_LINK + || $share->getShareType() === IShare::TYPE_EMAIL + || $share->getShareType() === IShare::TYPE_CIRCLE) { + if ($this->shareManager->checkPassword($share, $password)) { + // If not set, set authenticated session cookie + if (!$this->session->exists(self::DAV_AUTHENTICATED) + || $this->session->get(self::DAV_AUTHENTICATED) !== $share->getId()) { + $this->session->set(self::DAV_AUTHENTICATED, $share->getId()); + } + return true; + } + + if ($this->session->exists(PublicAuth::DAV_AUTHENTICATED) + && $this->session->get(PublicAuth::DAV_AUTHENTICATED) === $share->getId()) { + return true; + } + + if (in_array('XMLHttpRequest', explode(',', $this->request->getHeader('X-Requested-With')))) { + // do not re-authenticate over ajax, use dummy auth name to prevent browser popup + http_response_code(401); + header('WWW-Authenticate: DummyBasic realm="' . $this->realm . '"'); + throw new NotAuthenticated('Cannot authenticate over ajax calls'); + } + + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } elseif ($share->getShareType() === IShare::TYPE_REMOTE) { + return true; + } + + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } + + return true; + } + + public function getShare(): IShare { + $token = $this->getToken(); + + if ($this->share === null) { + $share = $this->shareManager->getShareByToken($token); + $this->share = $share; + } + + return $this->share; + } +} diff --git a/apps/dav/lib/Connector/Sabre/QuotaPlugin.php b/apps/dav/lib/Connector/Sabre/QuotaPlugin.php index f2b652e3320..bbb378edc9b 100644 --- a/apps/dav/lib/Connector/Sabre/QuotaPlugin.php +++ b/apps/dav/lib/Connector/Sabre/QuotaPlugin.php @@ -1,40 +1,22 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (C) 2012 entreCables S.L. All rights reserved. - * @copyright Copyright (C) 2012 entreCables S.L. All rights reserved. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Felix Moeller <mail@felixmoeller.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author scambra <sergio@entrecables.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-FileCopyrightText: 2012 entreCables S.L. All rights reserved + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; +use OC\Files\View; use OCA\DAV\Upload\FutureFile; +use OCA\DAV\Upload\UploadFolder; use OCP\Files\StorageNotAvailableException; use Sabre\DAV\Exception\InsufficientStorage; use Sabre\DAV\Exception\ServiceUnavailable; use Sabre\DAV\INode; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; /** * This plugin check user quota and deny creating files when they exceeds the quota. @@ -44,10 +26,6 @@ use Sabre\DAV\INode; * @license http://code.google.com/p/sabredav/wiki/License Modified BSD License */ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { - - /** @var \OC\Files\View */ - private $view; - /** * Reference to main server object * @@ -56,10 +34,11 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { private $server; /** - * @param \OC\Files\View $view + * @param View $view */ - public function __construct($view) { - $this->view = $view; + public function __construct( + private $view, + ) { } /** @@ -78,7 +57,9 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { $server->on('beforeWriteContent', [$this, 'beforeWriteContent'], 10); $server->on('beforeCreateFile', [$this, 'beforeCreateFile'], 10); + $server->on('method:MKCOL', [$this, 'onCreateCollection'], 30); $server->on('beforeMove', [$this, 'beforeMove'], 10); + $server->on('beforeCopy', [$this, 'beforeCopy'], 10); } /** @@ -90,6 +71,19 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { * @param bool $modified modified */ public function beforeCreateFile($uri, $data, INode $parent, $modified) { + $request = $this->server->httpRequest; + if ($parent instanceof UploadFolder && $request->getHeader('Destination')) { + // If chunked upload and Total-Length header is set, use that + // value for quota check. This allows us to also check quota while + // uploading chunks and not only when the file is assembled. + $length = $request->getHeader('OC-Total-Length'); + $destinationPath = $this->server->calculateUri($request->getHeader('Destination')); + $quotaPath = $this->getPathForDestination($destinationPath); + if ($quotaPath && is_numeric($length)) { + return $this->checkQuota($quotaPath, (int)$length); + } + } + if (!$parent instanceof Node) { return; } @@ -98,6 +92,31 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { } /** + * Check quota before creating directory + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return bool + * @throws InsufficientStorage + * @throws \Sabre\DAV\Exception\Forbidden + */ + public function onCreateCollection(RequestInterface $request, ResponseInterface $response): bool { + try { + $destinationPath = $this->server->calculateUri($request->getUrl()); + $quotaPath = $this->getPathForDestination($destinationPath); + } catch (\Exception $e) { + return true; + } + if ($quotaPath) { + // MKCOL does not have a Content-Length header, so we can use + // a fixed value for the quota check. + return $this->checkQuota($quotaPath, 4096, true); + } + + return true; + } + + /** * Check quota before writing content * * @param string $uri target file URI @@ -114,40 +133,76 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { } /** - * Check if we're moving a Futurefile in which case we need to check + * Check if we're moving a FutureFile in which case we need to check * the quota on the target destination. - * - * @param string $source source path - * @param string $destination destination path */ - public function beforeMove($source, $destination) { - $sourceNode = $this->server->tree->getNodeForPath($source); + public function beforeMove(string $sourcePath, string $destinationPath): bool { + $sourceNode = $this->server->tree->getNodeForPath($sourcePath); if (!$sourceNode instanceof FutureFile) { - return; + return true; } - // get target node for proper path conversion - if ($this->server->tree->nodeExists($destination)) { - $destinationNode = $this->server->tree->getNodeForPath($destination); - $path = $destinationNode->getPath(); - } else { - $parentNode = $this->server->tree->getNodeForPath(dirname($destination)); - $path = $parentNode->getPath(); + try { + // The final path is not known yet, we check the quota on the parent + $path = $this->getPathForDestination($destinationPath); + } catch (\Exception $e) { + return true; } return $this->checkQuota($path, $sourceNode->getSize()); } + /** + * Check quota on the target destination before a copy. + */ + public function beforeCopy(string $sourcePath, string $destinationPath): bool { + $sourceNode = $this->server->tree->getNodeForPath($sourcePath); + if (!$sourceNode instanceof Node) { + return true; + } + + try { + $path = $this->getPathForDestination($destinationPath); + } catch (\Exception $e) { + return true; + } + + return $this->checkQuota($path, $sourceNode->getSize()); + } + + private function getPathForDestination(string $destinationPath): string { + // get target node for proper path conversion + if ($this->server->tree->nodeExists($destinationPath)) { + $destinationNode = $this->server->tree->getNodeForPath($destinationPath); + if (!$destinationNode instanceof Node) { + throw new \Exception('Invalid destination node'); + } + return $destinationNode->getPath(); + } + + $parent = dirname($destinationPath); + if ($parent === '.') { + $parent = ''; + } + + $parentNode = $this->server->tree->getNodeForPath($parent); + if (!$parentNode instanceof Node) { + throw new \Exception('Invalid destination node'); + } + + return $parentNode->getPath(); + } + /** * This method is called before any HTTP method and validates there is enough free space to store the file * * @param string $path relative to the users home - * @param int $length + * @param int|float|null $length * @throws InsufficientStorage * @return bool */ - public function checkQuota($path, $length = null) { + public function checkQuota(string $path, $length = null, $isDir = false) { if ($length === null) { $length = $this->getLength(); } @@ -158,29 +213,21 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { $parentPath = ''; } $req = $this->server->httpRequest; - if ($req->getHeader('OC-Chunked')) { - $info = \OC_FileChunking::decodeName($newName); - $chunkHandler = $this->getFileChunking($info); - // subtract the already uploaded size to see whether - // there is still enough space for the remaining chunks - $length -= $chunkHandler->getCurrentSize(); - // use target file name for free space check in case of shared files - $path = rtrim($parentPath, '/') . '/' . $info['name']; - } + + // Strip any duplicate slashes + $path = str_replace('//', '/', $path); + $freeSpace = $this->getFreeSpace($path); if ($freeSpace >= 0 && $length > $freeSpace) { - if (isset($chunkHandler)) { - $chunkHandler->cleanup(); + if ($isDir) { + throw new InsufficientStorage("Insufficient space in $path. $freeSpace available. Cannot create directory"); } + throw new InsufficientStorage("Insufficient space in $path, $length required, $freeSpace available"); } } - return true; - } - public function getFileChunking($info) { - // FIXME: need a factory for better mocking support - return new \OC_FileChunking($info); + return true; } public function getLength() { @@ -192,11 +239,13 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { } $ocLength = $req->getHeader('OC-Total-Length'); - if (is_numeric($length) && is_numeric($ocLength)) { - return max($length, $ocLength); + if (!is_numeric($ocLength)) { + return $length; } - - return $length; + if (!is_numeric($length)) { + return $ocLength; + } + return max($length, $ocLength); } /** diff --git a/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php b/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php index b281a1053b8..5484bab9237 100644 --- a/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php +++ b/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php @@ -1,22 +1,9 @@ -<?php declare(strict_types=1); +<?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; @@ -26,11 +13,9 @@ use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class RequestIdHeaderPlugin extends \Sabre\DAV\ServerPlugin { - /** @var IRequest */ - private $request; - - public function __construct(IRequest $request) { - $this->request = $request; + public function __construct( + private IRequest $request, + ) { } public function initialize(\Sabre\DAV\Server $server) { diff --git a/apps/dav/lib/Connector/Sabre/Server.php b/apps/dav/lib/Connector/Sabre/Server.php index 6cf6fa954c8..dda9c29b763 100644 --- a/apps/dav/lib/Connector/Sabre/Server.php +++ b/apps/dav/lib/Connector/Sabre/Server.php @@ -1,30 +1,20 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author scolebrook <scolebrook@mac.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; +use OC\DB\Connection; +use Override; +use Sabre\DAV\Exception; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\Version; +use TypeError; + /** * Class \OCA\DAV\Connector\Sabre\Server * @@ -36,6 +26,14 @@ class Server extends \Sabre\DAV\Server { /** @var CachingTree $tree */ /** + * Tracks queries done by plugins. + * @var array<int, array<string, array{nodes:int, queries:int}>> + */ + private array $pluginQueries = []; + + public bool $debugEnabled = false; + + /** * @see \Sabre\DAV\Server */ public function __construct($treeOrNode = null) { @@ -43,4 +41,190 @@ class Server extends \Sabre\DAV\Server { self::$exposeVersion = false; $this->enablePropfindDepthInfinity = true; } + + #[Override] + public function once( + string $eventName, + callable $callBack, + int $priority = 100, + ): void { + $this->debugEnabled ? $this->monitorPropfindQueries( + parent::once(...), + ...func_get_args(), + ) : parent::once(...func_get_args()); + } + + #[Override] + public function on( + string $eventName, + callable $callBack, + int $priority = 100, + ): void { + $this->debugEnabled ? $this->monitorPropfindQueries( + parent::on(...), + ...func_get_args(), + ) : parent::on(...func_get_args()); + } + + /** + * Wraps the handler $callBack into a query-monitoring function and calls + * $parentFn to register it. + */ + private function monitorPropfindQueries( + callable $parentFn, + string $eventName, + callable $callBack, + int $priority = 100, + ): void { + if ($eventName !== 'propFind') { + $parentFn($eventName, $callBack, $priority); + return; + } + + $pluginName = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['class'] ?? 'unknown'; + $callback = $this->getMonitoredCallback($callBack, $pluginName); + + $parentFn($eventName, $callback, $priority); + } + + /** + * Returns a callable that wraps $callBack with code that monitors and + * records queries per plugin. + */ + private function getMonitoredCallback( + callable $callBack, + string $pluginName, + ): callable { + return function (PropFind $propFind, INode $node) use ( + $callBack, + $pluginName, + ) { + $connection = \OCP\Server::get(Connection::class); + $queriesBefore = $connection->getStats()['executed']; + $result = $callBack($propFind, $node); + $queriesAfter = $connection->getStats()['executed']; + $this->trackPluginQueries( + $pluginName, + $queriesAfter - $queriesBefore, + $propFind->getDepth() + ); + + return $result; + }; + } + + /** + * Tracks the queries executed by a specific plugin. + */ + private function trackPluginQueries( + string $pluginName, + int $queriesExecuted, + int $depth, + ): void { + // report only nodes which cause queries to the DB + if ($queriesExecuted === 0) { + return; + } + + $this->pluginQueries[$depth][$pluginName]['nodes'] + = ($this->pluginQueries[$depth][$pluginName]['nodes'] ?? 0) + 1; + + $this->pluginQueries[$depth][$pluginName]['queries'] + = ($this->pluginQueries[$depth][$pluginName]['queries'] ?? 0) + $queriesExecuted; + } + + /** + * + * @return void + */ + public function start() { + try { + // If nginx (pre-1.2) is used as a proxy server, and SabreDAV as an + // origin, we must make sure we send back HTTP/1.0 if this was + // requested. + // This is mainly because nginx doesn't support Chunked Transfer + // Encoding, and this forces the webserver SabreDAV is running on, + // to buffer entire responses to calculate Content-Length. + $this->httpResponse->setHTTPVersion($this->httpRequest->getHTTPVersion()); + + // Setting the base url + $this->httpRequest->setBaseUrl($this->getBaseUri()); + $this->invokeMethod($this->httpRequest, $this->httpResponse); + } catch (\Throwable $e) { + try { + $this->emit('exception', [$e]); + } catch (\Exception) { + } + + if ($e instanceof TypeError) { + /* + * The TypeError includes the file path where the error occurred, + * potentially revealing the installation directory. + */ + $e = new TypeError('A type error occurred. For more details, please refer to the logs, which provide additional context about the type error.'); + } + + $DOM = new \DOMDocument('1.0', 'utf-8'); + $DOM->formatOutput = true; + + $error = $DOM->createElementNS('DAV:', 'd:error'); + $error->setAttribute('xmlns:s', self::NS_SABREDAV); + $DOM->appendChild($error); + + $h = function ($v) { + return htmlspecialchars((string)$v, ENT_NOQUOTES, 'UTF-8'); + }; + + if (self::$exposeVersion) { + $error->appendChild($DOM->createElement('s:sabredav-version', $h(Version::VERSION))); + } + + $error->appendChild($DOM->createElement('s:exception', $h(get_class($e)))); + $error->appendChild($DOM->createElement('s:message', $h($e->getMessage()))); + if ($this->debugExceptions) { + $error->appendChild($DOM->createElement('s:file', $h($e->getFile()))); + $error->appendChild($DOM->createElement('s:line', $h($e->getLine()))); + $error->appendChild($DOM->createElement('s:code', $h($e->getCode()))); + $error->appendChild($DOM->createElement('s:stacktrace', $h($e->getTraceAsString()))); + } + + if ($this->debugExceptions) { + $previous = $e; + while ($previous = $previous->getPrevious()) { + $xPrevious = $DOM->createElement('s:previous-exception'); + $xPrevious->appendChild($DOM->createElement('s:exception', $h(get_class($previous)))); + $xPrevious->appendChild($DOM->createElement('s:message', $h($previous->getMessage()))); + $xPrevious->appendChild($DOM->createElement('s:file', $h($previous->getFile()))); + $xPrevious->appendChild($DOM->createElement('s:line', $h($previous->getLine()))); + $xPrevious->appendChild($DOM->createElement('s:code', $h($previous->getCode()))); + $xPrevious->appendChild($DOM->createElement('s:stacktrace', $h($previous->getTraceAsString()))); + $error->appendChild($xPrevious); + } + } + + if ($e instanceof Exception) { + $httpCode = $e->getHTTPCode(); + $e->serialize($this, $error); + $headers = $e->getHTTPHeaders($this); + } else { + $httpCode = 500; + $headers = []; + } + $headers['Content-Type'] = 'application/xml; charset=utf-8'; + + $this->httpResponse->setStatus($httpCode); + $this->httpResponse->setHeaders($headers); + $this->httpResponse->setBody($DOM->saveXML()); + $this->sapi->sendResponse($this->httpResponse); + } + } + + /** + * Returns queries executed by registered plugins. + * + * @return array<int, array<string, array{nodes:int, queries:int}>> + */ + public function getPluginQueries(): array { + return $this->pluginQueries; + } } diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index b13dbd20ca9..a6a27057177 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -1,137 +1,111 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; -use OCP\Files\Folder; +use OC\Files\View; +use OC\KnownUser\KnownUserService; use OCA\DAV\AppInfo\PluginManager; +use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\CalDAV\Proxy\ProxyMapper; +use OCA\DAV\DAV\CustomPropertiesBackend; +use OCA\DAV\DAV\ViewOnlyPlugin; use OCA\DAV\Files\BrowserErrorPagePlugin; +use OCA\DAV\Files\Sharing\RootCollection; +use OCA\DAV\Upload\CleanupService; +use OCA\Theming\ThemingDefaults; +use OCP\Accounts\IAccountManager; +use OCP\App\IAppManager; +use OCP\Comments\ICommentsManager; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Folder; +use OCP\Files\IFilenameValidator; +use OCP\Files\IRootFolder; use OCP\Files\Mount\IMountManager; use OCP\IConfig; use OCP\IDBConnection; +use OCP\IGroupManager; use OCP\IL10N; -use OCP\ILogger; use OCP\IPreview; use OCP\IRequest; use OCP\ITagManager; +use OCP\IUserManager; use OCP\IUserSession; use OCP\SabrePluginEvent; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; +use Psr\Log\LoggerInterface; use Sabre\DAV\Auth\Plugin; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Sabre\DAV\SimpleCollection; class ServerFactory { - /** @var IConfig */ - private $config; - /** @var ILogger */ - private $logger; - /** @var IDBConnection */ - private $databaseConnection; - /** @var IUserSession */ - private $userSession; - /** @var IMountManager */ - private $mountManager; - /** @var ITagManager */ - private $tagManager; - /** @var IRequest */ - private $request; - /** @var IPreview */ - private $previewManager; - /** @var EventDispatcherInterface */ - private $eventDispatcher; - /** @var IL10N */ - private $l10n; - /** - * @param IConfig $config - * @param ILogger $logger - * @param IDBConnection $databaseConnection - * @param IUserSession $userSession - * @param IMountManager $mountManager - * @param ITagManager $tagManager - * @param IRequest $request - * @param IPreview $previewManager - */ public function __construct( - IConfig $config, - ILogger $logger, - IDBConnection $databaseConnection, - IUserSession $userSession, - IMountManager $mountManager, - ITagManager $tagManager, - IRequest $request, - IPreview $previewManager, - EventDispatcherInterface $eventDispatcher, - IL10N $l10n + private IConfig $config, + private LoggerInterface $logger, + private IDBConnection $databaseConnection, + private IUserSession $userSession, + private IMountManager $mountManager, + private ITagManager $tagManager, + private IRequest $request, + private IPreview $previewManager, + private IEventDispatcher $eventDispatcher, + private IL10N $l10n, ) { - $this->config = $config; - $this->logger = $logger; - $this->databaseConnection = $databaseConnection; - $this->userSession = $userSession; - $this->mountManager = $mountManager; - $this->tagManager = $tagManager; - $this->request = $request; - $this->previewManager = $previewManager; - $this->eventDispatcher = $eventDispatcher; - $this->l10n = $l10n; } /** - * @param string $baseUri - * @param string $requestUri - * @param Plugin $authPlugin * @param callable $viewCallBack callback that should return the view for the dav endpoint - * @return Server */ - public function createServer($baseUri, - $requestUri, - Plugin $authPlugin, - callable $viewCallBack) { + public function createServer( + bool $isPublicShare, + string $baseUri, + string $requestUri, + Plugin $authPlugin, + callable $viewCallBack, + ): Server { + $debugEnabled = $this->config->getSystemValue('debug', false); // Fire up server - $objectTree = new \OCA\DAV\Connector\Sabre\ObjectTree(); - $server = new \OCA\DAV\Connector\Sabre\Server($objectTree); + if ($isPublicShare) { + $rootCollection = new SimpleCollection('root'); + $tree = new CachingTree($rootCollection); + } else { + $rootCollection = null; + $tree = new ObjectTree(); + } + $server = new Server($tree); // Set URL explicitly due to reverse-proxy situations $server->httpRequest->setUrl($requestUri); $server->setBaseUri($baseUri); // Load plugins - $server->addPlugin(new \OCA\DAV\Connector\Sabre\MaintenancePlugin($this->config, $this->l10n)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin($this->config)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin()); + $server->addPlugin(new MaintenancePlugin($this->config, $this->l10n)); + $server->addPlugin(new BlockLegacyClientPlugin( + $this->config, + \OCP\Server::get(ThemingDefaults::class), + )); + $server->addPlugin(new AnonymousOptionsPlugin()); $server->addPlugin($authPlugin); + if ($debugEnabled) { + $server->debugEnabled = $debugEnabled; + $server->addPlugin(new PropFindMonitorPlugin()); + } // FIXME: The following line is a workaround for legacy components relying on being able to send a GET to / - $server->addPlugin(new \OCA\DAV\Connector\Sabre\DummyGetResponsePlugin()); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin('webdav', $this->logger)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\LockPlugin()); + $server->addPlugin(new DummyGetResponsePlugin()); + $server->addPlugin(new ExceptionLoggerPlugin('webdav', $this->logger)); + $server->addPlugin(new LockPlugin()); - $server->addPlugin(new RequestIdHeaderPlugin(\OC::$server->get(IRequest::class))); + $server->addPlugin(new RequestIdHeaderPlugin($this->request)); + + $server->addPlugin(new ZipFolderPlugin( + $tree, + $this->logger, + $this->eventDispatcher, + )); // Some WebDAV clients do require Class 2 WebDAV support (locking), since // we do not provide locking we emulate it using a fake locking plugin. @@ -140,7 +114,7 @@ class ServerFactory { '/OneNote/', '/Microsoft-WebDAV-MiniRedir/', ])) { - $server->addPlugin(new \OCA\DAV\Connector\Sabre\FakeLockerPlugin()); + $server->addPlugin(new FakeLockerPlugin()); } if (BrowserErrorPagePlugin::isBrowserRequest($this->request)) { @@ -148,11 +122,12 @@ class ServerFactory { } // wait with registering these until auth is handled and the filesystem is setup - $server->on('beforeMethod:*', function () use ($server, $objectTree, $viewCallBack) { + $server->on('beforeMethod:*', function () use ($server, $tree, + $viewCallBack, $isPublicShare, $rootCollection, $debugEnabled): void { // ensure the skeleton is copied $userFolder = \OC::$server->getUserFolder(); - /** @var \OC\Files\View $view */ + /** @var View $view */ $view = $viewCallBack($server); if ($userFolder instanceof Folder && $userFolder->getPath() === $view->getRoot()) { $rootInfo = $userFolder; @@ -162,65 +137,108 @@ class ServerFactory { // Create Nextcloud Dir if ($rootInfo->getType() === 'dir') { - $root = new \OCA\DAV\Connector\Sabre\Directory($view, $rootInfo, $objectTree); + $root = new Directory($view, $rootInfo, $tree); } else { - $root = new \OCA\DAV\Connector\Sabre\File($view, $rootInfo); + $root = new File($view, $rootInfo); + } + + if ($isPublicShare) { + $userPrincipalBackend = new Principal( + \OCP\Server::get(IUserManager::class), + \OCP\Server::get(IGroupManager::class), + \OCP\Server::get(IAccountManager::class), + \OCP\Server::get(\OCP\Share\IManager::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(IAppManager::class), + \OCP\Server::get(ProxyMapper::class), + \OCP\Server::get(KnownUserService::class), + \OCP\Server::get(IConfig::class), + \OC::$server->getL10NFactory(), + ); + + // Mount the share collection at /public.php/dav/shares/<share token> + $rootCollection->addChild(new RootCollection( + $root, + $userPrincipalBackend, + 'principals/shares', + )); + + // Mount the upload collection at /public.php/dav/uploads/<share token> + $rootCollection->addChild(new \OCA\DAV\Upload\RootCollection( + $userPrincipalBackend, + 'principals/shares', + \OCP\Server::get(CleanupService::class), + \OCP\Server::get(IRootFolder::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(\OCP\Share\IManager::class), + )); + } else { + /** @var ObjectTree $tree */ + $tree->init($root, $view, $this->mountManager); } - $objectTree->init($root, $view, $this->mountManager); $server->addPlugin( - new \OCA\DAV\Connector\Sabre\FilesPlugin( - $objectTree, + new FilesPlugin( + $tree, $this->config, $this->request, $this->previewManager, $this->userSession, + \OCP\Server::get(IFilenameValidator::class), + \OCP\Server::get(IAccountManager::class), false, - !$this->config->getSystemValue('debug', false) + !$debugEnabled ) ); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\QuotaPlugin($view, true)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\ChecksumUpdatePlugin()); + $server->addPlugin(new QuotaPlugin($view)); + $server->addPlugin(new ChecksumUpdatePlugin()); + + // Allow view-only plugin for webdav requests + $server->addPlugin(new ViewOnlyPlugin( + $userFolder, + )); if ($this->userSession->isLoggedIn()) { - $server->addPlugin(new \OCA\DAV\Connector\Sabre\TagsPlugin($objectTree, $this->tagManager)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\SharesPlugin( - $objectTree, + $server->addPlugin(new TagsPlugin($tree, $this->tagManager, $this->eventDispatcher, $this->userSession)); + $server->addPlugin(new SharesPlugin( + $tree, $this->userSession, $userFolder, - \OC::$server->getShareManager() + \OCP\Server::get(\OCP\Share\IManager::class) )); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\CommentPropertiesPlugin(\OC::$server->getCommentsManager(), $this->userSession)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\FilesReportPlugin( - $objectTree, + $server->addPlugin(new CommentPropertiesPlugin(\OCP\Server::get(ICommentsManager::class), $this->userSession)); + $server->addPlugin(new FilesReportPlugin( + $tree, $view, - \OC::$server->getSystemTagManager(), - \OC::$server->getSystemTagObjectMapper(), - \OC::$server->getTagManager(), + \OCP\Server::get(ISystemTagManager::class), + \OCP\Server::get(ISystemTagObjectMapper::class), + \OCP\Server::get(ITagManager::class), $this->userSession, - \OC::$server->getGroupManager(), + \OCP\Server::get(IGroupManager::class), $userFolder, - \OC::$server->getAppManager() + \OCP\Server::get(IAppManager::class) )); // custom properties plugin must be the last one $server->addPlugin( new \Sabre\DAV\PropertyStorage\Plugin( - new \OCA\DAV\DAV\CustomPropertiesBackend( - $objectTree, + new CustomPropertiesBackend( + $server, + $tree, $this->databaseConnection, - $this->userSession->getUser() + $this->userSession->getUser(), + \OCP\Server::get(DefaultCalendarValidator::class), ) ) ); } - $server->addPlugin(new \OCA\DAV\Connector\Sabre\CopyEtagHeaderPlugin()); + $server->addPlugin(new CopyEtagHeaderPlugin()); // Load dav plugins from apps $event = new SabrePluginEvent($server); - $this->eventDispatcher->dispatch($event); + $this->eventDispatcher->dispatchTyped($event); $pluginManager = new PluginManager( \OC::$server, - \OC::$server->getAppManager() + \OCP\Server::get(IAppManager::class) ); foreach ($pluginManager->getAppPlugins() as $appPlugin) { $server->addPlugin($appPlugin); diff --git a/apps/dav/lib/Connector/Sabre/ShareTypeList.php b/apps/dav/lib/Connector/Sabre/ShareTypeList.php index 6fbae0dee4a..0b66ed27576 100644 --- a/apps/dav/lib/Connector/Sabre/ShareTypeList.php +++ b/apps/dav/lib/Connector/Sabre/ShareTypeList.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -35,17 +20,14 @@ class ShareTypeList implements Element { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * Share types - * - * @var int[] - */ - private $shareTypes; - - /** * @param int[] $shareTypes */ - public function __construct($shareTypes) { - $this->shareTypes = $shareTypes; + public function __construct( + /** + * Share types + */ + private $shareTypes, + ) { } /** @@ -79,7 +61,7 @@ class ShareTypeList implements Element { } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * @param Writer $writer * @return void diff --git a/apps/dav/lib/Connector/Sabre/ShareeList.php b/apps/dav/lib/Connector/Sabre/ShareeList.php index db8c011cc45..909c29fc24b 100644 --- a/apps/dav/lib/Connector/Sabre/ShareeList.php +++ b/apps/dav/lib/Connector/Sabre/ShareeList.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; @@ -37,15 +18,14 @@ use Sabre\Xml\XmlSerializable; class ShareeList implements XmlSerializable { public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; - /** @var IShare[] */ - private $shares; - - public function __construct(array $shares) { - $this->shares = $shares; + public function __construct( + /** @var IShare[] */ + private array $shares, + ) { } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * @param Writer $writer * @return void diff --git a/apps/dav/lib/Connector/Sabre/SharesPlugin.php b/apps/dav/lib/Connector/Sabre/SharesPlugin.php index 57c91e05a8c..f49e85333f3 100644 --- a/apps/dav/lib/Connector/Sabre/SharesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/SharesPlugin.php @@ -1,39 +1,23 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; +use OC\Share20\Exception\BackendError; use OCA\DAV\Connector\Sabre\Node as DavNode; use OCP\Files\Folder; +use OCP\Files\Node; use OCP\Files\NotFoundException; use OCP\IUserSession; +use OCP\Share\IManager; use OCP\Share\IShare; use Sabre\DAV\PropFind; +use Sabre\DAV\Server; +use Sabre\DAV\Tree; /** * Sabre Plugin to provide share-related properties @@ -50,40 +34,19 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { * @var \Sabre\DAV\Server */ private $server; - - /** @var \OCP\Share\IManager */ - private $shareManager; - - /** @var \Sabre\DAV\Tree */ - private $tree; - - /** @var string */ - private $userId; - - /** @var \OCP\Files\Folder */ - private $userFolder; + private string $userId; /** @var IShare[][] */ - private $cachedShares = []; - + private array $cachedShares = []; /** @var string[] */ - private $cachedFolders = []; + private array $cachedFolders = []; - /** - * @param \Sabre\DAV\Tree $tree tree - * @param IUserSession $userSession user session - * @param \OCP\Files\Folder $userFolder user home folder - * @param \OCP\Share\IManager $shareManager share manager - */ public function __construct( - \Sabre\DAV\Tree $tree, - IUserSession $userSession, - \OCP\Files\Folder $userFolder, - \OCP\Share\IManager $shareManager + private Tree $tree, + private IUserSession $userSession, + private Folder $userFolder, + private IManager $shareManager, ) { - $this->tree = $tree; - $this->shareManager = $shareManager; - $this->userFolder = $userFolder; $this->userId = $userSession->getUser()->getUID(); } @@ -95,9 +58,9 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { * * This method should set up the required event subscriptions. * - * @param \Sabre\DAV\Server $server + * @return void */ - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(Server $server) { $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; $server->xml->elementMap[self::SHARETYPES_PROPERTYNAME] = ShareTypeList::class; $server->protectedProperties[] = self::SHARETYPES_PROPERTYNAME; @@ -108,10 +71,10 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { } /** - * @param \OCP\Files\Node $node + * @param Node $node * @return IShare[] */ - private function getShare(\OCP\Files\Node $node): array { + private function getShare(Node $node): array { $result = []; $requestedShareTypes = [ IShare::TYPE_USER, @@ -122,19 +85,31 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { IShare::TYPE_ROOM, IShare::TYPE_CIRCLE, IShare::TYPE_DECK, + IShare::TYPE_SCIENCEMESH, ]; + foreach ($requestedShareTypes as $requestedShareType) { - $shares = $this->shareManager->getSharesBy( + $result = array_merge($result, $this->shareManager->getSharesBy( $this->userId, $requestedShareType, $node, false, -1 - ); - foreach ($shares as $share) { - $result[] = $share; + )); + + // Also check for shares where the user is the recipient + try { + $result = array_merge($result, $this->shareManager->getSharedWith( + $this->userId, + $requestedShareType, + $node, + -1 + )); + } catch (BackendError $e) { + // ignore } } + return $result; } @@ -156,27 +131,29 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { */ private function getShares(DavNode $sabreNode): array { if (isset($this->cachedShares[$sabreNode->getId()])) { - $shares = $this->cachedShares[$sabreNode->getId()]; - } else { - [$parentPath,] = \Sabre\Uri\split($sabreNode->getPath()); - if ($parentPath === '') { - $parentPath = '/'; - } - // if we already cached the folder this file is in we know there are no shares for this file - if (array_search($parentPath, $this->cachedFolders) === false) { - try { - $node = $sabreNode->getNode(); - } catch (NotFoundException $e) { - return []; - } - $shares = $this->getShare($node); - $this->cachedShares[$sabreNode->getId()] = $shares; - } else { + return $this->cachedShares[$sabreNode->getId()]; + } + + [$parentPath,] = \Sabre\Uri\split($sabreNode->getPath()); + if ($parentPath === '') { + $parentPath = '/'; + } + + // if we already cached the folder containing this file + // then we already know there are no shares here. + if (array_search($parentPath, $this->cachedFolders) === false) { + try { + $node = $sabreNode->getNode(); + } catch (NotFoundException $e) { return []; } + + $shares = $this->getShare($node); + $this->cachedShares[$sabreNode->getId()] = $shares; + return $shares; } - return $shares; + return []; } /** @@ -187,18 +164,20 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { */ public function handleGetProperties( PropFind $propFind, - \Sabre\DAV\INode $sabreNode + \Sabre\DAV\INode $sabreNode, ) { if (!($sabreNode instanceof DavNode)) { return; } - // need prefetch ? + // If the node is a directory and we are requesting share types or sharees + // then we get all the shares in the folder and cache them. + // This is more performant than iterating each files afterwards. if ($sabreNode instanceof Directory && $propFind->getDepth() !== 0 && ( - !is_null($propFind->getStatus(self::SHARETYPES_PROPERTYNAME)) || - !is_null($propFind->getStatus(self::SHAREES_PROPERTYNAME)) + !is_null($propFind->getStatus(self::SHARETYPES_PROPERTYNAME)) + || !is_null($propFind->getStatus(self::SHAREES_PROPERTYNAME)) ) ) { $folderNode = $sabreNode->getNode(); @@ -209,7 +188,7 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { } } - $propFind->handle(self::SHARETYPES_PROPERTYNAME, function () use ($sabreNode) { + $propFind->handle(self::SHARETYPES_PROPERTYNAME, function () use ($sabreNode): ShareTypeList { $shares = $this->getShares($sabreNode); $shareTypes = array_unique(array_map(function (IShare $share) { @@ -219,7 +198,7 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { return new ShareTypeList($shareTypes); }); - $propFind->handle(self::SHAREES_PROPERTYNAME, function () use ($sabreNode) { + $propFind->handle(self::SHAREES_PROPERTYNAME, function () use ($sabreNode): ShareeList { $shares = $this->getShares($sabreNode); return new ShareeList($shares); diff --git a/apps/dav/lib/Connector/Sabre/TagList.php b/apps/dav/lib/Connector/Sabre/TagList.php index bbb938fb27d..9a5cd0d51cf 100644 --- a/apps/dav/lib/Connector/Sabre/TagList.php +++ b/apps/dav/lib/Connector/Sabre/TagList.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -36,17 +20,14 @@ class TagList implements Element { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * tags - * - * @var array - */ - private $tags; - - /** * @param array $tags */ - public function __construct(array $tags) { - $this->tags = $tags; + public function __construct( + /** + * tags + */ + private array $tags, + ) { } /** @@ -95,7 +76,7 @@ class TagList implements Element { } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * Use the $writer argument to write its own xml serialization. * diff --git a/apps/dav/lib/Connector/Sabre/TagsPlugin.php b/apps/dav/lib/Connector/Sabre/TagsPlugin.php index da5dd874905..25c1633df36 100644 --- a/apps/dav/lib/Connector/Sabre/TagsPlugin.php +++ b/apps/dav/lib/Connector/Sabre/TagsPlugin.php @@ -1,31 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright 2014 Vincent Petry <pvince81@owncloud.com> - * @copyright 2014 Vincent Petry <pvince81@owncloud.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sergio Bertolín <sbertolin@solidgear.es> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -49,7 +27,10 @@ namespace OCA\DAV\Connector\Sabre; * License along with this library. If not, see <http://www.gnu.org/licenses/>. * */ - +use OCP\EventDispatcher\IEventDispatcher; +use OCP\ITagManager; +use OCP\ITags; +use OCP\IUserSession; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; @@ -69,12 +50,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { private $server; /** - * @var \OCP\ITagManager - */ - private $tagManager; - - /** - * @var \OCP\ITags + * @var ITags */ private $tagger; @@ -87,17 +63,15 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { private $cachedTags; /** - * @var \Sabre\DAV\Tree - */ - private $tree; - - /** * @param \Sabre\DAV\Tree $tree tree - * @param \OCP\ITagManager $tagManager tag manager + * @param ITagManager $tagManager tag manager */ - public function __construct(\Sabre\DAV\Tree $tree, \OCP\ITagManager $tagManager) { - $this->tree = $tree; - $this->tagManager = $tagManager; + public function __construct( + private \Sabre\DAV\Tree $tree, + private ITagManager $tagManager, + private IEventDispatcher $eventDispatcher, + private IUserSession $userSession, + ) { $this->tagger = null; $this->cachedTags = []; } @@ -120,12 +94,13 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { $this->server = $server; $this->server->on('propFind', [$this, 'handleGetProperties']); $this->server->on('propPatch', [$this, 'handleUpdateProperties']); + $this->server->on('preloadProperties', [$this, 'handlePreloadProperties']); } /** * Returns the tagger * - * @return \OCP\ITags tagger + * @return ITags tagger */ private function getTagger() { if (!$this->tagger) { @@ -139,7 +114,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { * * @param integer $fileId file id * @return array list($tags, $favorite) with $tags as tag array - * and $favorite is a boolean whether the file was favorited + * and $favorite is a boolean whether the file was favorited */ private function getTagsAndFav($fileId) { $isFav = false; @@ -176,6 +151,24 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { } /** + * Prefetches tags for a list of file IDs and caches the results + * + * @param array $fileIds List of file IDs to prefetch tags for + * @return void + */ + private function prefetchTagsForFileIds(array $fileIds) { + $tags = $this->getTagger()->getTagsForObjects($fileIds); + if ($tags === false) { + // the tags API returns false on error... + $tags = []; + } + + foreach ($fileIds as $fileId) { + $this->cachedTags[$fileId] = $tags[$fileId] ?? []; + } + } + + /** * Updates the tags of the given file id * * @param int $fileId @@ -211,36 +204,25 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { */ public function handleGetProperties( PropFind $propFind, - \Sabre\DAV\INode $node + \Sabre\DAV\INode $node, ) { - if (!($node instanceof \OCA\DAV\Connector\Sabre\Node)) { + if (!($node instanceof Node)) { return; } // need prefetch ? - if ($node instanceof \OCA\DAV\Connector\Sabre\Directory + if ($node instanceof Directory && $propFind->getDepth() !== 0 && (!is_null($propFind->getStatus(self::TAGS_PROPERTYNAME)) || !is_null($propFind->getStatus(self::FAVORITE_PROPERTYNAME)) - )) { + )) { // note: pre-fetching only supported for depth <= 1 $folderContent = $node->getChildren(); - $fileIds[] = (int)$node->getId(); + $fileIds = [(int)$node->getId()]; foreach ($folderContent as $info) { $fileIds[] = (int)$info->getId(); } - $tags = $this->getTagger()->getTagsForObjects($fileIds); - if ($tags === false) { - // the tags API returns false on error... - $tags = []; - } - - $this->cachedTags = $this->cachedTags + $tags; - $emptyFileIds = array_diff($fileIds, array_keys($tags)); - // also cache the ones that were not found - foreach ($emptyFileIds as $fileId) { - $this->cachedTags[$fileId] = []; - } + $this->prefetchTagsForFileIds($fileIds); } $isFav = null; @@ -272,7 +254,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { */ public function handleUpdateProperties($path, PropPatch $propPatch) { $node = $this->tree->getNodeForPath($path); - if (!($node instanceof \OCA\DAV\Connector\Sabre\Node)) { + if (!($node instanceof Node)) { return; } @@ -281,7 +263,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { return true; }); - $propPatch->handle(self::FAVORITE_PROPERTYNAME, function ($favState) use ($node) { + $propPatch->handle(self::FAVORITE_PROPERTYNAME, function ($favState) use ($node, $path) { if ((int)$favState === 1 || $favState === 'true') { $this->getTagger()->tagAs($node->getId(), self::TAG_FAVORITE); } else { @@ -296,4 +278,14 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { return 200; }); } + + public function handlePreloadProperties(array $nodes, array $requestProperties): void { + if ( + !in_array(self::FAVORITE_PROPERTYNAME, $requestProperties, true) + && !in_array(self::TAGS_PROPERTYNAME, $requestProperties, true) + ) { + return; + } + $this->prefetchTagsForFileIds(array_map(fn ($node) => $node->getId(), $nodes)); + } } diff --git a/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php new file mode 100644 index 00000000000..f198519b454 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php @@ -0,0 +1,193 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use OC\Streamer; +use OCA\DAV\Connector\Sabre\Exception\Forbidden; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Events\BeforeZipCreatedEvent; +use OCP\Files\File as NcFile; +use OCP\Files\Folder as NcFolder; +use OCP\Files\Node as NcNode; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\DAV\Tree; +use Sabre\HTTP\Request; +use Sabre\HTTP\Response; + +/** + * This plugin allows to download folders accessed by GET HTTP requests on DAV. + * The WebDAV standard explicitly say that GET is not covered and should return what ever the application thinks would be a good representation. + * + * When a collection is accessed using GET, this will provide the content as a archive. + * The type can be set by the `Accept` header (MIME type of zip or tar), or as browser fallback using a `accept` GET parameter. + * It is also possible to only include some child nodes (from the collection it self) by providing a `filter` GET parameter or `X-NC-Files` custom header. + */ +class ZipFolderPlugin extends ServerPlugin { + + /** + * Reference to main server object + */ + private ?Server $server = null; + + public function __construct( + private Tree $tree, + private LoggerInterface $logger, + private IEventDispatcher $eventDispatcher, + ) { + } + + /** + * 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. + */ + public function initialize(Server $server): void { + $this->server = $server; + $this->server->on('method:GET', $this->handleDownload(...), 100); + // low priority to give any other afterMethod:* a chance to fire before we cancel everything + $this->server->on('afterMethod:GET', $this->afterDownload(...), 999); + } + + /** + * Adding a node to the archive streamer. + * This will recursively add new nodes to the stream if the node is a directory. + */ + protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath): void { + // Remove the root path from the filename to make it relative to the requested folder + $filename = str_replace($rootPath, '', $node->getPath()); + + $mtime = $node->getMTime(); + if ($node instanceof NcFile) { + $resource = $node->fopen('rb'); + if ($resource === false) { + $this->logger->info('Cannot read file for zip stream', ['filePath' => $node->getPath()]); + throw new \Sabre\DAV\Exception\ServiceUnavailable('Requested file can currently not be accessed.'); + } + $streamer->addFileFromStream($resource, $filename, $node->getSize(), $mtime); + } elseif ($node instanceof NcFolder) { + $streamer->addEmptyDir($filename, $mtime); + $content = $node->getDirectoryListing(); + foreach ($content as $subNode) { + $this->streamNode($streamer, $subNode, $rootPath); + } + } + } + + /** + * Download a folder as an archive. + * It is possible to filter / limit the files that should be downloaded, + * either by passing (multiple) `X-NC-Files: the-file` headers + * or by setting a `files=JSON_ARRAY_OF_FILES` URL query. + * + * @return false|null + */ + public function handleDownload(Request $request, Response $response): ?bool { + $node = $this->tree->getNodeForPath($request->getPath()); + if (!($node instanceof Directory)) { + // only handle directories + return null; + } + + $query = $request->getQueryParameters(); + + // Get accept header - or if set overwrite with accept GET-param + $accept = $request->getHeaderAsArray('Accept'); + $acceptParam = $query['accept'] ?? ''; + if ($acceptParam !== '') { + $accept = array_map(fn (string $name) => strtolower(trim($name)), explode(',', $acceptParam)); + } + $zipRequest = !empty(array_intersect(['application/zip', 'zip'], $accept)); + $tarRequest = !empty(array_intersect(['application/x-tar', 'tar'], $accept)); + if (!$zipRequest && !$tarRequest) { + // does not accept zip or tar stream + return null; + } + + $files = $request->getHeaderAsArray('X-NC-Files'); + $filesParam = $query['files'] ?? ''; + // The preferred way would be headers, but this is not possible for simple browser requests ("links") + // so we also need to support GET parameters + if ($filesParam !== '') { + $files = json_decode($filesParam); + if (!is_array($files)) { + $files = [$files]; + } + + foreach ($files as $file) { + if (!is_string($file)) { + // we log this as this means either we - or an app - have a bug somewhere or a user is trying invalid things + $this->logger->notice('Invalid files filter parameter for ZipFolderPlugin', ['filter' => $filesParam]); + // no valid parameter so continue with Sabre behavior + return null; + } + } + } + + $folder = $node->getNode(); + $event = new BeforeZipCreatedEvent($folder, $files); + $this->eventDispatcher->dispatchTyped($event); + if ((!$event->isSuccessful()) || $event->getErrorMessage() !== null) { + $errorMessage = $event->getErrorMessage(); + if ($errorMessage === null) { + // Not allowed to download but also no explaining error + // so we abort the ZIP creation and fall back to Sabre default behavior. + return null; + } + // Downloading was denied by an app + throw new Forbidden($errorMessage); + } + + $content = empty($files) ? $folder->getDirectoryListing() : []; + foreach ($files as $path) { + $child = $node->getChild($path); + assert($child instanceof Node); + $content[] = $child->getNode(); + } + + $archiveName = 'download'; + $rootPath = $folder->getPath(); + if (empty($files)) { + // We download the full folder so keep it in the tree + $rootPath = dirname($folder->getPath()); + // Full folder is loaded to rename the archive to the folder name + $archiveName = $folder->getName(); + } + $streamer = new Streamer($tarRequest, -1, count($content)); + $streamer->sendHeaders($archiveName); + // For full folder downloads we also add the folder itself to the archive + if (empty($files)) { + $streamer->addEmptyDir($archiveName); + } + foreach ($content as $node) { + $this->streamNode($streamer, $node, $rootPath); + } + $streamer->finalize(); + return false; + } + + /** + * Tell sabre/dav not to trigger it's own response sending logic as the handleDownload will have already send the response + * + * @return false|null + */ + public function afterDownload(Request $request, Response $response): ?bool { + $node = $this->tree->getNodeForPath($request->getPath()); + if (!($node instanceof Directory)) { + // only handle directories + return null; + } else { + return false; + } + } +} diff --git a/apps/dav/lib/Controller/BirthdayCalendarController.php b/apps/dav/lib/Controller/BirthdayCalendarController.php index 4305d6daaef..f6bfb229a9c 100644 --- a/apps/dav/lib/Controller/BirthdayCalendarController.php +++ b/apps/dav/lib/Controller/BirthdayCalendarController.php @@ -1,31 +1,16 @@ <?php + /** - * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @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\Controller; use OCA\DAV\BackgroundJob\GenerateBirthdayCalendarBackgroundJob; use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\Settings\CalDAVSettings; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\Response; use OCP\BackgroundJob\IJobList; @@ -38,31 +23,6 @@ use OCP\IUserManager; class BirthdayCalendarController extends Controller { /** - * @var IDBConnection - */ - protected $db; - - /** - * @var IConfig - */ - protected $config; - - /** - * @var IUserManager - */ - protected $userManager; - - /** - * @var CalDavBackend - */ - protected $caldavBackend; - - /** - * @var IJobList - */ - protected $jobList; - - /** * BirthdayCalendar constructor. * * @param string $appName @@ -71,30 +31,29 @@ class BirthdayCalendarController extends Controller { * @param IConfig $config * @param IJobList $jobList * @param IUserManager $userManager - * @param CalDavBackend $calDavBackend + * @param CalDavBackend $caldavBackend */ - public function __construct($appName, IRequest $request, - IDBConnection $db, IConfig $config, - IJobList $jobList, - IUserManager $userManager, - CalDavBackend $calDavBackend) { + public function __construct( + $appName, + IRequest $request, + protected IDBConnection $db, + protected IConfig $config, + protected IJobList $jobList, + protected IUserManager $userManager, + protected CalDavBackend $caldavBackend, + ) { parent::__construct($appName, $request); - $this->db = $db; - $this->config = $config; - $this->userManager = $userManager; - $this->jobList = $jobList; - $this->caldavBackend = $calDavBackend; } /** * @return Response - * @AuthorizedAdminSetting(settings=OCA\DAV\Settings\CalDAVSettings) */ + #[AuthorizedAdminSetting(settings: CalDAVSettings::class)] public function enable() { $this->config->setAppValue($this->appName, 'generateBirthdayCalendar', 'yes'); // add background job for each user - $this->userManager->callForSeenUsers(function (IUser $user) { + $this->userManager->callForSeenUsers(function (IUser $user): void { $this->jobList->add(GenerateBirthdayCalendarBackgroundJob::class, [ 'userId' => $user->getUID(), ]); @@ -105,8 +64,8 @@ class BirthdayCalendarController extends Controller { /** * @return Response - * @AuthorizedAdminSetting(settings=OCA\DAV\Settings\CalDAVSettings) */ + #[AuthorizedAdminSetting(settings: CalDAVSettings::class)] public function disable() { $this->config->setAppValue($this->appName, 'generateBirthdayCalendar', 'no'); diff --git a/apps/dav/lib/Controller/DirectController.php b/apps/dav/lib/Controller/DirectController.php index 955400998cf..ea209168123 100644 --- a/apps/dav/lib/Controller/DirectController.php +++ b/apps/dav/lib/Controller/DirectController.php @@ -3,36 +3,23 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Iscle <albertiscle9@gmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Controller; use OCA\DAV\Db\Direct; use OCA\DAV\Db\DirectMapper; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Events\BeforeDirectFileDownloadEvent; use OCP\Files\File; use OCP\Files\IRootFolder; use OCP\IRequest; @@ -41,52 +28,39 @@ use OCP\Security\ISecureRandom; class DirectController extends OCSController { - /** @var IRootFolder */ - private $rootFolder; - - /** @var string */ - private $userId; - - /** @var DirectMapper */ - private $mapper; - - /** @var ISecureRandom */ - private $random; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var IURLGenerator */ - private $urlGenerator; - - - public function __construct(string $appName, - IRequest $request, - IRootFolder $rootFolder, - string $userId, - DirectMapper $mapper, - ISecureRandom $random, - ITimeFactory $timeFactory, - IURLGenerator $urlGenerator) { + public function __construct( + string $appName, + IRequest $request, + private IRootFolder $rootFolder, + private string $userId, + private DirectMapper $mapper, + private ISecureRandom $random, + private ITimeFactory $timeFactory, + private IURLGenerator $urlGenerator, + private IEventDispatcher $eventDispatcher, + ) { parent::__construct($appName, $request); - - $this->rootFolder = $rootFolder; - $this->userId = $userId; - $this->mapper = $mapper; - $this->random = $random; - $this->timeFactory = $timeFactory; - $this->urlGenerator = $urlGenerator; } /** - * @NoAdminRequired + * Get a direct link to a file + * + * @param int $fileId ID of the file + * @param int $expirationTime Duration until the link expires + * @return DataResponse<Http::STATUS_OK, array{url: string}, array{}> + * @throws OCSNotFoundException File not found + * @throws OCSBadRequestException Getting direct link is not possible + * @throws OCSForbiddenException Missing permissions to get direct link + * + * 200: Direct link returned */ + #[NoAdminRequired] public function getUrl(int $fileId, int $expirationTime = 60 * 60 * 8): DataResponse { $userFolder = $this->rootFolder->getUserFolder($this->userId); - $files = $userFolder->getById($fileId); + $file = $userFolder->getFirstNodeById($fileId); - if ($files === []) { + if (!$file) { throw new OCSNotFoundException(); } @@ -94,11 +68,17 @@ class DirectController extends OCSController { throw new OCSBadRequestException('Expiration time should be greater than 0 and less than or equal to ' . (60 * 60 * 24)); } - $file = array_shift($files); if (!($file instanceof File)) { throw new OCSBadRequestException('Direct download only works for files'); } + $event = new BeforeDirectFileDownloadEvent($userFolder->getRelativePath($file->getPath())); + $this->eventDispatcher->dispatchTyped($event); + + if ($event->isSuccessful() === false) { + throw new OCSForbiddenException('Permission denied to download file'); + } + //TODO: at some point we should use the directdownlaod function of storages $direct = new Direct(); $direct->setUserId($this->userId); @@ -110,7 +90,7 @@ class DirectController extends OCSController { $this->mapper->insert($direct); - $url = $this->urlGenerator->getAbsoluteURL('remote.php/direct/'.$token); + $url = $this->urlGenerator->getAbsoluteURL('remote.php/direct/' . $token); return new DataResponse([ 'url' => $url, diff --git a/apps/dav/lib/Controller/ExampleContentController.php b/apps/dav/lib/Controller/ExampleContentController.php new file mode 100644 index 00000000000..e20ee4b7f49 --- /dev/null +++ b/apps/dav/lib/Controller/ExampleContentController.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Controller; + +use OCA\DAV\AppInfo\Application; +use OCA\DAV\Service\ExampleContactService; +use OCA\DAV\Service\ExampleEventService; +use OCP\AppFramework\ApiController; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\DataDownloadResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +class ExampleContentController extends ApiController { + public function __construct( + IRequest $request, + private readonly LoggerInterface $logger, + private readonly ExampleEventService $exampleEventService, + private readonly ExampleContactService $exampleContactService, + ) { + parent::__construct(Application::APP_ID, $request); + } + + #[FrontpageRoute(verb: 'PUT', url: '/api/defaultcontact/config')] + public function setEnableDefaultContact(bool $allow): JSONResponse { + if ($allow && !$this->exampleContactService->defaultContactExists()) { + try { + $this->exampleContactService->setCard(); + } catch (\Exception $e) { + $this->logger->error('Could not create default contact', ['exception' => $e]); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + $this->exampleContactService->setDefaultContactEnabled($allow); + return new JSONResponse([], Http::STATUS_OK); + } + + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/api/defaultcontact/contact')] + public function getDefaultContact(): DataDownloadResponse { + $cardData = $this->exampleContactService->getCard() + ?? file_get_contents(__DIR__ . '/../ExampleContentFiles/exampleContact.vcf'); + return new DataDownloadResponse($cardData, 'example_contact.vcf', 'text/vcard'); + } + + #[FrontpageRoute(verb: 'PUT', url: '/api/defaultcontact/contact')] + public function setDefaultContact(?string $contactData = null) { + if (!$this->exampleContactService->isDefaultContactEnabled()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + $this->exampleContactService->setCard($contactData); + return new JSONResponse([], Http::STATUS_OK); + } + + #[FrontpageRoute(verb: 'POST', url: '/api/exampleEvent/enable')] + public function setCreateExampleEvent(bool $enable): JSONResponse { + $this->exampleEventService->setCreateExampleEvent($enable); + return new JsonResponse([]); + } + + #[FrontpageRoute(verb: 'GET', url: '/api/exampleEvent/event')] + #[NoCSRFRequired] + public function downloadExampleEvent(): DataDownloadResponse { + $exampleEvent = $this->exampleEventService->getExampleEvent(); + return new DataDownloadResponse( + $exampleEvent->getIcs(), + 'example_event.ics', + 'text/calendar', + ); + } + + #[FrontpageRoute(verb: 'POST', url: '/api/exampleEvent/event')] + public function uploadExampleEvent(string $ics): JSONResponse { + if (!$this->exampleEventService->shouldCreateExampleEvent()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $this->exampleEventService->saveCustomExampleEvent($ics); + return new JsonResponse([]); + } + + #[FrontpageRoute(verb: 'DELETE', url: '/api/exampleEvent/event')] + public function deleteExampleEvent(): JSONResponse { + $this->exampleEventService->deleteCustomExampleEvent(); + return new JsonResponse([]); + } + +} diff --git a/apps/dav/lib/Controller/InvitationResponseController.php b/apps/dav/lib/Controller/InvitationResponseController.php index de22e3ba6a9..19eb4097b45 100644 --- a/apps/dav/lib/Controller/InvitationResponseController.php +++ b/apps/dav/lib/Controller/InvitationResponseController.php @@ -3,32 +3,16 @@ declare(strict_types=1); /** - * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Controller; use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IDBConnection; @@ -36,17 +20,9 @@ use OCP\IRequest; use Sabre\VObject\ITip\Message; use Sabre\VObject\Reader; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class InvitationResponseController extends Controller { - /** @var IDBConnection */ - private $db; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var InvitationResponseServer */ - private $responseServer; - /** * InvitationResponseController constructor. * @@ -56,25 +32,25 @@ class InvitationResponseController extends Controller { * @param ITimeFactory $timeFactory * @param InvitationResponseServer $responseServer */ - public function __construct(string $appName, IRequest $request, - IDBConnection $db, ITimeFactory $timeFactory, - InvitationResponseServer $responseServer) { + public function __construct( + string $appName, + IRequest $request, + private IDBConnection $db, + private ITimeFactory $timeFactory, + private InvitationResponseServer $responseServer, + ) { parent::__construct($appName, $request); - $this->db = $db; - $this->timeFactory = $timeFactory; - $this->responseServer = $responseServer; // Don't run `$server->exec()`, because we just need access to the // fully initialized schedule plugin, but we don't want Sabre/DAV // to actually handle and reply to the request } /** - * @PublicPage - * @NoCSRFRequired - * * @param string $token * @return TemplateResponse */ + #[PublicPage] + #[NoCSRFRequired] public function accept(string $token):TemplateResponse { $row = $this->getTokenInformation($token); if (!$row) { @@ -93,12 +69,11 @@ class InvitationResponseController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired - * * @param string $token * @return TemplateResponse */ + #[PublicPage] + #[NoCSRFRequired] public function decline(string $token):TemplateResponse { $row = $this->getTokenInformation($token); if (!$row) { @@ -118,12 +93,11 @@ class InvitationResponseController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired - * * @param string $token * @return TemplateResponse */ + #[PublicPage] + #[NoCSRFRequired] public function options(string $token):TemplateResponse { return new TemplateResponse($this->appName, 'schedule-response-options', [ 'token' => $token @@ -131,24 +105,21 @@ class InvitationResponseController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired - * * @param string $token * * @return TemplateResponse */ + #[PublicPage] + #[NoCSRFRequired] public function processMoreOptionsResult(string $token):TemplateResponse { $partstat = $this->request->getParam('partStat'); - $guests = (int) $this->request->getParam('guests'); - $comment = $this->request->getParam('comment'); $row = $this->getTokenInformation($token); if (!$row || !\in_array($partstat, ['ACCEPTED', 'DECLINED', 'TENTATIVE'])) { return new TemplateResponse($this->appName, 'schedule-response-error', [], 'guest'); } - $iTipMessage = $this->buildITipResponse($row, $partstat, $guests, $comment); + $iTipMessage = $this->buildITipResponse($row, $partstat); $this->responseServer->handleITipMessage($iTipMessage); if ($iTipMessage->getScheduleStatus() === '1.2') { return new TemplateResponse($this->appName, 'schedule-response-success', [], 'guest'); @@ -168,15 +139,16 @@ class InvitationResponseController extends Controller { $query->select('*') ->from('calendar_invitations') ->where($query->expr()->eq('token', $query->createNamedParameter($token))); - $stmt = $query->execute(); + $stmt = $query->executeQuery(); $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $stmt->closeCursor(); if (!$row) { return null; } $currentTime = $this->timeFactory->getTime(); - if (((int) $row['expiration']) < $currentTime) { + if (((int)$row['expiration']) < $currentTime) { return null; } @@ -190,8 +162,7 @@ class InvitationResponseController extends Controller { * @param string|null $comment * @return Message */ - private function buildITipResponse(array $row, string $partStat, int $guests = null, - string $comment = null):Message { + private function buildITipResponse(array $row, string $partStat):Message { $iTipMessage = new Message(); $iTipMessage->uid = $row['uid']; $iTipMessage->component = 'VEVENT'; @@ -225,19 +196,7 @@ EOF; $row['uid'], $row['sequence'] ?? 0, $row['recurrenceid'] ?? '' ])); $vEvent = $vObject->{'VEVENT'}; - /** @var \Sabre\VObject\Property\ICalendar\CalAddress $attendee */ - $attendee = $vEvent->{'ATTENDEE'}; - $vEvent->DTSTAMP = date('Ymd\\THis\\Z', $this->timeFactory->getTime()); - - if ($comment) { - $attendee->add('X-RESPONSE-COMMENT', $comment); - $vEvent->add('COMMENT', $comment); - } - if ($guests) { - $attendee->add('X-NUM-GUESTS', $guests); - } - $iTipMessage->message = $vObject; return $iTipMessage; diff --git a/apps/dav/lib/Controller/OutOfOfficeController.php b/apps/dav/lib/Controller/OutOfOfficeController.php new file mode 100644 index 00000000000..d3516d092e8 --- /dev/null +++ b/apps/dav/lib/Controller/OutOfOfficeController.php @@ -0,0 +1,189 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Controller; + +use DateTimeImmutable; +use OCA\DAV\ResponseDefinitions; +use OCA\DAV\Service\AbsenceService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\User\IAvailabilityCoordinator; +use function mb_strlen; + +/** + * @psalm-import-type DAVOutOfOfficeData from ResponseDefinitions + * @psalm-import-type DAVCurrentOutOfOfficeData from ResponseDefinitions + */ +class OutOfOfficeController extends OCSController { + + public function __construct( + string $appName, + IRequest $request, + private IUserManager $userManager, + private ?IUserSession $userSession, + private AbsenceService $absenceService, + private IAvailabilityCoordinator $coordinator, + ) { + parent::__construct($appName, $request); + } + + /** + * Get the currently configured out-of-office data of a user + * + * @param string $userId The user id to get out-of-office data for. + * @return DataResponse<Http::STATUS_OK, DAVCurrentOutOfOfficeData, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}> + * + * 200: Out-of-office data + * 404: No out-of-office data was found + */ + #[NoAdminRequired] + public function getCurrentOutOfOfficeData(string $userId): DataResponse { + $user = $this->userManager->get($userId); + if ($user === null) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + try { + $data = $this->absenceService->getCurrentAbsence($user); + if ($data === null) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + } catch (DoesNotExistException) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + + return new DataResponse($data->jsonSerialize()); + } + + /** + * Get the configured out-of-office data of a user. + * + * @param string $userId The user id to get out-of-office data for. + * @return DataResponse<Http::STATUS_OK, DAVOutOfOfficeData, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}> + * + * 200: Out-of-office data + * 404: No out-of-office data was found + */ + #[NoAdminRequired] + public function getOutOfOffice(string $userId): DataResponse { + try { + $data = $this->absenceService->getAbsence($userId); + if ($data === null) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + } catch (DoesNotExistException) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + + return new DataResponse([ + 'id' => $data->getId(), + 'userId' => $data->getUserId(), + 'firstDay' => $data->getFirstDay(), + 'lastDay' => $data->getLastDay(), + 'status' => $data->getStatus(), + 'message' => $data->getMessage(), + 'replacementUserId' => $data->getReplacementUserId(), + 'replacementUserDisplayName' => $data->getReplacementUserDisplayName(), + ]); + } + + /** + * Set out-of-office absence + * + * @param string $firstDay First day of the absence in format `YYYY-MM-DD` + * @param string $lastDay Last day of the absence in format `YYYY-MM-DD` + * @param string $status Short text that is set as user status during the absence + * @param string $message Longer multiline message that is shown to others during the absence + * @param ?string $replacementUserId User id of the replacement user + * @return DataResponse<Http::STATUS_OK, DAVOutOfOfficeData, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'firstDay'|'statusLength'}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, null, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}> + * + * 200: Absence data + * 400: When validation fails, e.g. data range error or the first day is not before the last day + * 401: When the user is not logged in + * 404: When the replacementUserId was provided but replacement user was not found + */ + #[NoAdminRequired] + public function setOutOfOffice( + string $firstDay, + string $lastDay, + string $status, + string $message, + ?string $replacementUserId, + ): DataResponse { + $user = $this->userSession?->getUser(); + if ($user === null) { + return new DataResponse(null, Http::STATUS_UNAUTHORIZED); + } + if (mb_strlen($status) > 100) { + return new DataResponse(['error' => 'statusLength'], Http::STATUS_BAD_REQUEST); + } + + $replacementUser = null; + if ($replacementUserId !== null) { + $replacementUser = $this->userManager->get($replacementUserId); + if ($replacementUser === null) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); + } + } + + $parsedFirstDay = new DateTimeImmutable($firstDay); + $parsedLastDay = new DateTimeImmutable($lastDay); + if ($parsedFirstDay->getTimestamp() > $parsedLastDay->getTimestamp()) { + return new DataResponse(['error' => 'firstDay'], Http::STATUS_BAD_REQUEST); + } + + $data = $this->absenceService->createOrUpdateAbsence( + $user, + $firstDay, + $lastDay, + $status, + $message, + $replacementUserId, + $replacementUser?->getDisplayName() + ); + $this->coordinator->clearCache($user->getUID()); + + return new DataResponse([ + 'id' => $data->getId(), + 'userId' => $data->getUserId(), + 'firstDay' => $data->getFirstDay(), + 'lastDay' => $data->getLastDay(), + 'status' => $data->getStatus(), + 'message' => $data->getMessage(), + 'replacementUserId' => $data->getReplacementUserId(), + 'replacementUserDisplayName' => $data->getReplacementUserDisplayName(), + ]); + } + + /** + * Clear the out-of-office + * + * @return DataResponse<Http::STATUS_OK|Http::STATUS_UNAUTHORIZED, null, array{}> + * + * 200: When the absence was cleared successfully + * 401: When the user is not logged in + */ + #[NoAdminRequired] + public function clearOutOfOffice(): DataResponse { + $user = $this->userSession?->getUser(); + if ($user === null) { + return new DataResponse(null, Http::STATUS_UNAUTHORIZED); + } + + $this->absenceService->clearAbsence($user); + $this->coordinator->clearCache($user->getUID()); + return new DataResponse(null); + } +} diff --git a/apps/dav/lib/Controller/UpcomingEventsController.php b/apps/dav/lib/Controller/UpcomingEventsController.php new file mode 100644 index 00000000000..a5d54f44754 --- /dev/null +++ b/apps/dav/lib/Controller/UpcomingEventsController.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Controller; + +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\UpcomingEvent; +use OCA\DAV\CalDAV\UpcomingEventsService; +use OCA\DAV\ResponseDefinitions; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; + +/** + * @psalm-import-type DAVUpcomingEvent from ResponseDefinitions + */ +class UpcomingEventsController extends OCSController { + public function __construct( + IRequest $request, + private ?string $userId, + private UpcomingEventsService $service, + ) { + parent::__construct(Application::APP_ID, $request); + } + + /** + * Get information about upcoming events + * + * @param string|null $location location/URL to filter by + * @return DataResponse<Http::STATUS_OK, array{events: list<DAVUpcomingEvent>}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, null, array{}> + * + * 200: Upcoming events + * 401: When not authenticated + */ + #[NoAdminRequired] + public function getEvents(?string $location = null): DataResponse { + if ($this->userId === null) { + return new DataResponse(null, Http::STATUS_UNAUTHORIZED); + } + + return new DataResponse([ + 'events' => array_values(array_map(fn (UpcomingEvent $e) => $e->jsonSerialize(), $this->service->getEvents( + $this->userId, + $location, + ))), + ]); + } + +} diff --git a/apps/dav/lib/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php index acee65cd00d..f9a4f8ee986 100644 --- a/apps/dav/lib/DAV/CustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php @@ -1,36 +1,33 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2017, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Robin Appelman <robin@icewind.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 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ + namespace OCA\DAV\DAV; -use OCA\DAV\Connector\Sabre\Node; +use Exception; +use OCA\DAV\CalDAV\Calendar; +use OCA\DAV\CalDAV\CalendarObject; +use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\Connector\Sabre\Directory; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\IUser; +use Sabre\DAV\Exception as DavException; use Sabre\DAV\PropertyStorage\Backend\BackendInterface; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; +use Sabre\DAV\Server; use Sabre\DAV\Tree; +use Sabre\DAV\Xml\Property\Complex; +use Sabre\DAV\Xml\Property\Href; +use Sabre\DAV\Xml\Property\LocalHref; +use Sabre\Xml\ParseException; +use Sabre\Xml\Service as XmlService; + use function array_intersect; class CustomPropertiesBackend implements BackendInterface { @@ -39,6 +36,26 @@ class CustomPropertiesBackend implements BackendInterface { private const TABLE_NAME = 'properties'; /** + * Value is stored as string. + */ + public const PROPERTY_TYPE_STRING = 1; + + /** + * Value is stored as XML fragment. + */ + public const PROPERTY_TYPE_XML = 2; + + /** + * Value is stored as a property object. + */ + public const PROPERTY_TYPE_OBJECT = 3; + + /** + * Value is stored as a {DAV:}href string. + */ + public const PROPERTY_TYPE_HREF = 4; + + /** * Ignored properties * * @var string[] @@ -49,58 +66,35 @@ class CustomPropertiesBackend implements BackendInterface { '{DAV:}getetag', '{DAV:}quota-used-bytes', '{DAV:}quota-available-bytes', - '{http://owncloud.org/ns}permissions', - '{http://owncloud.org/ns}downloadURL', - '{http://owncloud.org/ns}dDC', - '{http://owncloud.org/ns}size', - '{http://nextcloud.org/ns}is-encrypted', - - // Currently, returning null from any propfind handler would still trigger the backend, - // so we add all known Nextcloud custom properties in here to avoid that - - // text app - '{http://nextcloud.org/ns}rich-workspace', - '{http://nextcloud.org/ns}rich-workspace-file', - // groupfolders - '{http://nextcloud.org/ns}acl-enabled', - '{http://nextcloud.org/ns}acl-can-manage', - '{http://nextcloud.org/ns}acl-list', - '{http://nextcloud.org/ns}inherited-acl-list', - '{http://nextcloud.org/ns}group-folder-id', - // files_lock - '{http://nextcloud.org/ns}lock', - '{http://nextcloud.org/ns}lock-owner-type', - '{http://nextcloud.org/ns}lock-owner', - '{http://nextcloud.org/ns}lock-owner-displayname', - '{http://nextcloud.org/ns}lock-owner-editor', - '{http://nextcloud.org/ns}lock-time', - '{http://nextcloud.org/ns}lock-timeout', - '{http://nextcloud.org/ns}lock-token', ]; /** - * Properties set by one user, readable by all others + * Allowed properties for the oc/nc namespace, all other properties in the namespace are ignored * - * @var array[] + * @var string[] */ - private const PUBLISHED_READ_ONLY_PROPERTIES = [ - '{urn:ietf:params:xml:ns:caldav}calendar-availability', + private const ALLOWED_NC_PROPERTIES = [ + '{http://owncloud.org/ns}calendar-enabled', + '{http://owncloud.org/ns}enabled', ]; /** - * @var Tree - */ - private $tree; - - /** - * @var IDBConnection + * Properties set by one user, readable by all others + * + * @var string[] */ - private $connection; + private const PUBLISHED_READ_ONLY_PROPERTIES = [ + '{urn:ietf:params:xml:ns:caldav}calendar-availability', + '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL', + ]; /** - * @var IUser + * Map of custom XML elements to parse when trying to deserialize an instance of + * \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_* */ - private $user; + private const COMPLEX_XML_ELEMENT_MAP = [ + '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class, + ]; /** * Properties cache @@ -108,6 +102,7 @@ class CustomPropertiesBackend implements BackendInterface { * @var array */ private $userCache = []; + private XmlService $xmlService; /** * @param Tree $tree node tree @@ -115,12 +110,17 @@ class CustomPropertiesBackend implements BackendInterface { * @param IUser $user owner of the tree and properties */ public function __construct( - Tree $tree, - IDBConnection $connection, - IUser $user) { - $this->tree = $tree; - $this->connection = $connection; - $this->user = $user; + private Server $server, + private Tree $tree, + private IDBConnection $connection, + private IUser $user, + private DefaultCalendarValidator $defaultCalendarValidator, + ) { + $this->xmlService = new XmlService(); + $this->xmlService->elementMap = array_merge( + $this->xmlService->elementMap, + self::COMPLEX_XML_ELEMENT_MAP, + ); } /** @@ -133,15 +133,14 @@ class CustomPropertiesBackend implements BackendInterface { public function propFind($path, PropFind $propFind) { $requestedProps = $propFind->get404Properties(); - // these might appear - $requestedProps = array_diff( + $requestedProps = array_filter( $requestedProps, - self::IGNORED_PROPERTIES + $this->isPropertyAllowed(...), ); // substr of calendars/ => path is inside the CalDAV component // two '/' => this a calendar (no calendar-home nor calendar object) - if (substr($path, 0, 10) === 'calendars/' && substr_count($path, '/') === 2) { + if (str_starts_with($path, 'calendars/') && substr_count($path, '/') === 2) { $allRequestedProps = $propFind->getRequestedProperties(); $customPropertiesForShares = [ '{DAV:}displayname', @@ -159,20 +158,80 @@ class CustomPropertiesBackend implements BackendInterface { } } + // substr of addressbooks/ => path is inside the CardDAV component + // three '/' => this a addressbook (no addressbook-home nor contact object) + if (str_starts_with($path, 'addressbooks/') && substr_count($path, '/') === 3) { + $allRequestedProps = $propFind->getRequestedProperties(); + $customPropertiesForShares = [ + '{DAV:}displayname', + ]; + + foreach ($customPropertiesForShares as $customPropertyForShares) { + if (in_array($customPropertyForShares, $allRequestedProps, true)) { + $requestedProps[] = $customPropertyForShares; + } + } + } + + // substr of principals/users/ => path is a user principal + // two '/' => this a principal collection (and not some child object) + if (str_starts_with($path, 'principals/users/') && substr_count($path, '/') === 2) { + $allRequestedProps = $propFind->getRequestedProperties(); + $customProperties = [ + '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL', + ]; + + foreach ($customProperties as $customProperty) { + if (in_array($customProperty, $allRequestedProps, true)) { + $requestedProps[] = $customProperty; + } + } + } + if (empty($requestedProps)) { return; } + $node = $this->tree->getNodeForPath($path); + if ($node instanceof Directory && $propFind->getDepth() !== 0) { + $this->cacheDirectory($path, $node); + } + + if ($node instanceof CalendarObject) { + // No custom properties supported on individual events + return; + } + // First fetch the published properties (set by another user), then get the ones set by // the current user. If both are set then the latter as priority. foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) { + try { + $this->validateProperty($path, $propName, $propValue); + } catch (DavException $e) { + continue; + } $propFind->set($propName, $propValue); } foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) { + try { + $this->validateProperty($path, $propName, $propValue); + } catch (DavException $e) { + continue; + } $propFind->set($propName, $propValue); } } + private function isPropertyAllowed(string $property): bool { + if (in_array($property, self::IGNORED_PROPERTIES)) { + return false; + } + if (str_starts_with($property, '{http://owncloud.org/ns}') || str_starts_with($property, '{http://nextcloud.org/ns}')) { + return in_array($property, self::ALLOWED_NC_PROPERTIES); + } + return true; + } + /** * Updates properties for a path * @@ -212,14 +271,39 @@ class CustomPropertiesBackend implements BackendInterface { */ public function move($source, $destination) { $statement = $this->connection->prepare( - 'UPDATE `*PREFIX*properties` SET `propertypath` = ?' . - ' WHERE `userid` = ? AND `propertypath` = ?' + 'UPDATE `*PREFIX*properties` SET `propertypath` = ?' + . ' WHERE `userid` = ? AND `propertypath` = ?' ); $statement->execute([$this->formatPath($destination), $this->user->getUID(), $this->formatPath($source)]); $statement->closeCursor(); } /** + * Validate the value of a property. Will throw if a value is invalid. + * + * @throws DavException The value of the property is invalid + */ + private function validateProperty(string $path, string $propName, mixed $propValue): void { + switch ($propName) { + case '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL': + /** @var Href $propValue */ + $href = $propValue->getHref(); + if ($href === null) { + throw new DavException('Href is empty'); + } + + // $path is the principal here as this prop is only set on principals + $node = $this->tree->getNodeForPath($href); + if (!($node instanceof Calendar) || $node->getOwner() !== $path) { + throw new DavException('No such calendar'); + } + + $this->defaultCalendarValidator->validateScheduleDefaultCalendar($node); + break; + } + } + + /** * @param string $path * @param string[] $requestedProperties * @@ -239,13 +323,48 @@ class CustomPropertiesBackend implements BackendInterface { $result = $qb->executeQuery(); $props = []; while ($row = $result->fetch()) { - $props[$row['propertyname']] = $row['propertyvalue']; + $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']); } $result->closeCursor(); return $props; } /** + * prefetch all user properties in a directory + */ + private function cacheDirectory(string $path, Directory $node): void { + $prefix = ltrim($path . '/', '/'); + $query = $this->connection->getQueryBuilder(); + $query->select('name', 'p.propertypath', 'p.propertyname', 'p.propertyvalue', 'p.valuetype') + ->from('filecache', 'f') + ->hintShardKey('storage', $node->getNode()->getMountPoint()->getNumericStorageId()) + ->leftJoin('f', 'properties', 'p', $query->expr()->eq('p.propertypath', $query->func()->concat( + $query->createNamedParameter($prefix), + 'f.name' + )), + ) + ->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->orX( + $query->expr()->eq('p.userid', $query->createNamedParameter($this->user->getUID())), + $query->expr()->isNull('p.userid'), + )); + $result = $query->executeQuery(); + + $propsByPath = []; + + while ($row = $result->fetch()) { + $childPath = $prefix . $row['name']; + if (!isset($propsByPath[$childPath])) { + $propsByPath[$childPath] = []; + } + if (isset($row['propertyname'])) { + $propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']); + } + } + $this->userCache = array_merge($this->userCache, $propsByPath); + } + + /** * Returns a list of properties for the given path and current user * * @param string $path @@ -271,7 +390,7 @@ class CustomPropertiesBackend implements BackendInterface { // request only a subset $sql .= ' AND `propertyname` in (?)'; $whereValues[] = $requestedProperties; - $whereTypes[] = \Doctrine\DBAL\Connection::PARAM_STR_ARRAY; + $whereTypes[] = IQueryBuilder::PARAM_STR_ARRAY; } $result = $this->connection->executeQuery( @@ -282,7 +401,7 @@ class CustomPropertiesBackend implements BackendInterface { $props = []; while ($row = $result->fetch()) { - $props[$row['propertyname']] = $row['propertyvalue']; + $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']); } $result->closeCursor(); @@ -292,68 +411,58 @@ class CustomPropertiesBackend implements BackendInterface { } /** - * Update properties - * - * @param string $path path for which to update properties - * @param array $properties array of properties to update - * - * @return bool + * @throws Exception */ - private function updateProperties(string $path, array $properties) { - $deleteStatement = 'DELETE FROM `*PREFIX*properties`' . - ' WHERE `userid` = ? AND `propertypath` = ? AND `propertyname` = ?'; - - $insertStatement = 'INSERT INTO `*PREFIX*properties`' . - ' (`userid`,`propertypath`,`propertyname`,`propertyvalue`) VALUES(?,?,?,?)'; - - $updateStatement = 'UPDATE `*PREFIX*properties` SET `propertyvalue` = ?' . - ' WHERE `userid` = ? AND `propertypath` = ? AND `propertyname` = ?'; - + private function updateProperties(string $path, array $properties): bool { // TODO: use "insert or update" strategy ? $existing = $this->getUserProperties($path, []); - $this->connection->beginTransaction(); - foreach ($properties as $propertyName => $propertyValue) { - // If it was null, we need to delete the property - if (is_null($propertyValue)) { - if (array_key_exists($propertyName, $existing)) { - $this->connection->executeUpdate($deleteStatement, - [ - $this->user->getUID(), - $this->formatPath($path), - $propertyName, - ] - ); - } - } else { - if ($propertyValue instanceOf \Sabre\DAV\Xml\Property\Complex) { - $propertyValue = $propertyValue->getXml(); - } elseif (!is_string($propertyValue)) { - $propertyValue = (string)$propertyValue; - } - if (!array_key_exists($propertyName, $existing)) { - $this->connection->executeUpdate($insertStatement, - [ - $this->user->getUID(), - $this->formatPath($path), - $propertyName, - $propertyValue, - ] - ); + try { + $this->connection->beginTransaction(); + foreach ($properties as $propertyName => $propertyValue) { + // common parameters for all queries + $dbParameters = [ + 'userid' => $this->user->getUID(), + 'propertyPath' => $this->formatPath($path), + 'propertyName' => $propertyName, + ]; + + // If it was null, we need to delete the property + if (is_null($propertyValue)) { + if (array_key_exists($propertyName, $existing)) { + $deleteQuery = $deleteQuery ?? $this->createDeleteQuery(); + $deleteQuery + ->setParameters($dbParameters) + ->executeStatement(); + } } else { - $this->connection->executeUpdate($updateStatement, - [ - $propertyValue, - $this->user->getUID(), - $this->formatPath($path), - $propertyName, - ] + [$value, $valueType] = $this->encodeValueForDatabase( + $path, + $propertyName, + $propertyValue, ); + $dbParameters['propertyValue'] = $value; + $dbParameters['valueType'] = $valueType; + + if (!array_key_exists($propertyName, $existing)) { + $insertQuery = $insertQuery ?? $this->createInsertQuery(); + $insertQuery + ->setParameters($dbParameters) + ->executeStatement(); + } else { + $updateQuery = $updateQuery ?? $this->createUpdateQuery(); + $updateQuery + ->setParameters($dbParameters) + ->executeStatement(); + } } } - } - $this->connection->commit(); - unset($this->userCache[$path]); + $this->connection->commit(); + unset($this->userCache[$path]); + } catch (Exception $e) { + $this->connection->rollBack(); + throw $e; + } return true; } @@ -367,8 +476,122 @@ class CustomPropertiesBackend implements BackendInterface { private function formatPath(string $path): string { if (strlen($path) > 250) { return sha1($path); + } + + return $path; + } + + /** + * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails + * @throws DavException If the property value is invalid + */ + private function encodeValueForDatabase(string $path, string $name, mixed $value): array { + // Try to parse a more specialized property type first + if ($value instanceof Complex) { + $xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri()); + $value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value; + } + + if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') { + $value = $this->encodeDefaultCalendarUrl($value); + } + + try { + $this->validateProperty($path, $name, $value); + } catch (DavException $e) { + throw new DavException( + "Property \"$name\" has an invalid value: " . $e->getMessage(), + 0, + $e, + ); + } + + if (is_scalar($value)) { + $valueType = self::PROPERTY_TYPE_STRING; + } elseif ($value instanceof Complex) { + $valueType = self::PROPERTY_TYPE_XML; + $value = $value->getXml(); + } elseif ($value instanceof Href) { + $valueType = self::PROPERTY_TYPE_HREF; + $value = $value->getHref(); } else { - return $path; + $valueType = self::PROPERTY_TYPE_OBJECT; + // serialize produces null character + // these can not be properly stored in some databases and need to be replaced + $value = str_replace(chr(0), '\x00', serialize($value)); + } + return [$value, $valueType]; + } + + /** + * @return mixed|Complex|string + */ + private function decodeValueFromDatabase(string $value, int $valueType) { + switch ($valueType) { + case self::PROPERTY_TYPE_XML: + return new Complex($value); + case self::PROPERTY_TYPE_HREF: + return new Href($value); + case self::PROPERTY_TYPE_OBJECT: + // some databases can not handel null characters, these are custom encoded during serialization + // this custom encoding needs to be first reversed before unserializing + return unserialize(str_replace('\x00', chr(0), $value)); + case self::PROPERTY_TYPE_STRING: + default: + return $value; + } + } + + private function encodeDefaultCalendarUrl(Href $value): Href { + $href = $value->getHref(); + if ($href === null) { + return $value; + } + + if (!str_starts_with($href, '/')) { + return $value; + } + + try { + // Build path relative to the dav base URI to be used later to find the node + $value = new LocalHref($this->server->calculateUri($href) . '/'); + } catch (DavException\Forbidden) { + // Not existing calendars will be handled later when the value is validated } + + return $value; + } + + private function createDeleteQuery(): IQueryBuilder { + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete('properties') + ->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid'))) + ->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath'))) + ->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName'))); + return $deleteQuery; + } + + private function createInsertQuery(): IQueryBuilder { + $insertQuery = $this->connection->getQueryBuilder(); + $insertQuery->insert('properties') + ->values([ + 'userid' => $insertQuery->createParameter('userid'), + 'propertypath' => $insertQuery->createParameter('propertyPath'), + 'propertyname' => $insertQuery->createParameter('propertyName'), + 'propertyvalue' => $insertQuery->createParameter('propertyValue'), + 'valuetype' => $insertQuery->createParameter('valueType'), + ]); + return $insertQuery; + } + + private function createUpdateQuery(): IQueryBuilder { + $updateQuery = $this->connection->getQueryBuilder(); + $updateQuery->update('properties') + ->set('propertyvalue', $updateQuery->createParameter('propertyValue')) + ->set('valuetype', $updateQuery->createParameter('valueType')) + ->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid'))) + ->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath'))) + ->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName'))); + return $updateQuery; } } diff --git a/apps/dav/lib/DAV/GroupPrincipalBackend.php b/apps/dav/lib/DAV/GroupPrincipalBackend.php index f1f15fd61a6..77ba45182c9 100644 --- a/apps/dav/lib/DAV/GroupPrincipalBackend.php +++ b/apps/dav/lib/DAV/GroupPrincipalBackend.php @@ -1,30 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2018, Georg Ehrke - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV; @@ -42,32 +21,17 @@ use Sabre\DAVACL\PrincipalBackend\BackendInterface; class GroupPrincipalBackend implements BackendInterface { public const PRINCIPAL_PREFIX = 'principals/groups'; - /** @var IGroupManager */ - private $groupManager; - - /** @var IUserSession */ - private $userSession; - - /** @var IShareManager */ - private $shareManager; - /** @var IConfig */ - private $config; - /** - * @param IGroupManager $IGroupManager + * @param IGroupManager $groupManager * @param IUserSession $userSession * @param IShareManager $shareManager */ public function __construct( - IGroupManager $IGroupManager, - IUserSession $userSession, - IShareManager $shareManager, - IConfig $config + private IGroupManager $groupManager, + private IUserSession $userSession, + private IShareManager $shareManager, + private IConfig $config, ) { - $this->groupManager = $IGroupManager; - $this->userSession = $userSession; - $this->shareManager = $shareManager; - $this->config = $config; } /** @@ -87,8 +51,10 @@ class GroupPrincipalBackend implements BackendInterface { $principals = []; if ($prefixPath === self::PRINCIPAL_PREFIX) { - foreach ($this->groupManager->search('') as $user) { - $principals[] = $this->groupToPrincipal($user); + foreach ($this->groupManager->search('') as $group) { + if (!$group->hideFromCollaboration()) { + $principals[] = $this->groupToPrincipal($group); + } } } @@ -104,7 +70,7 @@ class GroupPrincipalBackend implements BackendInterface { * @return array */ public function getPrincipalByPath($path) { - $elements = explode('/', $path, 3); + $elements = explode('/', $path, 3); if ($elements[0] !== 'principals') { return null; } @@ -114,7 +80,7 @@ class GroupPrincipalBackend implements BackendInterface { $name = urldecode($elements[2]); $group = $this->groupManager->get($name); - if (!is_null($group)) { + if ($group !== null && !$group->hideFromCollaboration()) { return $this->groupToPrincipal($group); } @@ -223,6 +189,10 @@ class GroupPrincipalBackend implements BackendInterface { $groups = $this->groupManager->search($value, $searchLimit); $results[] = array_reduce($groups, function (array $carry, IGroup $group) use ($restrictGroups) { + if ($group->hideFromCollaboration()) { + return $carry; + } + $gid = $group->getGID(); // is sharing restricted to groups only? if ($restrictGroups !== false) { @@ -288,7 +258,7 @@ class GroupPrincipalBackend implements BackendInterface { $restrictGroups = $this->groupManager->getUserGroupIds($user); } - if (strpos($uri, 'principal:principals/groups/') === 0) { + if (str_starts_with($uri, 'principal:principals/groups/')) { $name = urlencode(substr($uri, 28)); if ($restrictGroups !== false && !\in_array($name, $restrictGroups, true)) { return null; diff --git a/apps/dav/lib/DAV/PublicAuth.php b/apps/dav/lib/DAV/PublicAuth.php index 83874ab0d4d..c2b4ada173a 100644 --- a/apps/dav/lib/DAV/PublicAuth.php +++ b/apps/dav/lib/DAV/PublicAuth.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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\DAV; @@ -68,9 +53,9 @@ class PublicAuth implements BackendInterface { */ public function check(RequestInterface $request, ResponseInterface $response) { if ($this->isRequestPublic($request)) { - return [true, "principals/system/public"]; + return [true, 'principals/system/public']; } - return [false, "No public access to this resource."]; + return [false, 'No public access to this resource.']; } /** @@ -86,7 +71,7 @@ class PublicAuth implements BackendInterface { private function isRequestPublic(RequestInterface $request) { $url = $request->getPath(); $matchingUrls = array_filter($this->publicURLs, function ($publicUrl) use ($url) { - return strpos($url, $publicUrl, 0) === 0; + return str_starts_with($url, $publicUrl); }); return !empty($matchingUrls); } diff --git a/apps/dav/lib/DAV/Sharing/Backend.php b/apps/dav/lib/DAV/Sharing/Backend.php index 0f675ea4c15..d60f5cca7c6 100644 --- a/apps/dav/lib/DAV/Sharing/Backend.php +++ b/apps/dav/lib/DAV/Sharing/Backend.php @@ -1,178 +1,106 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license 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\DAV\Sharing; use OCA\DAV\Connector\Sabre\Principal; -use OCP\IDBConnection; +use OCP\AppFramework\Db\TTransactional; +use OCP\ICache; +use OCP\ICacheFactory; use OCP\IGroupManager; use OCP\IUserManager; +use Psr\Log\LoggerInterface; -class Backend { - - /** @var IDBConnection */ - private $db; - /** @var IUserManager */ - private $userManager; - /** @var IGroupManager */ - private $groupManager; - /** @var Principal */ - private $principalBackend; - /** @var string */ - private $resourceType; - +abstract class Backend { + use TTransactional; public const ACCESS_OWNER = 1; + public const ACCESS_READ_WRITE = 2; public const ACCESS_READ = 3; - - /** - * @param IDBConnection $db - * @param IUserManager $userManager - * @param IGroupManager $groupManager - * @param Principal $principalBackend - * @param string $resourceType - */ - public function __construct(IDBConnection $db, IUserManager $userManager, IGroupManager $groupManager, Principal $principalBackend, $resourceType) { - $this->db = $db; - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->principalBackend = $principalBackend; - $this->resourceType = $resourceType; + // 4 is already in use for public calendars + public const ACCESS_UNSHARED = 5; + + private ICache $shareCache; + + public function __construct( + private IUserManager $userManager, + private IGroupManager $groupManager, + private Principal $principalBackend, + private ICacheFactory $cacheFactory, + private SharingService $service, + private LoggerInterface $logger, + ) { + $this->shareCache = $this->cacheFactory->createInMemory(); } /** - * @param IShareable $shareable - * @param string[] $add - * @param string[] $remove + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove */ - public function updateShares(IShareable $shareable, array $add, array $remove) { + public function updateShares(IShareable $shareable, array $add, array $remove, array $oldShares = []): void { + $this->shareCache->clear(); foreach ($add as $element) { $principal = $this->principalBackend->findByUri($element['href'], ''); - if ($principal !== '') { - $this->shareWith($shareable, $element); + if (empty($principal)) { + continue; } - } - foreach ($remove as $element) { - $principal = $this->principalBackend->findByUri($element, ''); - if ($principal !== '') { - $this->unshare($shareable, $element); + + // We need to validate manually because some principals are only virtual + // i.e. Group principals + $principalparts = explode('/', $principal, 3); + if (count($principalparts) !== 3 || $principalparts[0] !== 'principals' || !in_array($principalparts[1], ['users', 'groups', 'circles'], true)) { + // Invalid principal + continue; } - } - } - /** - * @param IShareable $shareable - * @param string $element - */ - private function shareWith($shareable, $element) { - $user = $element['href']; - $parts = explode(':', $user, 2); - if ($parts[0] !== 'principal') { - return; - } + // Don't add share for owner + if ($shareable->getOwner() !== null && strcasecmp($shareable->getOwner(), $principal) === 0) { + continue; + } - // don't share with owner - if ($shareable->getOwner() === $parts[1]) { - return; - } + $principalparts[2] = urldecode($principalparts[2]); + if (($principalparts[1] === 'users' && !$this->userManager->userExists($principalparts[2])) + || ($principalparts[1] === 'groups' && !$this->groupManager->groupExists($principalparts[2]))) { + // User or group does not exist + continue; + } - $principal = explode('/', $parts[1], 3); - if (count($principal) !== 3 || $principal[0] !== 'principals' || !in_array($principal[1], ['users', 'groups', 'circles'], true)) { - // Invalid principal - return; - } + $access = Backend::ACCESS_READ; + if (isset($element['readOnly'])) { + $access = $element['readOnly'] ? Backend::ACCESS_READ : Backend::ACCESS_READ_WRITE; + } - $principal[2] = urldecode($principal[2]); - if (($principal[1] === 'users' && !$this->userManager->userExists($principal[2])) || - ($principal[1] === 'groups' && !$this->groupManager->groupExists($principal[2]))) { - // User or group does not exist - return; + $this->service->shareWith($shareable->getResourceId(), $principal, $access); } + foreach ($remove as $element) { + $principal = $this->principalBackend->findByUri($element, ''); + if (empty($principal)) { + continue; + } - // remove the share if it already exists - $this->unshare($shareable, $element['href']); - $access = self::ACCESS_READ; - if (isset($element['readOnly'])) { - $access = $element['readOnly'] ? self::ACCESS_READ : self::ACCESS_READ_WRITE; - } + // Don't add unshare for owner + if ($shareable->getOwner() !== null && strcasecmp($shareable->getOwner(), $principal) === 0) { + continue; + } - $query = $this->db->getQueryBuilder(); - $query->insert('dav_shares') - ->values([ - 'principaluri' => $query->createNamedParameter($parts[1]), - 'type' => $query->createNamedParameter($this->resourceType), - 'access' => $query->createNamedParameter($access), - 'resourceid' => $query->createNamedParameter($shareable->getResourceId()) - ]); - $query->execute(); + // Delete any possible direct shares (since the frontend does not separate between them) + $this->service->deleteShare($shareable->getResourceId(), $principal); + } } - /** - * @param $resourceId - */ - public function deleteAllShares($resourceId) { - $query = $this->db->getQueryBuilder(); - $query->delete('dav_shares') - ->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId))) - ->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType))) - ->execute(); + public function deleteAllShares(int $resourceId): void { + $this->shareCache->clear(); + $this->service->deleteAllShares($resourceId); } - public function deleteAllSharesByUser($principaluri) { - $query = $this->db->getQueryBuilder(); - $query->delete('dav_shares') - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principaluri))) - ->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType))) - ->execute(); - } - - /** - * @param IShareable $shareable - * @param string $element - */ - private function unshare($shareable, $element) { - $parts = explode(':', $element, 2); - if ($parts[0] !== 'principal') { - return; - } - - // don't share with owner - if ($shareable->getOwner() === $parts[1]) { - return; - } - - $query = $this->db->getQueryBuilder(); - $query->delete('dav_shares') - ->where($query->expr()->eq('resourceid', $query->createNamedParameter($shareable->getResourceId()))) - ->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType))) - ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($parts[1]))) - ; - $query->execute(); + public function deleteAllSharesByUser(string $principaluri): void { + $this->shareCache->clear(); + $this->service->deleteAllSharesByUser($principaluri); } /** @@ -183,45 +111,67 @@ class Backend { * * commonName - Optional, for example a first + last name * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. * * readOnly - boolean - * * summary - Optional, a description for the share * * @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) { - $query = $this->db->getQueryBuilder(); - $result = $query->select(['principaluri', 'access']) - ->from('dav_shares') - ->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId))) - ->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType))) - ->groupBy(['principaluri', 'access']) - ->execute(); + public function getShares(int $resourceId): array { + $cached = $this->shareCache->get((string)$resourceId); + if ($cached) { + return $cached; + } + $rows = $this->service->getShares($resourceId); $shares = []; - while ($row = $result->fetch()) { + foreach ($rows as $row) { $p = $this->principalBackend->getPrincipalByPath($row['principaluri']); $shares[] = [ - 'href' => "principal:${row['principaluri']}", - 'commonName' => isset($p['{DAV:}displayname']) ? $p['{DAV:}displayname'] : '', + 'href' => "principal:{$row['principaluri']}", + 'commonName' => isset($p['{DAV:}displayname']) ? (string)$p['{DAV:}displayname'] : '', 'status' => 1, - 'readOnly' => (int) $row['access'] === self::ACCESS_READ, - '{http://owncloud.org/ns}principal' => $row['principaluri'], - '{http://owncloud.org/ns}group-share' => is_null($p) + 'readOnly' => (int)$row['access'] === Backend::ACCESS_READ, + '{http://owncloud.org/ns}principal' => (string)$row['principaluri'], + '{http://owncloud.org/ns}group-share' => isset($p['uri']) && (str_starts_with($p['uri'], 'principals/groups') || str_starts_with($p['uri'], 'principals/circles')) ]; } - + $this->shareCache->set((string)$resourceId, $shares); return $shares; } + public function preloadShares(array $resourceIds): void { + $resourceIds = array_filter($resourceIds, function (int $resourceId) { + return empty($this->shareCache->get((string)$resourceId)); + }); + if (empty($resourceIds)) { + return; + } + + $rows = $this->service->getSharesForIds($resourceIds); + $sharesByResource = array_fill_keys($resourceIds, []); + foreach ($rows as $row) { + $resourceId = (int)$row['resourceid']; + $p = $this->principalBackend->getPrincipalByPath($row['principaluri']); + $sharesByResource[$resourceId][] = [ + 'href' => "principal:{$row['principaluri']}", + 'commonName' => isset($p['{DAV:}displayname']) ? (string)$p['{DAV:}displayname'] : '', + 'status' => 1, + 'readOnly' => (int)$row['access'] === self::ACCESS_READ, + '{http://owncloud.org/ns}principal' => (string)$row['principaluri'], + '{http://owncloud.org/ns}group-share' => isset($p['uri']) && str_starts_with($p['uri'], 'principals/groups') + ]; + $this->shareCache->set((string)$resourceId, $sharesByResource[$resourceId]); + } + } + /** * For shared resources the sharee is set in the ACL of the resource * * @param int $resourceId - * @param array $acl - * @return array + * @param list<array{privilege: string, principal: string, protected: bool}> $acl + * @param list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> $shares + * @return list<array{principal: string, privilege: string, protected: bool}> */ - public function applyShareAcl($resourceId, $acl) { - $shares = $this->getShares($resourceId); + public function applyShareAcl(array $shares, array $acl): array { foreach ($shares as $share) { $acl[] = [ 'privilege' => '{DAV:}read', @@ -234,7 +184,7 @@ class Backend { 'principal' => $share['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}principal'], 'protected' => true, ]; - } elseif ($this->resourceType === 'calendar') { + } elseif (in_array($this->service->getResourceType(), ['calendar','addressbook'])) { // Allow changing the properties of read only calendars, // so users can change the visibility. $acl[] = [ @@ -246,4 +196,45 @@ class Backend { } return $acl; } + + public function unshare(IShareable $shareable, string $principalUri): bool { + $this->shareCache->clear(); + + $principal = $this->principalBackend->findByUri($principalUri, ''); + if (empty($principal)) { + return false; + } + + if ($shareable->getOwner() === $principal) { + return false; + } + + // Delete any possible direct shares (since the frontend does not separate between them) + $this->service->deleteShare($shareable->getResourceId(), $principal); + + $needsUnshare = $this->hasAccessByGroupOrCirclesMembership( + $shareable->getResourceId(), + $principal + ); + + if ($needsUnshare) { + $this->service->unshare($shareable->getResourceId(), $principal); + } + + return true; + } + + private function hasAccessByGroupOrCirclesMembership(int $resourceId, string $principal) { + $memberships = array_merge( + $this->principalBackend->getGroupMembership($principal, true), + $this->principalBackend->getCircleMembership($principal) + ); + + $shares = array_column( + $this->service->getShares($resourceId), + 'principaluri' + ); + + return count(array_intersect($memberships, $shares)) > 0; + } } diff --git a/apps/dav/lib/DAV/Sharing/IShareable.php b/apps/dav/lib/DAV/Sharing/IShareable.php index 3833e026696..d83079f6975 100644 --- a/apps/dav/lib/DAV/Sharing/IShareable.php +++ b/apps/dav/lib/DAV/Sharing/IShareable.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV\Sharing; @@ -40,16 +24,14 @@ interface IShareable extends INode { * 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 + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove */ - public function updateShares(array $add, array $remove); + public function updateShares(array $add, array $remove): void; /** * Returns the list of people whom this resource is shared with. @@ -59,19 +41,15 @@ interface IShareable extends INode { * * commonName - Optional, for example a first + last name * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. * * 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; - /** - * @return int - */ - public function getResourceId(); + public function getResourceId(): int; /** - * @return string + * @return ?string */ public function getOwner(); } diff --git a/apps/dav/lib/DAV/Sharing/Plugin.php b/apps/dav/lib/DAV/Sharing/Plugin.php index a4b2cd3681c..03e63813bab 100644 --- a/apps/dav/lib/DAV/Sharing/Plugin.php +++ b/apps/dav/lib/DAV/Sharing/Plugin.php @@ -1,32 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.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-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV\Sharing; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\CalendarHome; use OCA\DAV\Connector\Sabre\Auth; use OCA\DAV\DAV\Sharing\Xml\Invite; use OCA\DAV\DAV\Sharing\Xml\ShareRequest; +use OCP\AppFramework\Http; use OCP\IConfig; use OCP\IRequest; use Sabre\DAV\Exception\NotFound; @@ -41,26 +27,18 @@ class Plugin extends ServerPlugin { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; public const NS_NEXTCLOUD = 'http://nextcloud.com/ns'; - /** @var Auth */ - private $auth; - - /** @var IRequest */ - private $request; - - /** @var IConfig */ - private $config; - /** * Plugin constructor. * - * @param Auth $authBackEnd + * @param Auth $auth * @param IRequest $request * @param IConfig $config */ - public function __construct(Auth $authBackEnd, IRequest $request, IConfig $config) { - $this->auth = $authBackEnd; - $this->request = $request; - $this->config = $config; + public function __construct( + private Auth $auth, + private IRequest $request, + private IConfig $config, + ) { } /** @@ -111,7 +89,7 @@ class Plugin extends ServerPlugin { $this->server->xml->elementMap['{' . Plugin::NS_OWNCLOUD . '}invite'] = Invite::class; $this->server->on('method:POST', [$this, 'httpPost']); - $this->server->on('propFind', [$this, 'propFind']); + $this->server->on('propFind', [$this, 'propFind']); } /** @@ -125,8 +103,8 @@ class Plugin 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; } @@ -180,7 +158,7 @@ class Plugin extends ServerPlugin { $node->updateShares($message->set, $message->remove); - $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'); @@ -201,6 +179,20 @@ class Plugin extends ServerPlugin { * @return void */ public function propFind(PropFind $propFind, INode $node) { + if ($node instanceof CalendarHome && $propFind->getDepth() === 1) { + $backend = $node->getCalDAVBackend(); + if ($backend instanceof CalDavBackend) { + $calendars = $node->getChildren(); + $calendars = array_filter($calendars, function (INode $node) { + return $node instanceof IShareable; + }); + /** @var int[] $resourceIds */ + $resourceIds = array_map(function (IShareable $node) { + return $node->getResourceId(); + }, $calendars); + $backend->preloadShares($resourceIds); + } + } if ($node instanceof IShareable) { $propFind->handle('{' . Plugin::NS_OWNCLOUD . '}invite', function () use ($node) { return new Invite( diff --git a/apps/dav/lib/DAV/Sharing/SharingMapper.php b/apps/dav/lib/DAV/Sharing/SharingMapper.php new file mode 100644 index 00000000000..e4722208189 --- /dev/null +++ b/apps/dav/lib/DAV/Sharing/SharingMapper.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\DAV\Sharing; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +class SharingMapper { + public function __construct( + private IDBConnection $db, + ) { + } + + protected function getSharesForIdByAccess(int $resourceId, string $resourceType, bool $sharesWithAccess): array { + $query = $this->db->getQueryBuilder(); + $query->select(['principaluri', 'access']) + ->from('dav_shares') + ->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR))) + ->groupBy(['principaluri', 'access']); + + if ($sharesWithAccess) { + $query->andWhere($query->expr()->neq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT))); + } else { + $query->andWhere($query->expr()->eq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT))); + } + + $result = $query->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + return $rows; + } + + public function getSharesForId(int $resourceId, string $resourceType): array { + return $this->getSharesForIdByAccess($resourceId, $resourceType, true); + } + + public function getUnsharesForId(int $resourceId, string $resourceType): array { + return $this->getSharesForIdByAccess($resourceId, $resourceType, false); + } + + public function getSharesForIds(array $resourceIds, string $resourceType): array { + $query = $this->db->getQueryBuilder(); + $result = $query->select(['resourceid', 'principaluri', 'access']) + ->from('dav_shares') + ->where($query->expr()->in('resourceid', $query->createNamedParameter($resourceIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->andWhere($query->expr()->neq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT))) + ->groupBy(['principaluri', 'access', 'resourceid']) + ->executeQuery(); + + $rows = $result->fetchAll(); + $result->closeCursor(); + return $rows; + } + + public function unshare(int $resourceId, string $resourceType, string $principal): void { + $query = $this->db->getQueryBuilder(); + $query->insert('dav_shares') + ->values([ + 'principaluri' => $query->createNamedParameter($principal), + 'type' => $query->createNamedParameter($resourceType), + 'access' => $query->createNamedParameter(Backend::ACCESS_UNSHARED), + 'resourceid' => $query->createNamedParameter($resourceId) + ]); + $query->executeStatement(); + } + + public function share(int $resourceId, string $resourceType, int $access, string $principal): void { + $query = $this->db->getQueryBuilder(); + $query->insert('dav_shares') + ->values([ + 'principaluri' => $query->createNamedParameter($principal), + 'type' => $query->createNamedParameter($resourceType), + 'access' => $query->createNamedParameter($access), + 'resourceid' => $query->createNamedParameter($resourceId) + ]); + $query->executeStatement(); + } + + public function deleteShare(int $resourceId, string $resourceType, string $principal): void { + $query = $this->db->getQueryBuilder(); + $query->delete('dav_shares'); + $query->where( + $query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT)), + $query->expr()->eq('type', $query->createNamedParameter($resourceType)), + $query->expr()->eq('principaluri', $query->createNamedParameter($principal)) + ); + $query->executeStatement(); + + } + + public function deleteAllShares(int $resourceId, string $resourceType): void { + $query = $this->db->getQueryBuilder(); + $query->delete('dav_shares') + ->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->executeStatement(); + } + + public function deleteAllSharesByUser(string $principaluri, string $resourceType): void { + $query = $this->db->getQueryBuilder(); + $query->delete('dav_shares') + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principaluri))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->executeStatement(); + } + + public function getSharesByPrincipals(array $principals, string $resourceType): array { + $query = $this->db->getQueryBuilder(); + $result = $query->select(['id', 'principaluri', 'type', 'access', 'resourceid']) + ->from('dav_shares') + ->where($query->expr()->in('principaluri', $query->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->orderBy('id') + ->executeQuery(); + + $rows = $result->fetchAll(); + $result->closeCursor(); + + return $rows; + } + + public function deleteUnsharesByPrincipal(string $principal, string $resourceType): void { + $query = $this->db->getQueryBuilder(); + $query->delete('dav_shares') + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principal))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->andWhere($query->expr()->eq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } +} diff --git a/apps/dav/lib/DAV/Sharing/SharingService.php b/apps/dav/lib/DAV/Sharing/SharingService.php new file mode 100644 index 00000000000..11459e12d74 --- /dev/null +++ b/apps/dav/lib/DAV/Sharing/SharingService.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\DAV\Sharing; + +abstract class SharingService { + protected string $resourceType = ''; + public function __construct( + protected SharingMapper $mapper, + ) { + } + + public function getResourceType(): string { + return $this->resourceType; + } + public function shareWith(int $resourceId, string $principal, int $access): void { + // remove the share if it already exists + $this->mapper->deleteShare($resourceId, $this->getResourceType(), $principal); + $this->mapper->share($resourceId, $this->getResourceType(), $access, $principal); + } + + public function unshare(int $resourceId, string $principal): void { + $this->mapper->unshare($resourceId, $this->getResourceType(), $principal); + } + + public function deleteShare(int $resourceId, string $principal): void { + $this->mapper->deleteShare($resourceId, $this->getResourceType(), $principal); + } + + public function deleteAllShares(int $resourceId): void { + $this->mapper->deleteAllShares($resourceId, $this->getResourceType()); + } + + public function deleteAllSharesByUser(string $principaluri): void { + $this->mapper->deleteAllSharesByUser($principaluri, $this->getResourceType()); + } + + public function getShares(int $resourceId): array { + return $this->mapper->getSharesForId($resourceId, $this->getResourceType()); + } + + public function getUnshares(int $resourceId): array { + return $this->mapper->getUnsharesForId($resourceId, $this->getResourceType()); + } + + public function getSharesForIds(array $resourceIds): array { + return $this->mapper->getSharesForIds($resourceIds, $this->getResourceType()); + } +} diff --git a/apps/dav/lib/DAV/Sharing/Xml/Invite.php b/apps/dav/lib/DAV/Sharing/Xml/Invite.php index 161a8dd0ebf..7a20dbe6df7 100644 --- a/apps/dav/lib/DAV/Sharing/Xml/Invite.php +++ b/apps/dav/lib/DAV/Sharing/Xml/Invite.php @@ -1,28 +1,10 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (C) fruux GmbH (https://fruux.com/) - * @copyright Copyright (C) fruux GmbH (https://fruux.com/) - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-FileCopyrightText: fruux GmbH (https://fruux.com/) + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV\Sharing\Xml; @@ -45,21 +27,6 @@ use Sabre\Xml\XmlSerializable; class Invite implements XmlSerializable { /** - * The list of users a calendar has been shared to. - * - * @var array - */ - protected $users; - - /** - * The organizer contains information about the person who shared the - * object. - * - * @var array|null - */ - protected $organizer; - - /** * Creates the property. * * Users is an array. Each element of the array has the following @@ -85,9 +52,17 @@ class Invite implements XmlSerializable { * * @param array $users */ - public function __construct(array $users, array $organizer = null) { - $this->users = $users; - $this->organizer = $organizer; + public function __construct( + /** + * The list of users a calendar has been shared to. + */ + protected array $users, + /** + * The organizer contains information about the person who shared the + * object. + */ + protected ?array $organizer = null, + ) { } /** @@ -100,7 +75,7 @@ class Invite implements XmlSerializable { } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * Use the $writer argument to write its own xml serialization. * diff --git a/apps/dav/lib/DAV/Sharing/Xml/ShareRequest.php b/apps/dav/lib/DAV/Sharing/Xml/ShareRequest.php index eb5d7d4661d..aefb39c5701 100644 --- a/apps/dav/lib/DAV/Sharing/Xml/ShareRequest.php +++ b/apps/dav/lib/DAV/Sharing/Xml/ShareRequest.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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\DAV\Sharing\Xml; @@ -27,24 +12,21 @@ use Sabre\Xml\Reader; use Sabre\Xml\XmlDeserializable; class ShareRequest implements XmlDeserializable { - public $set = []; - - public $remove = []; - /** * Constructor * * @param array $set * @param array $remove */ - public function __construct(array $set, array $remove) { - $this->set = $set; - $this->remove = $remove; + public function __construct( + public array $set, + public array $remove, + ) { } public static function xmlDeserialize(Reader $reader) { $elements = $reader->parseInnerTree([ - '{' . Plugin::NS_OWNCLOUD. '}set' => 'Sabre\\Xml\\Element\\KeyValue', + '{' . Plugin::NS_OWNCLOUD . '}set' => 'Sabre\\Xml\\Element\\KeyValue', '{' . Plugin::NS_OWNCLOUD . '}remove' => 'Sabre\\Xml\\Element\\KeyValue', ]); @@ -62,8 +44,8 @@ class ShareRequest implements XmlDeserializable { $set[] = [ 'href' => $sharee['{DAV:}href'], - 'commonName' => isset($sharee[$commonName]) ? $sharee[$commonName] : null, - 'summary' => isset($sharee[$sumElem]) ? $sharee[$sumElem] : null, + 'commonName' => $sharee[$commonName] ?? null, + 'summary' => $sharee[$sumElem] ?? null, 'readOnly' => !array_key_exists('{' . Plugin::NS_OWNCLOUD . '}read-write', $sharee), ]; break; diff --git a/apps/dav/lib/DAV/SystemPrincipalBackend.php b/apps/dav/lib/DAV/SystemPrincipalBackend.php index e5b9a20037f..9760d68f05f 100644 --- a/apps/dav/lib/DAV/SystemPrincipalBackend.php +++ b/apps/dav/lib/DAV/SystemPrincipalBackend.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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\DAV; @@ -60,7 +45,7 @@ class SystemPrincipalBackend extends AbstractBackend { } /** - * Returns a specific principal, specified by it's path. + * Returns a specific principal, specified by its path. * The returned structure should be the exact same as from * getPrincipalsByPrefix. * @@ -87,7 +72,7 @@ class SystemPrincipalBackend extends AbstractBackend { } /** - * Updates one ore more webdav properties on a principal. + * Updates one or more webdav properties on a principal. * * The list of mutations is stored in a Sabre\DAV\PropPatch object. * To do the actual updates, you must tell this object which properties diff --git a/apps/dav/lib/DAV/ViewOnlyPlugin.php b/apps/dav/lib/DAV/ViewOnlyPlugin.php new file mode 100644 index 00000000000..9b9615b8063 --- /dev/null +++ b/apps/dav/lib/DAV/ViewOnlyPlugin.php @@ -0,0 +1,114 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2019 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\DAV\DAV; + +use OCA\DAV\Connector\Sabre\Exception\Forbidden; +use OCA\DAV\Connector\Sabre\File as DavFile; +use OCA\Files_Versions\Sabre\VersionFile; +use OCP\Files\Folder; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\ISharedStorage; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; + +/** + * Sabre plugin for restricting file share receiver download: + */ +class ViewOnlyPlugin extends ServerPlugin { + private ?Server $server = null; + + public function __construct( + private ?Folder $userFolder, + ) { + } + + /** + * 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. + */ + public function initialize(Server $server): void { + $this->server = $server; + //priority 90 to make sure the plugin is called before + //Sabre\DAV\CorePlugin::httpGet + $this->server->on('method:GET', [$this, 'checkViewOnly'], 90); + $this->server->on('method:COPY', [$this, 'checkViewOnly'], 90); + $this->server->on('method:MOVE', [$this, 'checkViewOnly'], 90); + } + + /** + * Disallow download via DAV Api in case file being received share + * and having special permission + * + * @throws Forbidden + * @throws NotFoundException + */ + public function checkViewOnly(RequestInterface $request): bool { + $path = $request->getPath(); + + try { + assert($this->server !== null); + $davNode = $this->server->tree->getNodeForPath($path); + if ($davNode instanceof DavFile) { + // Restrict view-only to nodes which are shared + $node = $davNode->getNode(); + } elseif ($davNode instanceof VersionFile) { + $node = $davNode->getVersion()->getSourceFile(); + $currentUserId = $this->userFolder?->getOwner()?->getUID(); + // The version source file is relative to the owner storage. + // But we need the node from the current user perspective. + if ($node->getOwner()->getUID() !== $currentUserId) { + $nodes = $this->userFolder->getById($node->getId()); + $node = array_pop($nodes); + if (!$node) { + throw new NotFoundException('Version file not accessible by current user'); + } + } + } else { + return true; + } + + $storage = $node->getStorage(); + + if (!$storage->instanceOfStorage(ISharedStorage::class)) { + return true; + } + + // Extract extra permissions + /** @var ISharedStorage $storage */ + $share = $storage->getShare(); + $attributes = $share->getAttributes(); + if ($attributes === null) { + return true; + } + + // We have two options here, if download is disabled, but viewing is allowed, + // we still allow the GET request to return the file content. + $canDownload = $attributes->getAttribute('permissions', 'download'); + if (!$share->canSeeContent()) { + throw new Forbidden('Access to this shared resource has been denied because its download permission is disabled.'); + } + + // If download is disabled, we disable the COPY and MOVE methods even if the + // shareapi_allow_view_without_download is set to true. + if ($request->getMethod() !== 'GET' && ($canDownload !== null && !$canDownload)) { + throw new Forbidden('Access to this shared resource has been denied because its download permission is disabled.'); + } + } catch (NotFound $e) { + // File not found + } + + return true; + } +} diff --git a/apps/dav/lib/Db/Absence.php b/apps/dav/lib/Db/Absence.php new file mode 100644 index 00000000000..d7cd46087c3 --- /dev/null +++ b/apps/dav/lib/Db/Absence.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Db; + +use DateTime; +use Exception; +use InvalidArgumentException; +use JsonSerializable; +use OC\User\OutOfOfficeData; +use OCP\AppFramework\Db\Entity; +use OCP\IUser; +use OCP\User\IOutOfOfficeData; + +/** + * @method string getUserId() + * @method void setUserId(string $userId) + * @method string getFirstDay() + * @method void setFirstDay(string $firstDay) + * @method string getLastDay() + * @method void setLastDay(string $lastDay) + * @method string getStatus() + * @method void setStatus(string $status) + * @method string getMessage() + * @method void setMessage(string $message) + * @method string getReplacementUserId() + * @method void setReplacementUserId(?string $replacementUserId) + * @method string getReplacementUserDisplayName() + * @method void setReplacementUserDisplayName(?string $replacementUserDisplayName) + */ +class Absence extends Entity implements JsonSerializable { + protected string $userId = ''; + + /** Inclusive, formatted as YYYY-MM-DD */ + protected string $firstDay = ''; + + /** Inclusive, formatted as YYYY-MM-DD */ + protected string $lastDay = ''; + + protected string $status = ''; + + protected string $message = ''; + + protected ?string $replacementUserId = null; + + protected ?string $replacementUserDisplayName = null; + + public function __construct() { + $this->addType('userId', 'string'); + $this->addType('firstDay', 'string'); + $this->addType('lastDay', 'string'); + $this->addType('status', 'string'); + $this->addType('message', 'string'); + $this->addType('replacementUserId', 'string'); + $this->addType('replacementUserDisplayName', 'string'); + } + + public function toOutOufOfficeData(IUser $user, string $timezone): IOutOfOfficeData { + if ($user->getUID() !== $this->getUserId()) { + throw new InvalidArgumentException("The user doesn't match the user id of this absence! Expected " . $this->getUserId() . ', got ' . $user->getUID()); + } + if ($this->getId() === null) { + throw new Exception('Creating out-of-office data without ID'); + } + + $tz = new \DateTimeZone($timezone); + $startDate = new DateTime($this->getFirstDay(), $tz); + $endDate = new DateTime($this->getLastDay(), $tz); + $endDate->setTime(23, 59); + return new OutOfOfficeData( + (string)$this->getId(), + $user, + $startDate->getTimestamp(), + $endDate->getTimestamp(), + $this->getStatus(), + $this->getMessage(), + $this->getReplacementUserId(), + $this->getReplacementUserDisplayName(), + ); + } + + public function jsonSerialize(): array { + return [ + 'userId' => $this->userId, + 'firstDay' => $this->firstDay, + 'lastDay' => $this->lastDay, + 'status' => $this->status, + 'message' => $this->message, + 'replacementUserId' => $this->replacementUserId, + 'replacementUserDisplayName' => $this->replacementUserDisplayName, + ]; + } +} diff --git a/apps/dav/lib/Db/AbsenceMapper.php b/apps/dav/lib/Db/AbsenceMapper.php new file mode 100644 index 00000000000..1214a123236 --- /dev/null +++ b/apps/dav/lib/Db/AbsenceMapper.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<Absence> + */ +class AbsenceMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'dav_absence', Absence::class); + } + + /** + * @throws DoesNotExistException + * @throws \OCP\DB\Exception + */ + public function findById(int $id): Absence { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq( + 'id', + $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), + IQueryBuilder::PARAM_INT), + ); + try { + return $this->findEntity($qb); + } catch (MultipleObjectsReturnedException $e) { + // Won't happen as id is the primary key + throw new \RuntimeException( + 'The impossible has happened! The query returned multiple absence settings for one user.', + 0, + $e, + ); + } + } + + /** + * @throws DoesNotExistException + * @throws \OCP\DB\Exception + */ + public function findByUserId(string $userId): Absence { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq( + 'user_id', + $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), + IQueryBuilder::PARAM_STR), + ); + try { + return $this->findEntity($qb); + } catch (MultipleObjectsReturnedException $e) { + // Won't happen as there is a unique index on user_id + throw new \RuntimeException( + 'The impossible has happened! The query returned multiple absence settings for one user.', + 0, + $e, + ); + } + } + + /** + * @throws \OCP\DB\Exception + */ + public function deleteByUserId(string $userId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq( + 'user_id', + $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), + IQueryBuilder::PARAM_STR), + ); + $qb->executeStatement(); + } +} diff --git a/apps/dav/lib/Db/Direct.php b/apps/dav/lib/Db/Direct.php index ca2586ab2e0..4e4a12d225f 100644 --- a/apps/dav/lib/Db/Direct.php +++ b/apps/dav/lib/Db/Direct.php @@ -3,29 +3,13 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Db; use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; /** * @method string getUserId() @@ -51,9 +35,9 @@ class Direct extends Entity { protected $expiration; public function __construct() { - $this->addType('userId', 'string'); - $this->addType('fileId', 'int'); - $this->addType('token', 'string'); - $this->addType('expiration', 'int'); + $this->addType('userId', Types::STRING); + $this->addType('fileId', Types::INTEGER); + $this->addType('token', Types::STRING); + $this->addType('expiration', Types::INTEGER); } } diff --git a/apps/dav/lib/Db/DirectMapper.php b/apps/dav/lib/Db/DirectMapper.php index c0ed10b97c6..4fedac35b72 100644 --- a/apps/dav/lib/Db/DirectMapper.php +++ b/apps/dav/lib/Db/DirectMapper.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Db; @@ -62,6 +45,6 @@ class DirectMapper extends QBMapper { $qb->expr()->lt('expiration', $qb->createNamedParameter($expiration)) ); - $qb->execute(); + $qb->executeStatement(); } } diff --git a/apps/dav/lib/Db/Property.php b/apps/dav/lib/Db/Property.php new file mode 100644 index 00000000000..96c5f75ef4f --- /dev/null +++ b/apps/dav/lib/Db/Property.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Db; + +use OCP\AppFramework\Db\Entity; + +/** + * @method string getUserid() + * @method string getPropertypath() + * @method string getPropertyname() + * @method string getPropertyvalue() + */ +class Property extends Entity { + + /** @var string|null */ + protected $userid; + + /** @var string|null */ + protected $propertypath; + + /** @var string|null */ + protected $propertyname; + + /** @var string|null */ + protected $propertyvalue; + + /** @var int|null */ + protected $valuetype; + +} diff --git a/apps/dav/lib/Db/PropertyMapper.php b/apps/dav/lib/Db/PropertyMapper.php new file mode 100644 index 00000000000..1789194ee7a --- /dev/null +++ b/apps/dav/lib/Db/PropertyMapper.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<Property> + */ +class PropertyMapper extends QBMapper { + + private const TABLE_NAME = 'properties'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, self::TABLE_NAME, Property::class); + } + + /** + * @return Property[] + */ + public function findPropertyByPathAndName(string $userId, string $path, string $name): array { + $selectQb = $this->db->getQueryBuilder(); + $selectQb->select('*') + ->from(self::TABLE_NAME) + ->where( + $selectQb->expr()->eq('userid', $selectQb->createNamedParameter($userId)), + $selectQb->expr()->eq('propertypath', $selectQb->createNamedParameter($path)), + $selectQb->expr()->eq('propertyname', $selectQb->createNamedParameter($name)), + ); + return $this->findEntities($selectQb); + } + + /** + * @return Property[] + */ + public function findPropertiesByPath(string $userId, string $path): array { + $selectQb = $this->db->getQueryBuilder(); + $selectQb->select('*') + ->from(self::TABLE_NAME) + ->where( + $selectQb->expr()->eq('userid', $selectQb->createNamedParameter($userId)), + $selectQb->expr()->eq('propertypath', $selectQb->createNamedParameter($path)), + ); + return $this->findEntities($selectQb); + } + +} diff --git a/apps/dav/lib/Direct/DirectFile.php b/apps/dav/lib/Direct/DirectFile.php index a4a1999aca7..7f41dd65f41 100644 --- a/apps/dav/lib/Direct/DirectFile.php +++ b/apps/dav/lib/Direct/DirectFile.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Direct; @@ -36,21 +18,14 @@ use Sabre\DAV\Exception\NotFound; use Sabre\DAV\IFile; class DirectFile implements IFile { - /** @var Direct */ - private $direct; - - /** @var IRootFolder */ - private $rootFolder; - /** @var File */ private $file; - private $eventDispatcher; - - public function __construct(Direct $direct, IRootFolder $rootFolder, IEventDispatcher $eventDispatcher) { - $this->direct = $direct; - $this->rootFolder = $rootFolder; - $this->eventDispatcher = $eventDispatcher; + public function __construct( + private Direct $direct, + private IRootFolder $rootFolder, + private IEventDispatcher $eventDispatcher, + ) { } public function put($data) { @@ -77,6 +52,10 @@ class DirectFile implements IFile { return $this->file->getEtag(); } + /** + * @psalm-suppress ImplementedReturnTypeMismatch \Sabre\DAV\IFile::getSize signature does not support 32bit + * @return int|float + */ public function getSize() { $this->getFile(); @@ -104,13 +83,16 @@ class DirectFile implements IFile { private function getFile() { if ($this->file === null) { $userFolder = $this->rootFolder->getUserFolder($this->direct->getUserId()); - $files = $userFolder->getById($this->direct->getFileId()); + $file = $userFolder->getFirstNodeById($this->direct->getFileId()); - if ($files === []) { + if (!$file) { throw new NotFound(); } + if (!$file instanceof File) { + throw new Forbidden('direct download not allowed on directories'); + } - $this->file = array_shift($files); + $this->file = $file; } return $this->file; diff --git a/apps/dav/lib/Direct/DirectHome.php b/apps/dav/lib/Direct/DirectHome.php index a385cd8f39d..ac411c9b52f 100644 --- a/apps/dav/lib/Direct/DirectHome.php +++ b/apps/dav/lib/Direct/DirectHome.php @@ -3,36 +3,18 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Direct; -use OC\Security\Bruteforce\Throttler; use OCA\DAV\Db\DirectMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IRootFolder; use OCP\IRequest; +use OCP\Security\Bruteforce\IThrottler; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\DAV\Exception\NotFound; @@ -40,36 +22,14 @@ use Sabre\DAV\ICollection; class DirectHome implements ICollection { - /** @var IRootFolder */ - private $rootFolder; - - /** @var DirectMapper */ - private $mapper; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var Throttler */ - private $throttler; - - /** @var IRequest */ - private $request; - private $eventDispatcher; - public function __construct( - IRootFolder $rootFolder, - DirectMapper $mapper, - ITimeFactory $timeFactory, - Throttler $throttler, - IRequest $request, - IEventDispatcher $eventDispatcher + private IRootFolder $rootFolder, + private DirectMapper $mapper, + private ITimeFactory $timeFactory, + private IThrottler $throttler, + private IRequest $request, + private IEventDispatcher $eventDispatcher, ) { - $this->rootFolder = $rootFolder; - $this->mapper = $mapper; - $this->timeFactory = $timeFactory; - $this->throttler = $throttler; - $this->request = $request; - $this->eventDispatcher = $eventDispatcher; } public function createFile($name, $data = null) { @@ -91,9 +51,9 @@ class DirectHome implements ICollection { return new DirectFile($direct, $this->rootFolder, $this->eventDispatcher); } catch (DoesNotExistException $e) { - // Since the token space is so huge only throttle on non exsisting token + // Since the token space is so huge only throttle on non-existing token $this->throttler->registerAttempt('directlink', $this->request->getRemoteAddress()); - $this->throttler->sleepDelay($this->request->getRemoteAddress(), 'directlink'); + $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), 'directlink'); throw new NotFound(); } diff --git a/apps/dav/lib/Direct/Server.php b/apps/dav/lib/Direct/Server.php index 0ce5798571d..957f6f99b34 100644 --- a/apps/dav/lib/Direct/Server.php +++ b/apps/dav/lib/Direct/Server.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Direct; diff --git a/apps/dav/lib/Direct/ServerFactory.php b/apps/dav/lib/Direct/ServerFactory.php index 05587ab4c2c..473439361c2 100644 --- a/apps/dav/lib/Direct/ServerFactory.php +++ b/apps/dav/lib/Direct/ServerFactory.php @@ -3,31 +3,11 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Direct; -use OC\Security\Bruteforce\Throttler; use OCA\DAV\Connector\Sabre\MaintenancePlugin; use OCA\DAV\Db\DirectMapper; use OCP\AppFramework\Utility\ITimeFactory; @@ -37,27 +17,27 @@ use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; use OCP\L10N\IFactory; +use OCP\Security\Bruteforce\IThrottler; class ServerFactory { - /** @var IConfig */ - private $config; /** @var IL10N */ private $l10n; - private $eventDispatcher; - public function __construct(IConfig $config, IFactory $l10nFactory, IEventDispatcher $eventDispatcher) { - $this->config = $config; + public function __construct( + private IConfig $config, + IFactory $l10nFactory, + private IEventDispatcher $eventDispatcher, + ) { $this->l10n = $l10nFactory->get('dav'); - $this->eventDispatcher = $eventDispatcher; } public function createServer(string $baseURI, - string $requestURI, - IRootFolder $rootFolder, - DirectMapper $mapper, - ITimeFactory $timeFactory, - Throttler $throttler, - IRequest $request): Server { + string $requestURI, + IRootFolder $rootFolder, + DirectMapper $mapper, + ITimeFactory $timeFactory, + IThrottler $throttler, + IRequest $request): Server { $home = new DirectHome($rootFolder, $mapper, $timeFactory, $throttler, $request, $this->eventDispatcher); $server = new Server($home); diff --git a/apps/dav/lib/Events/AddressBookCreatedEvent.php b/apps/dav/lib/Events/AddressBookCreatedEvent.php index 86c4cd23640..1a56bcbf63f 100644 --- a/apps/dav/lib/Events/AddressBookCreatedEvent.php +++ b/apps/dav/lib/Events/AddressBookCreatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,12 +18,6 @@ use OCP\EventDispatcher\Event; */ class AddressBookCreatedEvent extends Event { - /** @var int */ - private $addressBookId; - - /** @var array */ - private $addressBookData; - /** * AddressBookCreatedEvent constructor. * @@ -48,11 +25,11 @@ class AddressBookCreatedEvent extends Event { * @param array $addressBookData * @since 20.0.0 */ - public function __construct(int $addressBookId, - array $addressBookData) { + public function __construct( + private int $addressBookId, + private array $addressBookData, + ) { parent::__construct(); - $this->addressBookId = $addressBookId; - $this->addressBookData = $addressBookData; } /** diff --git a/apps/dav/lib/Events/AddressBookDeletedEvent.php b/apps/dav/lib/Events/AddressBookDeletedEvent.php index 3c8da6b7bf0..b1ec4125513 100644 --- a/apps/dav/lib/Events/AddressBookDeletedEvent.php +++ b/apps/dav/lib/Events/AddressBookDeletedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,15 +18,6 @@ use OCP\EventDispatcher\Event; */ class AddressBookDeletedEvent extends Event { - /** @var int */ - private $addressBookId; - - /** @var array */ - private $addressBookData; - - /** @var array */ - private $shares; - /** * AddressBookDeletedEvent constructor. * @@ -52,13 +26,12 @@ class AddressBookDeletedEvent extends Event { * @param array $shares * @since 20.0.0 */ - public function __construct(int $addressBookId, - array $addressBookData, - array $shares) { + public function __construct( + private int $addressBookId, + private array $addressBookData, + private array $shares, + ) { parent::__construct(); - $this->addressBookId = $addressBookId; - $this->addressBookData = $addressBookData; - $this->shares = $shares; } /** diff --git a/apps/dav/lib/Events/AddressBookShareUpdatedEvent.php b/apps/dav/lib/Events/AddressBookShareUpdatedEvent.php index f9f8ff99d40..9a574fb548e 100644 --- a/apps/dav/lib/Events/AddressBookShareUpdatedEvent.php +++ b/apps/dav/lib/Events/AddressBookShareUpdatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,21 +18,6 @@ use OCP\EventDispatcher\Event; */ class AddressBookShareUpdatedEvent extends Event { - /** @var int */ - private $addressBookId; - - /** @var array */ - private $addressBookData; - - /** @var array */ - private $oldShares; - - /** @var array */ - private $added; - - /** @var array */ - private $removed; - /** * AddressBookShareUpdatedEvent constructor. * @@ -60,17 +28,14 @@ class AddressBookShareUpdatedEvent extends Event { * @param array $removed * @since 20.0.0 */ - public function __construct(int $addressBookId, - array $addressBookData, - array $oldShares, - array $added, - array $removed) { + public function __construct( + private int $addressBookId, + private array $addressBookData, + private array $oldShares, + private array $added, + private array $removed, + ) { parent::__construct(); - $this->addressBookId = $addressBookId; - $this->addressBookData = $addressBookData; - $this->oldShares = $oldShares; - $this->added = $added; - $this->removed = $removed; } /** diff --git a/apps/dav/lib/Events/AddressBookUpdatedEvent.php b/apps/dav/lib/Events/AddressBookUpdatedEvent.php index c632f1817a8..fe6dc024cd2 100644 --- a/apps/dav/lib/Events/AddressBookUpdatedEvent.php +++ b/apps/dav/lib/Events/AddressBookUpdatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,18 +18,6 @@ use OCP\EventDispatcher\Event; */ class AddressBookUpdatedEvent extends Event { - /** @var int */ - private $addressBookId; - - /** @var array */ - private $addressBookData; - - /** @var array */ - private $shares; - - /** @var array */ - private $mutations; - /** * AddressBookUpdatedEvent constructor. * @@ -56,15 +27,13 @@ class AddressBookUpdatedEvent extends Event { * @param array $mutations * @since 20.0.0 */ - public function __construct(int $addressBookId, - array $addressBookData, - array $shares, - array $mutations) { + public function __construct( + private int $addressBookId, + private array $addressBookData, + private array $shares, + private array $mutations, + ) { parent::__construct(); - $this->addressBookId = $addressBookId; - $this->addressBookData = $addressBookData; - $this->shares = $shares; - $this->mutations = $mutations; } /** diff --git a/apps/dav/lib/Events/BeforeFileDirectDownloadedEvent.php b/apps/dav/lib/Events/BeforeFileDirectDownloadedEvent.php index ddb79505ac6..a79d730e8ff 100644 --- a/apps/dav/lib/Events/BeforeFileDirectDownloadedEvent.php +++ b/apps/dav/lib/Events/BeforeFileDirectDownloadedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -32,11 +15,10 @@ use OCP\Files\File; * @since 22.0.0 */ class BeforeFileDirectDownloadedEvent extends Event { - private $file; - - public function __construct(File $file) { + public function __construct( + private File $file, + ) { parent::__construct(); - $this->file = $file; } /** diff --git a/apps/dav/lib/Events/CachedCalendarObjectCreatedEvent.php b/apps/dav/lib/Events/CachedCalendarObjectCreatedEvent.php index 29e11ddc146..ea1c344ed27 100644 --- a/apps/dav/lib/Events/CachedCalendarObjectCreatedEvent.php +++ b/apps/dav/lib/Events/CachedCalendarObjectCreatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,18 +18,6 @@ use OCP\EventDispatcher\Event; */ class CachedCalendarObjectCreatedEvent extends Event { - /** @var int */ - private $subscriptionId; - - /** @var array */ - private $subscriptionData; - - /** @var array */ - private $shares; - - /** @var array */ - private $objectData; - /** * CachedCalendarObjectCreatedEvent constructor. * @@ -56,15 +27,13 @@ class CachedCalendarObjectCreatedEvent extends Event { * @param array $objectData * @since 20.0.0 */ - public function __construct(int $subscriptionId, - array $subscriptionData, - array $shares, - array $objectData) { + public function __construct( + private int $subscriptionId, + private array $subscriptionData, + private array $shares, + private array $objectData, + ) { parent::__construct(); - $this->subscriptionId = $subscriptionId; - $this->subscriptionData = $subscriptionData; - $this->shares = $shares; - $this->objectData = $objectData; } /** diff --git a/apps/dav/lib/Events/CachedCalendarObjectDeletedEvent.php b/apps/dav/lib/Events/CachedCalendarObjectDeletedEvent.php index eaf98df60bf..8f8e55d32e5 100644 --- a/apps/dav/lib/Events/CachedCalendarObjectDeletedEvent.php +++ b/apps/dav/lib/Events/CachedCalendarObjectDeletedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,18 +18,6 @@ use OCP\EventDispatcher\Event; */ class CachedCalendarObjectDeletedEvent extends Event { - /** @var int */ - private $subscriptionId; - - /** @var array */ - private $subscriptionData; - - /** @var array */ - private $shares; - - /** @var array */ - private $objectData; - /** * CachedCalendarObjectDeletedEvent constructor. * @@ -56,15 +27,13 @@ class CachedCalendarObjectDeletedEvent extends Event { * @param array $objectData * @since 20.0.0 */ - public function __construct(int $subscriptionId, - array $subscriptionData, - array $shares, - array $objectData) { + public function __construct( + private int $subscriptionId, + private array $subscriptionData, + private array $shares, + private array $objectData, + ) { parent::__construct(); - $this->subscriptionId = $subscriptionId; - $this->subscriptionData = $subscriptionData; - $this->shares = $shares; - $this->objectData = $objectData; } /** diff --git a/apps/dav/lib/Events/CachedCalendarObjectUpdatedEvent.php b/apps/dav/lib/Events/CachedCalendarObjectUpdatedEvent.php index d47d75ec1d8..0adb4164dc9 100644 --- a/apps/dav/lib/Events/CachedCalendarObjectUpdatedEvent.php +++ b/apps/dav/lib/Events/CachedCalendarObjectUpdatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,18 +18,6 @@ use OCP\EventDispatcher\Event; */ class CachedCalendarObjectUpdatedEvent extends Event { - /** @var int */ - private $subscriptionId; - - /** @var array */ - private $subscriptionData; - - /** @var array */ - private $shares; - - /** @var array */ - private $objectData; - /** * CachedCalendarObjectUpdatedEvent constructor. * @@ -56,15 +27,13 @@ class CachedCalendarObjectUpdatedEvent extends Event { * @param array $objectData * @since 20.0.0 */ - public function __construct(int $subscriptionId, - array $subscriptionData, - array $shares, - array $objectData) { + public function __construct( + private int $subscriptionId, + private array $subscriptionData, + private array $shares, + private array $objectData, + ) { parent::__construct(); - $this->subscriptionId = $subscriptionId; - $this->subscriptionData = $subscriptionData; - $this->shares = $shares; - $this->objectData = $objectData; } /** diff --git a/apps/dav/lib/Events/CalendarCreatedEvent.php b/apps/dav/lib/Events/CalendarCreatedEvent.php index ba51002f829..46d1194914e 100644 --- a/apps/dav/lib/Events/CalendarCreatedEvent.php +++ b/apps/dav/lib/Events/CalendarCreatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,12 +18,6 @@ use OCP\EventDispatcher\Event; */ class CalendarCreatedEvent extends Event { - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - /** * CalendarCreatedEvent constructor. * @@ -48,11 +25,11 @@ class CalendarCreatedEvent extends Event { * @param array $calendarData * @since 20.0.0 */ - public function __construct(int $calendarId, - array $calendarData) { + public function __construct( + private int $calendarId, + private array $calendarData, + ) { parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; } /** diff --git a/apps/dav/lib/Events/CalendarDeletedEvent.php b/apps/dav/lib/Events/CalendarDeletedEvent.php index d6207ac6ee2..c8ab4265b27 100644 --- a/apps/dav/lib/Events/CalendarDeletedEvent.php +++ b/apps/dav/lib/Events/CalendarDeletedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,15 +18,6 @@ use OCP\EventDispatcher\Event; */ class CalendarDeletedEvent extends Event { - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var array */ - private $shares; - /** * CalendarDeletedEvent constructor. * @@ -52,13 +26,12 @@ class CalendarDeletedEvent extends Event { * @param array $shares * @since 20.0.0 */ - public function __construct(int $calendarId, - array $calendarData, - array $shares) { + public function __construct( + private int $calendarId, + private array $calendarData, + private array $shares, + ) { parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->shares = $shares; } /** diff --git a/apps/dav/lib/Events/CalendarMovedToTrashEvent.php b/apps/dav/lib/Events/CalendarMovedToTrashEvent.php index c04b383d5bf..8bb660a98c6 100644 --- a/apps/dav/lib/Events/CalendarMovedToTrashEvent.php +++ b/apps/dav/lib/Events/CalendarMovedToTrashEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -32,28 +15,18 @@ use OCP\EventDispatcher\Event; */ class CalendarMovedToTrashEvent extends Event { - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var array */ - private $shares; - /** * @param int $calendarId * @param array $calendarData * @param array $shares * @since 22.0.0 */ - public function __construct(int $calendarId, - array $calendarData, - array $shares) { + public function __construct( + private int $calendarId, + private array $calendarData, + private array $shares, + ) { parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->shares = $shares; } /** diff --git a/apps/dav/lib/Events/CalendarObjectCreatedEvent.php b/apps/dav/lib/Events/CalendarObjectCreatedEvent.php deleted file mode 100644 index 294c778335e..00000000000 --- a/apps/dav/lib/Events/CalendarObjectCreatedEvent.php +++ /dev/null @@ -1,101 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright Copyright (c) 2020, 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/>. - * - */ -namespace OCA\DAV\Events; - -use OCP\EventDispatcher\Event; - -/** - * Class CalendarObjectCreatedEvent - * - * @package OCA\DAV\Events - * @since 20.0.0 - */ -class CalendarObjectCreatedEvent extends Event { - - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var array */ - private $shares; - - /** @var array */ - private $objectData; - - /** - * CalendarObjectCreatedEvent constructor. - * - * @param int $calendarId - * @param array $calendarData - * @param array $shares - * @param array $objectData - * @since 20.0.0 - */ - public function __construct(int $calendarId, - array $calendarData, - array $shares, - array $objectData) { - parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->shares = $shares; - $this->objectData = $objectData; - } - - /** - * @return int - * @since 20.0.0 - */ - public function getCalendarId(): int { - return $this->calendarId; - } - - /** - * @return array - * @since 20.0.0 - */ - public function getCalendarData(): array { - return $this->calendarData; - } - - /** - * @return array - * @since 20.0.0 - */ - public function getShares(): array { - return $this->shares; - } - - /** - * @return array - * @since 20.0.0 - */ - public function getObjectData(): array { - return $this->objectData; - } -} diff --git a/apps/dav/lib/Events/CalendarObjectDeletedEvent.php b/apps/dav/lib/Events/CalendarObjectDeletedEvent.php deleted file mode 100644 index 7a621994b80..00000000000 --- a/apps/dav/lib/Events/CalendarObjectDeletedEvent.php +++ /dev/null @@ -1,101 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright Copyright (c) 2020, 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/>. - * - */ -namespace OCA\DAV\Events; - -use OCP\EventDispatcher\Event; - -/** - * Class CalendarObjectDeletedEvent - * - * @package OCA\DAV\Events - * @since 20.0.0 - */ -class CalendarObjectDeletedEvent extends Event { - - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var array */ - private $shares; - - /** @var array */ - private $objectData; - - /** - * CalendarObjectDeletedEvent constructor. - * - * @param int $calendarId - * @param array $calendarData - * @param array $shares - * @param array $objectData - * @since 20.0.0 - */ - public function __construct(int $calendarId, - array $calendarData, - array $shares, - array $objectData) { - parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->shares = $shares; - $this->objectData = $objectData; - } - - /** - * @return int - * @since 20.0.0 - */ - public function getCalendarId(): int { - return $this->calendarId; - } - - /** - * @return array - * @since 20.0.0 - */ - public function getCalendarData(): array { - return $this->calendarData; - } - - /** - * @return array - * @since 20.0.0 - */ - public function getShares(): array { - return $this->shares; - } - - /** - * @return array - * @since 20.0.0 - */ - public function getObjectData(): array { - return $this->objectData; - } -} diff --git a/apps/dav/lib/Events/CalendarObjectMovedToTrashEvent.php b/apps/dav/lib/Events/CalendarObjectMovedToTrashEvent.php deleted file mode 100644 index d7a3b99de82..00000000000 --- a/apps/dav/lib/Events/CalendarObjectMovedToTrashEvent.php +++ /dev/null @@ -1,96 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ -namespace OCA\DAV\Events; - -use OCP\EventDispatcher\Event; - -/** - * @since 22.0.0 - */ -class CalendarObjectMovedToTrashEvent extends Event { - - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var array */ - private $shares; - - /** @var array */ - private $objectData; - - /** - * @param int $calendarId - * @param array $calendarData - * @param array $shares - * @param array $objectData - * @since 22.0.0 - */ - public function __construct(int $calendarId, - array $calendarData, - array $shares, - array $objectData) { - parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->shares = $shares; - $this->objectData = $objectData; - } - - /** - * @return int - * @since 22.0.0 - */ - public function getCalendarId(): int { - return $this->calendarId; - } - - /** - * @return array - * @since 22.0.0 - */ - public function getCalendarData(): array { - return $this->calendarData; - } - - /** - * @return array - * @since 22.0.0 - */ - public function getShares(): array { - return $this->shares; - } - - /** - * @return array - * @since 22.0.0 - */ - public function getObjectData(): array { - return $this->objectData; - } -} diff --git a/apps/dav/lib/Events/CalendarObjectRestoredEvent.php b/apps/dav/lib/Events/CalendarObjectRestoredEvent.php deleted file mode 100644 index 42f296e64cc..00000000000 --- a/apps/dav/lib/Events/CalendarObjectRestoredEvent.php +++ /dev/null @@ -1,96 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ -namespace OCA\DAV\Events; - -use OCP\EventDispatcher\Event; - -/** - * @since 22.0.0 - */ -class CalendarObjectRestoredEvent extends Event { - - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var array */ - private $shares; - - /** @var array */ - private $objectData; - - /** - * @param int $calendarId - * @param array $calendarData - * @param array $shares - * @param array $objectData - * @since 22.0.0 - */ - public function __construct(int $calendarId, - array $calendarData, - array $shares, - array $objectData) { - parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->shares = $shares; - $this->objectData = $objectData; - } - - /** - * @return int - * @since 22.0.0 - */ - public function getCalendarId(): int { - return $this->calendarId; - } - - /** - * @return array - * @since 22.0.0 - */ - public function getCalendarData(): array { - return $this->calendarData; - } - - /** - * @return array - * @since 22.0.0 - */ - public function getShares(): array { - return $this->shares; - } - - /** - * @return array - * @since 22.0.0 - */ - public function getObjectData(): array { - return $this->objectData; - } -} diff --git a/apps/dav/lib/Events/CalendarObjectUpdatedEvent.php b/apps/dav/lib/Events/CalendarObjectUpdatedEvent.php deleted file mode 100644 index 84157afd20a..00000000000 --- a/apps/dav/lib/Events/CalendarObjectUpdatedEvent.php +++ /dev/null @@ -1,101 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * @copyright Copyright (c) 2020, 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/>. - * - */ -namespace OCA\DAV\Events; - -use OCP\EventDispatcher\Event; - -/** - * Class CalendarObjectUpdatedEvent - * - * @package OCA\DAV\Events - * @since 20.0.0 - */ -class CalendarObjectUpdatedEvent extends Event { - - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var array */ - private $shares; - - /** @var array */ - private $objectData; - - /** - * CalendarObjectUpdatedEvent constructor. - * - * @param int $calendarId - * @param array $calendarData - * @param array $shares - * @param array $objectData - * @since 20.0.0 - */ - public function __construct(int $calendarId, - array $calendarData, - array $shares, - array $objectData) { - parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->shares = $shares; - $this->objectData = $objectData; - } - - /** - * @return int - * @since 20.0.0 - */ - public function getCalendarId(): int { - return $this->calendarId; - } - - /** - * @return array - * @since 20.0.0 - */ - public function getCalendarData(): array { - return $this->calendarData; - } - - /** - * @return array - * @since 20.0.0 - */ - public function getShares(): array { - return $this->shares; - } - - /** - * @return array - * @since 20.0.0 - */ - public function getObjectData(): array { - return $this->objectData; - } -} diff --git a/apps/dav/lib/Events/CalendarPublishedEvent.php b/apps/dav/lib/Events/CalendarPublishedEvent.php index 7b3b95f2f77..32fb1c36963 100644 --- a/apps/dav/lib/Events/CalendarPublishedEvent.php +++ b/apps/dav/lib/Events/CalendarPublishedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -34,16 +17,6 @@ use OCP\EventDispatcher\Event; * @since 20.0.0 */ class CalendarPublishedEvent extends Event { - - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var string */ - private $publicUri; - /** * CalendarPublishedEvent constructor. * @@ -52,13 +25,12 @@ class CalendarPublishedEvent extends Event { * @param string $publicUri * @since 20.0.0 */ - public function __construct(int $calendarId, - array $calendarData, - string $publicUri) { + public function __construct( + private int $calendarId, + private array $calendarData, + private string $publicUri, + ) { parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->publicUri = $publicUri; } /** diff --git a/apps/dav/lib/Events/CalendarRestoredEvent.php b/apps/dav/lib/Events/CalendarRestoredEvent.php index ef477ac1d48..f404771dea2 100644 --- a/apps/dav/lib/Events/CalendarRestoredEvent.php +++ b/apps/dav/lib/Events/CalendarRestoredEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -32,28 +15,18 @@ use OCP\EventDispatcher\Event; */ class CalendarRestoredEvent extends Event { - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var array */ - private $shares; - /** * @param int $calendarId * @param array $calendarData * @param array $shares * @since 22.0.0 */ - public function __construct(int $calendarId, - array $calendarData, - array $shares) { + public function __construct( + private int $calendarId, + private array $calendarData, + private array $shares, + ) { parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->shares = $shares; } /** diff --git a/apps/dav/lib/Events/CalendarShareUpdatedEvent.php b/apps/dav/lib/Events/CalendarShareUpdatedEvent.php index a9011bc0273..0f8b23ad3ac 100644 --- a/apps/dav/lib/Events/CalendarShareUpdatedEvent.php +++ b/apps/dav/lib/Events/CalendarShareUpdatedEvent.php @@ -3,28 +3,12 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; +use OCA\DAV\CalDAV\CalDavBackend; use OCP\EventDispatcher\Event; /** @@ -32,49 +16,32 @@ use OCP\EventDispatcher\Event; * * @package OCA\DAV\Events * @since 20.0.0 + * + * @psalm-import-type CalendarInfo from CalDavBackend */ class CalendarShareUpdatedEvent extends Event { - - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var array */ - private $oldShares; - - /** @var array */ - private $added; - - /** @var array */ - private $removed; - /** * CalendarShareUpdatedEvent constructor. * * @param int $calendarId + * @psalm-param CalendarInfo $calendarData * @param array $calendarData - * @param array $oldShares - * @param array $added - * @param array $removed + * @param list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> $oldShares + * @param list<array{href: string, commonName: string, readOnly: bool}> $added + * @param list<string> $removed * @since 20.0.0 */ - public function __construct(int $calendarId, - array $calendarData, - array $oldShares, - array $added, - array $removed) { + public function __construct( + private int $calendarId, + private array $calendarData, + private array $oldShares, + private array $added, + private array $removed, + ) { parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->oldShares = $oldShares; - $this->added = $added; - $this->removed = $removed; } /** - * @return int * @since 20.0.0 */ public function getCalendarId(): int { @@ -82,6 +49,7 @@ class CalendarShareUpdatedEvent extends Event { } /** + * @psalm-return CalendarInfo * @return array * @since 20.0.0 */ @@ -90,7 +58,7 @@ class CalendarShareUpdatedEvent extends Event { } /** - * @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}> * @since 20.0.0 */ public function getOldShares(): array { @@ -98,7 +66,7 @@ class CalendarShareUpdatedEvent extends Event { } /** - * @return array + * @return list<array{href: string, commonName: string, readOnly: bool}> * @since 20.0.0 */ public function getAdded(): array { @@ -106,7 +74,7 @@ class CalendarShareUpdatedEvent extends Event { } /** - * @return array + * @return list<string> * @since 20.0.0 */ public function getRemoved(): array { diff --git a/apps/dav/lib/Events/CalendarUnpublishedEvent.php b/apps/dav/lib/Events/CalendarUnpublishedEvent.php index 0cea53c6f0d..10d1712686d 100644 --- a/apps/dav/lib/Events/CalendarUnpublishedEvent.php +++ b/apps/dav/lib/Events/CalendarUnpublishedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -34,13 +17,6 @@ use OCP\EventDispatcher\Event; * @since 20.0.0 */ class CalendarUnpublishedEvent extends Event { - - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - /** * CalendarUnpublishedEvent constructor. * @@ -48,11 +24,11 @@ class CalendarUnpublishedEvent extends Event { * @param array $calendarData * @since 20.0.0 */ - public function __construct(int $calendarId, - array $calendarData) { + public function __construct( + private int $calendarId, + private array $calendarData, + ) { parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; } /** diff --git a/apps/dav/lib/Events/CalendarUpdatedEvent.php b/apps/dav/lib/Events/CalendarUpdatedEvent.php index ec33412b478..a603d3152f0 100644 --- a/apps/dav/lib/Events/CalendarUpdatedEvent.php +++ b/apps/dav/lib/Events/CalendarUpdatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,18 +18,6 @@ use OCP\EventDispatcher\Event; */ class CalendarUpdatedEvent extends Event { - /** @var int */ - private $calendarId; - - /** @var array */ - private $calendarData; - - /** @var array */ - private $shares; - - /** @var array */ - private $mutations; - /** * CalendarUpdatedEvent constructor. * @@ -56,15 +27,13 @@ class CalendarUpdatedEvent extends Event { * @param array $mutations * @since 20.0.0 */ - public function __construct(int $calendarId, - array $calendarData, - array $shares, - array $mutations) { + public function __construct( + private int $calendarId, + private array $calendarData, + private array $shares, + private array $mutations, + ) { parent::__construct(); - $this->calendarId = $calendarId; - $this->calendarData = $calendarData; - $this->shares = $shares; - $this->mutations = $mutations; } /** diff --git a/apps/dav/lib/Events/CardCreatedEvent.php b/apps/dav/lib/Events/CardCreatedEvent.php index 4c6b1714721..5a66d73e707 100644 --- a/apps/dav/lib/Events/CardCreatedEvent.php +++ b/apps/dav/lib/Events/CardCreatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,18 +18,6 @@ use OCP\EventDispatcher\Event; */ class CardCreatedEvent extends Event { - /** @var int */ - private $addressBookId; - - /** @var array */ - private $addressBookData; - - /** @var array */ - private $shares; - - /** @var array */ - private $cardData; - /** * CardCreatedEvent constructor. * @@ -56,15 +27,13 @@ class CardCreatedEvent extends Event { * @param array $cardData * @since 20.0.0 */ - public function __construct(int $addressBookId, - array $addressBookData, - array $shares, - array $cardData) { + public function __construct( + private int $addressBookId, + private array $addressBookData, + private array $shares, + private array $cardData, + ) { parent::__construct(); - $this->addressBookId = $addressBookId; - $this->addressBookData = $addressBookData; - $this->shares = $shares; - $this->cardData = $cardData; } /** diff --git a/apps/dav/lib/Events/CardDeletedEvent.php b/apps/dav/lib/Events/CardDeletedEvent.php index f4d7e21fab1..237ffa7d623 100644 --- a/apps/dav/lib/Events/CardDeletedEvent.php +++ b/apps/dav/lib/Events/CardDeletedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,18 +18,6 @@ use OCP\EventDispatcher\Event; */ class CardDeletedEvent extends Event { - /** @var int */ - private $addressBookId; - - /** @var array */ - private $addressBookData; - - /** @var array */ - private $shares; - - /** @var array */ - private $cardData; - /** * CardDeletedEvent constructor. * @@ -56,15 +27,13 @@ class CardDeletedEvent extends Event { * @param array $cardData * @since 20.0.0 */ - public function __construct(int $addressBookId, - array $addressBookData, - array $shares, - array $cardData) { + public function __construct( + private int $addressBookId, + private array $addressBookData, + private array $shares, + private array $cardData, + ) { parent::__construct(); - $this->addressBookId = $addressBookId; - $this->addressBookData = $addressBookData; - $this->shares = $shares; - $this->cardData = $cardData; } /** diff --git a/apps/dav/lib/Events/CardMovedEvent.php b/apps/dav/lib/Events/CardMovedEvent.php new file mode 100644 index 00000000000..be69a046537 --- /dev/null +++ b/apps/dav/lib/Events/CardMovedEvent.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Events; + +use OCP\EventDispatcher\Event; + +/** + * Class CardMovedEvent + * + * @package OCA\DAV\Events + * @since 27.0.0 + */ +class CardMovedEvent extends Event { + /** + * @since 27.0.0 + */ + public function __construct( + private int $sourceAddressBookId, + private array $sourceAddressBookData, + private int $targetAddressBookId, + private array $targetAddressBookData, + private array $sourceShares, + private array $targetShares, + private array $objectData, + ) { + parent::__construct(); + } + + /** + * @return int + * @since 27.0.0 + */ + public function getSourceAddressBookId(): int { + return $this->sourceAddressBookId; + } + + /** + * @return array + * @since 27.0.0 + */ + public function getSourceAddressBookData(): array { + return $this->sourceAddressBookData; + } + + /** + * @return int + * @since 27.0.0 + */ + public function getTargetAddressBookId(): int { + return $this->targetAddressBookId; + } + + /** + * @return array + * @since 27.0.0 + */ + public function getTargetAddressBookData(): array { + return $this->targetAddressBookData; + } + + /** + * @return array + * @since 27.0.0 + */ + public function getSourceShares(): array { + return $this->sourceShares; + } + + /** + * @return array + * @since 27.0.0 + */ + public function getTargetShares(): array { + return $this->targetShares; + } + + /** + * @return array + * @since 27.0.0 + */ + public function getObjectData(): array { + return $this->objectData; + } +} diff --git a/apps/dav/lib/Events/CardUpdatedEvent.php b/apps/dav/lib/Events/CardUpdatedEvent.php index 213419d51a3..10fc5b74594 100644 --- a/apps/dav/lib/Events/CardUpdatedEvent.php +++ b/apps/dav/lib/Events/CardUpdatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,18 +18,6 @@ use OCP\EventDispatcher\Event; */ class CardUpdatedEvent extends Event { - /** @var int */ - private $addressBookId; - - /** @var array */ - private $addressBookData; - - /** @var array */ - private $shares; - - /** @var array */ - private $cardData; - /** * CardUpdatedEvent constructor. * @@ -56,15 +27,13 @@ class CardUpdatedEvent extends Event { * @param array $cardData * @since 20.0.0 */ - public function __construct(int $addressBookId, - array $addressBookData, - array $shares, - array $cardData) { + public function __construct( + private int $addressBookId, + private array $addressBookData, + private array $shares, + private array $cardData, + ) { parent::__construct(); - $this->addressBookId = $addressBookId; - $this->addressBookData = $addressBookData; - $this->shares = $shares; - $this->cardData = $cardData; } /** diff --git a/apps/dav/lib/Events/SabrePluginAddEvent.php b/apps/dav/lib/Events/SabrePluginAddEvent.php new file mode 100644 index 00000000000..866fae2e224 --- /dev/null +++ b/apps/dav/lib/Events/SabrePluginAddEvent.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Events; + +use OCP\EventDispatcher\Event; +use Sabre\DAV\Server; + +/** + * This event is triggered during the setup of the SabreDAV server to allow the + * registration of additional plugins. + * + * @since 28.0.0 + */ +class SabrePluginAddEvent extends Event { + + /** + * @since 28.0.0 + */ + public function __construct( + private Server $server, + ) { + parent::__construct(); + } + + /** + * @since 28.0.0 + */ + public function getServer(): Server { + return $this->server; + } +} diff --git a/apps/dav/lib/Events/SabrePluginAuthInitEvent.php b/apps/dav/lib/Events/SabrePluginAuthInitEvent.php index ea1bd95a0f7..d3d93770c74 100644 --- a/apps/dav/lib/Events/SabrePluginAuthInitEvent.php +++ b/apps/dav/lib/Events/SabrePluginAuthInitEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Morris Jobke <hey@morrisjobke.de> - * - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -36,14 +19,12 @@ use Sabre\DAV\Server; */ class SabrePluginAuthInitEvent extends Event { - /** @var Server */ - private $server; - /** * @since 20.0.0 */ - public function __construct(Server $server) { - $this->server = $server; + public function __construct( + private Server $server, + ) { } /** diff --git a/apps/dav/lib/Events/SubscriptionCreatedEvent.php b/apps/dav/lib/Events/SubscriptionCreatedEvent.php index f7fc2101221..433b6db59b0 100644 --- a/apps/dav/lib/Events/SubscriptionCreatedEvent.php +++ b/apps/dav/lib/Events/SubscriptionCreatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,12 +18,6 @@ use OCP\EventDispatcher\Event; */ class SubscriptionCreatedEvent extends Event { - /** @var int */ - private $subscriptionId; - - /** @var array */ - private $subscriptionData; - /** * SubscriptionCreatedEvent constructor. * @@ -48,11 +25,11 @@ class SubscriptionCreatedEvent extends Event { * @param array $subscriptionData * @since 20.0.0 */ - public function __construct(int $subscriptionId, - array $subscriptionData) { + public function __construct( + private int $subscriptionId, + private array $subscriptionData, + ) { parent::__construct(); - $this->subscriptionId = $subscriptionId; - $this->subscriptionData = $subscriptionData; } /** diff --git a/apps/dav/lib/Events/SubscriptionDeletedEvent.php b/apps/dav/lib/Events/SubscriptionDeletedEvent.php index fc31005ac0f..58d47ae98a4 100644 --- a/apps/dav/lib/Events/SubscriptionDeletedEvent.php +++ b/apps/dav/lib/Events/SubscriptionDeletedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,15 +18,6 @@ use OCP\EventDispatcher\Event; */ class SubscriptionDeletedEvent extends Event { - /** @var int */ - private $subscriptionId; - - /** @var array */ - private $subscriptionData; - - /** @var array */ - private $shares; - /** * SubscriptionDeletedEvent constructor. * @@ -52,13 +26,12 @@ class SubscriptionDeletedEvent extends Event { * @param array $shares * @since 20.0.0 */ - public function __construct(int $subscriptionId, - array $subscriptionData, - array $shares) { + public function __construct( + private int $subscriptionId, + private array $subscriptionData, + private array $shares, + ) { parent::__construct(); - $this->subscriptionId = $subscriptionId; - $this->subscriptionData = $subscriptionData; - $this->shares = $shares; } /** diff --git a/apps/dav/lib/Events/SubscriptionUpdatedEvent.php b/apps/dav/lib/Events/SubscriptionUpdatedEvent.php index 29231d13a7f..1a1b2804663 100644 --- a/apps/dav/lib/Events/SubscriptionUpdatedEvent.php +++ b/apps/dav/lib/Events/SubscriptionUpdatedEvent.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Events; @@ -35,18 +18,6 @@ use OCP\EventDispatcher\Event; */ class SubscriptionUpdatedEvent extends Event { - /** @var int */ - private $subscriptionId; - - /** @var array */ - private $subscriptionData; - - /** @var array */ - private $shares; - - /** @var array */ - private $mutations; - /** * SubscriptionUpdatedEvent constructor. * @@ -56,15 +27,13 @@ class SubscriptionUpdatedEvent extends Event { * @param array $mutations * @since 20.0.0 */ - public function __construct(int $subscriptionId, - array $subscriptionData, - array $shares, - array $mutations) { + public function __construct( + private int $subscriptionId, + private array $subscriptionData, + private array $shares, + private array $mutations, + ) { parent::__construct(); - $this->subscriptionId = $subscriptionId; - $this->subscriptionData = $subscriptionData; - $this->shares = $shares; - $this->mutations = $mutations; } /** diff --git a/apps/dav/lib/ExampleContentFiles/exampleContact.vcf b/apps/dav/lib/ExampleContentFiles/exampleContact.vcf new file mode 100644 index 00000000000..c58c949d0db --- /dev/null +++ b/apps/dav/lib/ExampleContentFiles/exampleContact.vcf @@ -0,0 +1,3555 @@ +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Sabre//Sabre VObject 4.5.6//EN +UID:cffff367-4580-4e01-8b6d-f91e95ce7e92 +FN:Leon Green +ADR;TYPE=HOME:;;123 Street Street;City;State;;Country +EMAIL;TYPE=HOME:leon@example.com +TEL;TYPE=HOME,VOICE:+999999999999 +PHOTO;ENCODING=b;TYPE=PNG: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 + ORK5CYII= +PHOTO;VALUE=URI: +PHOTO;VALUE=URI: +TITLE:Manager +ORG:Company +BDAY;VALUE=DATE:20000101 +URL;VALUE=URI:www.nextcloud.com +REV;VALUE=DATE-AND-OR-TIME:20250108T160752Z +END:VCARD diff --git a/apps/dav/lib/Exception/ExampleEventException.php b/apps/dav/lib/Exception/ExampleEventException.php new file mode 100644 index 00000000000..2d77cc443cb --- /dev/null +++ b/apps/dav/lib/Exception/ExampleEventException.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Exception; + +class ExampleEventException extends \Exception { +} diff --git a/apps/dav/lib/Exception/ServerMaintenanceMode.php b/apps/dav/lib/Exception/ServerMaintenanceMode.php new file mode 100644 index 00000000000..8f621588fdc --- /dev/null +++ b/apps/dav/lib/Exception/ServerMaintenanceMode.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Exception; + +use Sabre\DAV\Exception\ServiceUnavailable; + +class ServerMaintenanceMode extends ServiceUnavailable { + +} diff --git a/apps/dav/lib/Exception/UnsupportedLimitOnInitialSyncException.php b/apps/dav/lib/Exception/UnsupportedLimitOnInitialSyncException.php index 255a06578ac..c6b7f8564c5 100644 --- a/apps/dav/lib/Exception/UnsupportedLimitOnInitialSyncException.php +++ b/apps/dav/lib/Exception/UnsupportedLimitOnInitialSyncException.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2019 Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Exception; diff --git a/apps/dav/lib/Files/BrowserErrorPagePlugin.php b/apps/dav/lib/Files/BrowserErrorPagePlugin.php index b3ce591bd4a..85ed975a409 100644 --- a/apps/dav/lib/Files/BrowserErrorPagePlugin.php +++ b/apps/dav/lib/Files/BrowserErrorPagePlugin.php @@ -1,33 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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\Files; use OC\AppFramework\Http\Request; -use OC_Template; use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\TemplateResponse; use OCP\IRequest; +use OCP\Security\Bruteforce\MaxDelayReached; +use OCP\Template\ITemplateManager; use Sabre\DAV\Exception; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; @@ -70,12 +55,15 @@ class BrowserErrorPagePlugin extends ServerPlugin { } /** - * @param \Exception $ex + * @param \Throwable $ex */ - public function logException(\Exception $ex) { + public function logException(\Throwable $ex): void { if ($ex instanceof Exception) { $httpCode = $ex->getHTTPCode(); $headers = $ex->getHTTPHeaders($this->server); + } elseif ($ex instanceof MaxDelayReached) { + $httpCode = 429; + $headers = []; } else { $httpCode = 500; $headers = []; @@ -94,14 +82,14 @@ class BrowserErrorPagePlugin extends ServerPlugin { * @return bool|string */ public function generateBody(int $httpCode) { - $request = \OC::$server->getRequest(); + $request = \OCP\Server::get(IRequest::class); $templateName = 'exception'; - if ($httpCode === 403 || $httpCode === 404) { + if ($httpCode === 403 || $httpCode === 404 || $httpCode === 429) { $templateName = (string)$httpCode; } - $content = new OC_Template('core', $templateName, 'guest'); + $content = \OCP\Server::get(ITemplateManager::class)->getTemplate('core', $templateName, TemplateResponse::RENDER_AS_GUEST); $content->assign('title', $this->server->httpResponse->getStatusText()); $content->assign('remoteAddr', $request->getRemoteAddress()); $content->assign('requestID', $request->getId()); diff --git a/apps/dav/lib/Files/FileSearchBackend.php b/apps/dav/lib/Files/FileSearchBackend.php index 7ee82779849..eb548bbd55c 100644 --- a/apps/dav/lib/Files/FileSearchBackend.php +++ b/apps/dav/lib/Files/FileSearchBackend.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Christian <16852529+cviereck@users.noreply.github.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Files; @@ -29,19 +10,26 @@ use OC\Files\Search\SearchBinaryOperator; use OC\Files\Search\SearchComparison; use OC\Files\Search\SearchOrder; use OC\Files\Search\SearchQuery; +use OC\Files\Storage\Wrapper\Jail; use OC\Files\View; -use OC\Metadata\IMetadataManager; use OCA\DAV\Connector\Sabre\CachingTree; use OCA\DAV\Connector\Sabre\Directory; +use OCA\DAV\Connector\Sabre\File; use OCA\DAV\Connector\Sabre\FilesPlugin; +use OCA\DAV\Connector\Sabre\Server; use OCA\DAV\Connector\Sabre\TagsPlugin; use OCP\Files\Cache\ICacheEntry; use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\Node; +use OCP\Files\Search\ISearchBinaryOperator; +use OCP\Files\Search\ISearchComparison; use OCP\Files\Search\ISearchOperator; use OCP\Files\Search\ISearchOrder; use OCP\Files\Search\ISearchQuery; +use OCP\FilesMetadata\IFilesMetadataManager; +use OCP\FilesMetadata\IMetadataQuery; +use OCP\FilesMetadata\Model\IMetadataValueWrapper; use OCP\IUser; use OCP\Share\IManager; use Sabre\DAV\Exception\NotFound; @@ -55,37 +43,17 @@ use SearchDAV\Query\Order; use SearchDAV\Query\Query; class FileSearchBackend implements ISearchBackend { - /** @var CachingTree */ - private $tree; - - /** @var IUser */ - private $user; - - /** @var IRootFolder */ - private $rootFolder; - - /** @var IManager */ - private $shareManager; - - /** @var View */ - private $view; - - /** - * FileSearchBackend constructor. - * - * @param CachingTree $tree - * @param IUser $user - * @param IRootFolder $rootFolder - * @param IManager $shareManager - * @param View $view - * @internal param IRootFolder $rootFolder - */ - public function __construct(CachingTree $tree, IUser $user, IRootFolder $rootFolder, IManager $shareManager, View $view) { - $this->tree = $tree; - $this->user = $user; - $this->rootFolder = $rootFolder; - $this->shareManager = $shareManager; - $this->view = $view; + public const OPERATOR_LIMIT = 100; + + public function __construct( + private Server $server, + private CachingTree $tree, + private IUser $user, + private IRootFolder $rootFolder, + private IManager $shareManager, + private View $view, + private IFilesMetadataManager $filesMetadataManager, + ) { } /** @@ -113,7 +81,7 @@ class FileSearchBackend implements ISearchBackend { // all valid scopes support the same schema //todo dynamically load all propfind properties that are supported - return [ + $props = [ // queryable properties new SearchPropertyDefinition('{DAV:}displayname', true, true, true), new SearchPropertyDefinition('{DAV:}getcontenttype', true, true, true), @@ -132,9 +100,35 @@ class FileSearchBackend implements ISearchBackend { new SearchPropertyDefinition(FilesPlugin::OWNER_DISPLAY_NAME_PROPERTYNAME, true, false, false), new SearchPropertyDefinition(FilesPlugin::DATA_FINGERPRINT_PROPERTYNAME, true, false, false), new SearchPropertyDefinition(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, true, false, false, SearchPropertyDefinition::DATATYPE_BOOLEAN), - new SearchPropertyDefinition(FilesPlugin::FILE_METADATA_SIZE, true, false, false, SearchPropertyDefinition::DATATYPE_STRING), new SearchPropertyDefinition(FilesPlugin::FILEID_PROPERTYNAME, true, false, false, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER), ]; + + return array_merge($props, $this->getPropertyDefinitionsForMetadata()); + } + + + private function getPropertyDefinitionsForMetadata(): array { + $metadataProps = []; + $metadata = $this->filesMetadataManager->getKnownMetadata(); + $indexes = $metadata->getIndexes(); + foreach ($metadata->getKeys() as $key) { + $isIndex = in_array($key, $indexes); + $type = match ($metadata->getType($key)) { + IMetadataValueWrapper::TYPE_INT => SearchPropertyDefinition::DATATYPE_INTEGER, + IMetadataValueWrapper::TYPE_FLOAT => SearchPropertyDefinition::DATATYPE_DECIMAL, + IMetadataValueWrapper::TYPE_BOOL => SearchPropertyDefinition::DATATYPE_BOOLEAN, + default => SearchPropertyDefinition::DATATYPE_STRING + }; + $metadataProps[] = new SearchPropertyDefinition( + FilesPlugin::FILE_METADATA_PREFIX . $key, + true, + $isIndex, + $isIndex, + $type + ); + } + + return $metadataProps; } /** @@ -142,58 +136,84 @@ class FileSearchBackend implements ISearchBackend { * @param string[] $requestProperties */ public function preloadPropertyFor(array $nodes, array $requestProperties): void { - if (in_array(FilesPlugin::FILE_METADATA_SIZE, $requestProperties, true)) { - // Preloading of the metadata - $fileIds = []; - foreach ($nodes as $node) { - /** @var \OCP\Files\Node|\OCA\DAV\Connector\Sabre\Node $node */ - if (str_starts_with($node->getFileInfo()->getMimeType(), 'image/')) { - /** @var \OCA\DAV\Connector\Sabre\File $node */ - $fileIds[] = $node->getFileInfo()->getId(); - } - } - /** @var IMetaDataManager $metadataManager */ - $metadataManager = \OC::$server->get(IMetadataManager::class); - $preloadedMetadata = $metadataManager->fetchMetadataFor('size', $fileIds); - foreach ($nodes as $node) { - /** @var \OCP\Files\Node|\OCA\DAV\Connector\Sabre\Node $node */ - if (str_starts_with($node->getFileInfo()->getMimeType(), 'image/')) { - /** @var \OCA\DAV\Connector\Sabre\File $node */ - $node->setMetadata('size', $preloadedMetadata[$node->getFileInfo()->getId()]); - } - } - } + $this->server->emit('preloadProperties', [$nodes, $requestProperties]); } - /** - * @param Query $search - * @return SearchResult[] - */ - public function search(Query $search): array { - if (count($search->from) !== 1) { - throw new \InvalidArgumentException('Searching more than one folder is not supported'); - } - $query = $this->transformQuery($search); - $scope = $search->from[0]; - if ($scope->path === null) { + private function getFolderForPath(?string $path = null): Folder { + if ($path === null) { throw new \InvalidArgumentException('Using uri\'s as scope is not supported, please use a path relative to the search arbiter instead'); } - $node = $this->tree->getNodeForPath($scope->path); + + $node = $this->tree->getNodeForPath($path); + if (!$node instanceof Directory) { throw new \InvalidArgumentException('Search is only supported on directories'); } $fileInfo = $node->getFileInfo(); - $folder = $this->rootFolder->get($fileInfo->getPath()); - /** @var Folder $folder $results */ - $results = $folder->search($query); + + /** @var Folder */ + return $this->rootFolder->get($fileInfo->getPath()); + } + + /** + * @param Query $search + * @return SearchResult[] + */ + public function search(Query $search): array { + switch (count($search->from)) { + case 0: + throw new \InvalidArgumentException('You need to specify a scope for the search.'); + break; + case 1: + $scope = $search->from[0]; + $folder = $this->getFolderForPath($scope->path); + $query = $this->transformQuery($search); + $results = $folder->search($query); + break; + default: + $scopes = []; + foreach ($search->from as $scope) { + $folder = $this->getFolderForPath($scope->path); + $folderStorage = $folder->getStorage(); + if ($folderStorage->instanceOfStorage(Jail::class)) { + /** @var Jail $folderStorage */ + $internalPath = $folderStorage->getUnjailedPath($folder->getInternalPath()); + } else { + $internalPath = $folder->getInternalPath(); + } + + $scopes[] = new SearchBinaryOperator( + ISearchBinaryOperator::OPERATOR_AND, + [ + new SearchComparison( + ISearchComparison::COMPARE_EQUAL, + 'storage', + $folderStorage->getCache()->getNumericStorageId(), + '' + ), + new SearchComparison( + ISearchComparison::COMPARE_LIKE, + 'path', + $internalPath . '/%', + '' + ), + ] + ); + } + + $scopeOperators = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $scopes); + $query = $this->transformQuery($search, $scopeOperators); + $userFolder = $this->rootFolder->getUserFolder($this->user->getUID()); + $results = $userFolder->search($query); + } /** @var SearchResult[] $nodes */ $nodes = array_map(function (Node $node) { if ($node instanceof Folder) { - $davNode = new \OCA\DAV\Connector\Sabre\Directory($this->view, $node, $this->tree, $this->shareManager); + $davNode = new Directory($this->view, $node, $this->tree, $this->shareManager); } else { - $davNode = new \OCA\DAV\Connector\Sabre\File($this->view, $node, $this->shareManager); + $davNode = new File($this->view, $node, $this->shareManager); } $path = $this->getHrefForNode($node); $this->tree->cacheNode($davNode, $path); @@ -298,11 +318,20 @@ class FileSearchBackend implements ISearchBackend { /** * @param Query $query + * * @return ISearchQuery */ - private function transformQuery(Query $query): ISearchQuery { + private function transformQuery(Query $query, ?SearchBinaryOperator $scopeOperators = null): ISearchQuery { + $orders = array_map(function (Order $order): ISearchOrder { + $direction = $order->order === Order::ASC ? ISearchOrder::DIRECTION_ASCENDING : ISearchOrder::DIRECTION_DESCENDING; + if (str_starts_with($order->property->name, FilesPlugin::FILE_METADATA_PREFIX)) { + return new SearchOrder($direction, substr($order->property->name, strlen(FilesPlugin::FILE_METADATA_PREFIX)), IMetadataQuery::EXTRA); + } else { + return new SearchOrder($direction, $this->mapPropertyNameToColumn($order->property)); + } + }, $query->orderBy); + $limit = $query->limit; - $orders = array_map([$this, 'mapSearchOrder'], $query->orderBy); $offset = $limit->firstResult; $limitHome = false; @@ -315,8 +344,21 @@ class FileSearchBackend implements ISearchBackend { } } + $operatorCount = $this->countSearchOperators($query->where); + if ($operatorCount > self::OPERATOR_LIMIT) { + throw new \InvalidArgumentException('Invalid search query, maximum operator limit of ' . self::OPERATOR_LIMIT . ' exceeded, got ' . $operatorCount . ' operators'); + } + + /** @var SearchBinaryOperator|SearchComparison */ + $queryOperators = $this->transformSearchOperation($query->where); + if ($scopeOperators === null) { + $operators = $queryOperators; + } else { + $operators = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [$queryOperators, $scopeOperators]); + } + return new SearchQuery( - $this->transformSearchOperation($query->where), + $operators, (int)$limit->maxResults, $offset, $orders, @@ -325,12 +367,24 @@ class FileSearchBackend implements ISearchBackend { ); } - /** - * @param Order $order - * @return ISearchOrder - */ - private function mapSearchOrder(Order $order) { - return new SearchOrder($order->order === Order::ASC ? ISearchOrder::DIRECTION_ASCENDING : ISearchOrder::DIRECTION_DESCENDING, $this->mapPropertyNameToColumn($order->property)); + private function countSearchOperators(Operator $operator): int { + switch ($operator->type) { + case Operator::OPERATION_AND: + case Operator::OPERATION_OR: + case Operator::OPERATION_NOT: + /** @var Operator[] $arguments */ + $arguments = $operator->arguments; + return array_sum(array_map([$this, 'countSearchOperators'], $arguments)); + case Operator::OPERATION_EQUAL: + case Operator::OPERATION_GREATER_OR_EQUAL_THAN: + case Operator::OPERATION_GREATER_THAN: + case Operator::OPERATION_LESS_OR_EQUAL_THAN: + case Operator::OPERATION_LESS_THAN: + case Operator::OPERATION_IS_LIKE: + case Operator::OPERATION_IS_COLLECTION: + default: + return 1; + } } /** @@ -354,13 +408,37 @@ class FileSearchBackend implements ISearchBackend { if (count($operator->arguments) !== 2) { throw new \InvalidArgumentException('Invalid number of arguments for ' . $trimmedType . ' operation'); } + if (!($operator->arguments[1] instanceof Literal)) { + throw new \InvalidArgumentException('Invalid argument 2 for ' . $trimmedType . ' operation, expected literal'); + } + $value = $operator->arguments[1]->value; + // no break + case Operator::OPERATION_IS_DEFINED: if (!($operator->arguments[0] instanceof SearchPropertyDefinition)) { throw new \InvalidArgumentException('Invalid argument 1 for ' . $trimmedType . ' operation, expected property'); } - if (!($operator->arguments[1] instanceof Literal)) { - throw new \InvalidArgumentException('Invalid argument 2 for ' . $trimmedType . ' operation, expected literal'); + $property = $operator->arguments[0]; + + if (str_starts_with($property->name, FilesPlugin::FILE_METADATA_PREFIX)) { + $field = substr($property->name, strlen(FilesPlugin::FILE_METADATA_PREFIX)); + $extra = IMetadataQuery::EXTRA; + } else { + $field = $this->mapPropertyNameToColumn($property); + } + + try { + $castedValue = $this->castValue($property, $value ?? ''); + } catch (\Error $e) { + throw new \InvalidArgumentException('Invalid property value for ' . $property->name, previous: $e); } - return new SearchComparison($trimmedType, $this->mapPropertyNameToColumn($operator->arguments[0]), $this->castValue($operator->arguments[0], $operator->arguments[1]->value)); + + return new SearchComparison( + $trimmedType, + $field, + $castedValue, + $extra ?? '' + ); + case Operator::OPERATION_IS_COLLECTION: return new SearchComparison('eq', 'mimetype', ICacheEntry::DIRECTORY_MIMETYPE); default: @@ -394,6 +472,10 @@ class FileSearchBackend implements ISearchBackend { } private function castValue(SearchPropertyDefinition $property, $value) { + if ($value === '') { + return ''; + } + switch ($property->dataType) { case SearchPropertyDefinition::DATATYPE_BOOLEAN: return $value === 'yes'; @@ -405,7 +487,7 @@ class FileSearchBackend implements ISearchBackend { if (is_numeric($value)) { return max(0, 0 + $value); } - $date = \DateTime::createFromFormat(\DateTimeInterface::ATOM, $value); + $date = \DateTime::createFromFormat(\DateTimeInterface::ATOM, (string)$value); return ($date instanceof \DateTime && $date->getTimestamp() !== false) ? $date->getTimestamp() : 0; default: return $value; diff --git a/apps/dav/lib/Files/FilesHome.php b/apps/dav/lib/Files/FilesHome.php index 0a781b5589d..f8aa82cdcc9 100644 --- a/apps/dav/lib/Files/FilesHome.php +++ b/apps/dav/lib/Files/FilesHome.php @@ -1,30 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Files; +use OC\Files\Filesystem; use OCA\DAV\Connector\Sabre\Directory; use OCP\Files\FileInfo; use Sabre\DAV\Exception\Forbidden; @@ -32,19 +15,16 @@ use Sabre\DAV\Exception\Forbidden; class FilesHome extends Directory { /** - * @var array - */ - private $principalInfo; - - /** * FilesHome constructor. * * @param array $principalInfo * @param FileInfo $userFolder */ - public function __construct($principalInfo, FileInfo $userFolder) { - $this->principalInfo = $principalInfo; - $view = \OC\Files\Filesystem::getView(); + public function __construct( + private $principalInfo, + FileInfo $userFolder, + ) { + $view = Filesystem::getView(); parent::__construct($view, $userFolder); } diff --git a/apps/dav/lib/Files/LazySearchBackend.php b/apps/dav/lib/Files/LazySearchBackend.php index c3b2f27d72a..6ba539ddd87 100644 --- a/apps/dav/lib/Files/LazySearchBackend.php +++ b/apps/dav/lib/Files/LazySearchBackend.php @@ -1,28 +1,11 @@ <?php + /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Files; -use Sabre\DAV\INode; use SearchDAV\Backend\ISearchBackend; use SearchDAV\Query\Query; diff --git a/apps/dav/lib/Files/RootCollection.php b/apps/dav/lib/Files/RootCollection.php index 15498ec26ec..a11bea72c59 100644 --- a/apps/dav/lib/Files/RootCollection.php +++ b/apps/dav/lib/Files/RootCollection.php @@ -1,31 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Files; use OCP\Files\FileInfo; +use OCP\IUserSession; +use OCP\Server; use Sabre\DAV\INode; use Sabre\DAV\SimpleCollection; use Sabre\DAVACL\AbstractPrincipalCollection; @@ -44,7 +28,7 @@ class RootCollection extends AbstractPrincipalCollection { */ public function getChildForPrincipal(array $principalInfo) { [,$name] = \Sabre\Uri\split($principalInfo['uri']); - $user = \OC::$server->getUserSession()->getUser(); + $user = Server::get(IUserSession::class)->getUser(); if (is_null($user) || $name !== $user->getUID()) { // a user is only allowed to see their own home contents, so in case another collection // is accessed, we return a simple empty collection for now diff --git a/apps/dav/lib/Files/Sharing/FilesDropPlugin.php b/apps/dav/lib/Files/Sharing/FilesDropPlugin.php index 3ac541bbfd9..a3dbd32ce6b 100644 --- a/apps/dav/lib/Files/Sharing/FilesDropPlugin.php +++ b/apps/dav/lib/Files/Sharing/FilesDropPlugin.php @@ -1,30 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Files\Sharing; -use OC\Files\View; +use OCP\Files\Folder; +use OCP\Files\NotFoundException; +use OCP\Share\IShare; +use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\DAV\ServerPlugin; use Sabre\HTTP\RequestInterface; @@ -35,51 +20,180 @@ use Sabre\HTTP\ResponseInterface; */ class FilesDropPlugin extends ServerPlugin { - /** @var View */ - private $view; - - /** @var bool */ - private $enabled = false; + private ?IShare $share = null; + private bool $enabled = false; - /** - * @param View $view - */ - public function setView($view) { - $this->view = $view; + public function setShare(IShare $share): void { + $this->share = $share; } - public function enable() { + public function enable(): void { $this->enabled = true; } - /** * This initializes the plugin. - * - * @param \Sabre\DAV\Server $server Sabre server - * - * @return void - * @throws MethodNotAllowed + * It is ONLY initialized by the server on a file drop request. */ - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(\Sabre\DAV\Server $server): void { $server->on('beforeMethod:*', [$this, 'beforeMethod'], 999); + $server->on('method:MKCOL', [$this, 'onMkcol']); $this->enabled = false; } + public function onMkcol(RequestInterface $request, ResponseInterface $response) { + if (!$this->enabled || $this->share === null) { + return; + } + + $node = $this->share->getNode(); + if (!($node instanceof Folder)) { + return; + } + + // If this is a folder creation request we need + // to fake a success so we can pretend every + // folder now exists. + $response->setStatus(201); + return false; + } + public function beforeMethod(RequestInterface $request, ResponseInterface $response) { - if (!$this->enabled) { + if (!$this->enabled || $this->share === null) { return; } + $node = $this->share->getNode(); + if (!($node instanceof Folder)) { + return; + } + + // Retrieve the nickname from the request + $nickname = $request->hasHeader('X-NC-Nickname') + ? trim(urldecode($request->getHeader('X-NC-Nickname'))) + : null; + if ($request->getMethod() !== 'PUT') { - throw new MethodNotAllowed('Only PUT is allowed on files drop'); + // If uploading subfolders we need to ensure they get created + // within the nickname folder + if ($request->getMethod() === 'MKCOL') { + if (!$nickname) { + throw new BadRequest('A nickname header is required when uploading subfolders'); + } + } else { + throw new MethodNotAllowed('Only PUT is allowed on files drop'); + } + } + + // If this is a folder creation request + // let's stop there and let the onMkcol handle it + if ($request->getMethod() === 'MKCOL') { + return; + } + + // Now if we create a file, we need to create the + // full path along the way. We'll only handle conflict + // resolution on file conflicts, but not on folders. + + // e.g files/dCP8yn3N86EK9sL/Folder/image.jpg + $path = $request->getPath(); + $token = $this->share->getToken(); + + // e.g files/dCP8yn3N86EK9sL + $rootPath = substr($path, 0, strpos($path, $token) + strlen($token)); + // e.g /Folder/image.jpg + $relativePath = substr($path, strlen($rootPath)); + $isRootUpload = substr_count($relativePath, '/') === 1; + + // Extract the attributes for the file request + $isFileRequest = false; + $attributes = $this->share->getAttributes(); + if ($attributes !== null) { + $isFileRequest = $attributes->getAttribute('fileRequest', 'enabled') === true; + } + + // We need a valid nickname for file requests + if ($isFileRequest && !$nickname) { + throw new BadRequest('A nickname header is required for file requests'); + } + + // We're only allowing the upload of + // long path with subfolders if a nickname is set. + // This prevents confusion when uploading files and help + // classify them by uploaders. + if (!$nickname && !$isRootUpload) { + throw new BadRequest('A nickname header is required when uploading subfolders'); } - $path = explode('/', $request->getPath()); - $path = array_pop($path); + if ($nickname) { + try { + $node->verifyPath($nickname); + } catch (\Exception $e) { + // If the path is not valid, we throw an exception + throw new BadRequest('Invalid nickname: ' . $nickname); + } - $newName = \OC_Helper::buildNotExistingFileNameForView('/', $path, $this->view); - $url = $request->getBaseUrl() . $newName; + // Forbid nicknames starting with a dot + if (str_starts_with($nickname, '.')) { + throw new BadRequest('Invalid nickname: ' . $nickname); + } + + // If we have a nickname, let's put + // all files in the subfolder + $relativePath = '/' . $nickname . '/' . $relativePath; + $relativePath = str_replace('//', '/', $relativePath); + } + + // Create the folders along the way + $folder = $node; + $pathSegments = $this->getPathSegments(dirname($relativePath)); + foreach ($pathSegments as $pathSegment) { + if ($pathSegment === '') { + continue; + } + + try { + // get the current folder + $currentFolder = $folder->get($pathSegment); + // check target is a folder + if ($currentFolder instanceof Folder) { + $folder = $currentFolder; + } else { + // otherwise look in the parent folder if we already create an unique folder name + foreach ($folder->getDirectoryListing() as $child) { + // we look for folders which match "NAME (SUFFIX)" + if ($child instanceof Folder && str_starts_with($child->getName(), $pathSegment)) { + $suffix = substr($child->getName(), strlen($pathSegment)); + if (preg_match('/^ \(\d+\)$/', $suffix)) { + // we found the unique folder name and can use it + $folder = $child; + break; + } + } + } + // no folder found so we need to create a new unique folder name + if (!isset($child) || $child !== $folder) { + $folder = $folder->newFolder($folder->getNonExistingName($pathSegment)); + } + } + } catch (NotFoundException) { + // the folder does simply not exist so we create it + $folder = $folder->newFolder($pathSegment); + } + } + + // Finally handle conflicts on the end files + $uniqueName = $folder->getNonExistingName(basename($relativePath)); + $relativePath = substr($folder->getPath(), strlen($node->getPath())); + $path = '/files/' . $token . '/' . $relativePath . '/' . $uniqueName; + $url = rtrim($request->getBaseUrl(), '/') . str_replace('//', '/', $path); $request->setUrl($url); } + + private function getPathSegments(string $path): array { + // Normalize slashes and remove trailing slash + $path = trim(str_replace('\\', '/', $path), '/'); + + return explode('/', $path); + } } diff --git a/apps/dav/lib/Files/Sharing/PublicLinkCheckPlugin.php b/apps/dav/lib/Files/Sharing/PublicLinkCheckPlugin.php index 94cd6d29c6c..38a45b3fc37 100644 --- a/apps/dav/lib/Files/Sharing/PublicLinkCheckPlugin.php +++ b/apps/dav/lib/Files/Sharing/PublicLinkCheckPlugin.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @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\Files\Sharing; @@ -57,7 +41,7 @@ class PublicLinkCheckPlugin extends ServerPlugin { } public function beforeMethod(RequestInterface $request, ResponseInterface $response) { - // verify that the owner didn't have his share permissions revoked + // verify that the owner didn't have their share permissions revoked if ($this->fileInfo && !$this->fileInfo->isShareable()) { throw new NotFound(); } diff --git a/apps/dav/lib/Files/Sharing/RootCollection.php b/apps/dav/lib/Files/Sharing/RootCollection.php new file mode 100644 index 00000000000..dd585fbb59b --- /dev/null +++ b/apps/dav/lib/Files/Sharing/RootCollection.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Files\Sharing; + +use Sabre\DAV\INode; +use Sabre\DAVACL\AbstractPrincipalCollection; +use Sabre\DAVACL\PrincipalBackend\BackendInterface; + +class RootCollection extends AbstractPrincipalCollection { + public function __construct( + private INode $root, + BackendInterface $principalBackend, + string $principalPrefix = 'principals', + ) { + parent::__construct($principalBackend, $principalPrefix); + } + + public function getChildForPrincipal(array $principalInfo): INode { + return $this->root; + } + + public function getName() { + return 'files'; + } +} diff --git a/apps/dav/lib/HookManager.php b/apps/dav/lib/HookManager.php deleted file mode 100644 index 1287104fd10..00000000000 --- a/apps/dav/lib/HookManager.php +++ /dev/null @@ -1,194 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ -namespace OCA\DAV; - -use OCA\DAV\CalDAV\CalDavBackend; -use OCA\DAV\CardDAV\CardDavBackend; -use OCA\DAV\CardDAV\SyncService; -use OCP\Defaults; -use OCP\IUser; -use OCP\IUserManager; -use OCP\Util; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; - -class HookManager { - - /** @var IUserManager */ - private $userManager; - - /** @var SyncService */ - private $syncService; - - /** @var IUser[] */ - private $usersToDelete = []; - - /** @var CalDavBackend */ - private $calDav; - - /** @var CardDavBackend */ - private $cardDav; - - /** @var array */ - private $calendarsToDelete = []; - - /** @var array */ - private $subscriptionsToDelete = []; - - /** @var array */ - private $addressBooksToDelete = []; - - /** @var Defaults */ - private $themingDefaults; - - /** @var EventDispatcherInterface */ - private $eventDispatcher; - - public function __construct(IUserManager $userManager, - SyncService $syncService, - CalDavBackend $calDav, - CardDavBackend $cardDav, - Defaults $themingDefaults, - EventDispatcherInterface $eventDispatcher) { - $this->userManager = $userManager; - $this->syncService = $syncService; - $this->calDav = $calDav; - $this->cardDav = $cardDav; - $this->themingDefaults = $themingDefaults; - $this->eventDispatcher = $eventDispatcher; - } - - public function setup() { - Util::connectHook('OC_User', - 'post_createUser', - $this, - 'postCreateUser'); - \OC::$server->getUserManager()->listen('\OC\User', 'assignedUserId', function ($uid) { - $this->postCreateUser(['uid' => $uid]); - }); - Util::connectHook('OC_User', - 'pre_deleteUser', - $this, - 'preDeleteUser'); - \OC::$server->getUserManager()->listen('\OC\User', 'preUnassignedUserId', [$this, 'preUnassignedUserId']); - Util::connectHook('OC_User', - 'post_deleteUser', - $this, - 'postDeleteUser'); - \OC::$server->getUserManager()->listen('\OC\User', 'postUnassignedUserId', function ($uid) { - $this->postDeleteUser(['uid' => $uid]); - }); - \OC::$server->getUserManager()->listen('\OC\User', 'postUnassignedUserId', [$this, 'postUnassignedUserId']); - Util::connectHook('OC_User', - 'changeUser', - $this, - 'changeUser'); - } - - public function postCreateUser($params) { - $user = $this->userManager->get($params['uid']); - if ($user instanceof IUser) { - $this->syncService->updateUser($user); - } - } - - public function preDeleteUser($params) { - $uid = $params['uid']; - $userPrincipalUri = 'principals/users/' . $uid; - $this->usersToDelete[$uid] = $this->userManager->get($uid); - $this->calendarsToDelete = $this->calDav->getUsersOwnCalendars($userPrincipalUri); - $this->subscriptionsToDelete = $this->calDav->getSubscriptionsForUser($userPrincipalUri); - $this->addressBooksToDelete = $this->cardDav->getUsersOwnAddressBooks($userPrincipalUri); - } - - public function preUnassignedUserId($uid) { - $this->usersToDelete[$uid] = $this->userManager->get($uid); - } - - public function postDeleteUser($params) { - $uid = $params['uid']; - if (isset($this->usersToDelete[$uid])) { - $this->syncService->deleteUser($this->usersToDelete[$uid]); - } - - foreach ($this->calendarsToDelete as $calendar) { - $this->calDav->deleteCalendar( - $calendar['id'], - true // Make sure the data doesn't go into the trashbin, a new user with the same UID would later see it otherwise - ); - } - - foreach ($this->subscriptionsToDelete as $subscription) { - $this->calDav->deleteSubscription( - $subscription['id'], - ); - } - $this->calDav->deleteAllSharesByUser('principals/users/' . $uid); - - foreach ($this->addressBooksToDelete as $addressBook) { - $this->cardDav->deleteAddressBook($addressBook['id']); - } - } - - public function postUnassignedUserId($uid) { - if (isset($this->usersToDelete[$uid])) { - $this->syncService->deleteUser($this->usersToDelete[$uid]); - } - } - - public function changeUser($params) { - $user = $params['user']; - $this->syncService->updateUser($user); - } - - public function firstLogin(IUser $user = null) { - if (!is_null($user)) { - $principal = 'principals/users/' . $user->getUID(); - if ($this->calDav->getCalendarsForUserCount($principal) === 0) { - try { - $this->calDav->createCalendar($principal, CalDavBackend::PERSONAL_CALENDAR_URI, [ - '{DAV:}displayname' => CalDavBackend::PERSONAL_CALENDAR_NAME, - '{http://apple.com/ns/ical/}calendar-color' => $this->themingDefaults->getColorPrimary(), - 'components' => 'VEVENT' - ]); - } catch (\Exception $ex) { - \OC::$server->getLogger()->logException($ex); - } - } - if ($this->cardDav->getAddressBooksForUserCount($principal) === 0) { - try { - $this->cardDav->createAddressBook($principal, CardDavBackend::PERSONAL_ADDRESSBOOK_URI, [ - '{DAV:}displayname' => CardDavBackend::PERSONAL_ADDRESSBOOK_NAME, - ]); - } catch (\Exception $ex) { - \OC::$server->getLogger()->logException($ex); - } - } - } - } -} diff --git a/apps/dav/lib/Listener/ActivityUpdaterListener.php b/apps/dav/lib/Listener/ActivityUpdaterListener.php index 371912ff035..f291e424c41 100644 --- a/apps/dav/lib/Listener/ActivityUpdaterListener.php +++ b/apps/dav/lib/Listener/ActivityUpdaterListener.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Listener; @@ -30,31 +13,27 @@ use OCA\DAV\DAV\Sharing\Plugin; use OCA\DAV\Events\CalendarCreatedEvent; use OCA\DAV\Events\CalendarDeletedEvent; use OCA\DAV\Events\CalendarMovedToTrashEvent; -use OCA\DAV\Events\CalendarObjectCreatedEvent; -use OCA\DAV\Events\CalendarObjectDeletedEvent; -use OCA\DAV\Events\CalendarObjectMovedToTrashEvent; -use OCA\DAV\Events\CalendarObjectRestoredEvent; -use OCA\DAV\Events\CalendarObjectUpdatedEvent; use OCA\DAV\Events\CalendarRestoredEvent; use OCA\DAV\Events\CalendarUpdatedEvent; +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\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use Psr\Log\LoggerInterface; use Throwable; use function sprintf; +/** @template-implements IEventListener<CalendarCreatedEvent|CalendarUpdatedEvent|CalendarMovedToTrashEvent|CalendarRestoredEvent|CalendarDeletedEvent|CalendarObjectCreatedEvent|CalendarObjectUpdatedEvent|CalendarObjectMovedEvent|CalendarObjectMovedToTrashEvent|CalendarObjectRestoredEvent|CalendarObjectDeletedEvent> */ class ActivityUpdaterListener implements IEventListener { - /** @var ActivityBackend */ - private $activityBackend; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(ActivityBackend $activityBackend, - LoggerInterface $logger) { - $this->activityBackend = $activityBackend; - $this->logger = $logger; + public function __construct( + private ActivityBackend $activityBackend, + private LoggerInterface $logger, + ) { } public function handle(Event $event): void { @@ -173,7 +152,26 @@ class ActivityUpdaterListener implements IEventListener { ); $this->logger->debug( - sprintf('Activity generated for deleted calendar object %d', $event->getCalendarId()) + sprintf('Activity generated for updated calendar object in calendar %d', $event->getCalendarId()) + ); + } catch (Throwable $e) { + // Any error with activities shouldn't abort the calendar deletion, so we just log it + $this->logger->error('Error generating activity for a deleted calendar object: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } elseif ($event instanceof CalendarObjectMovedEvent) { + try { + $this->activityBackend->onMovedCalendarObject( + $event->getSourceCalendarData(), + $event->getTargetCalendarData(), + $event->getSourceShares(), + $event->getTargetShares(), + $event->getObjectData() + ); + + $this->logger->debug( + sprintf('Activity generated for moved calendar object from calendar %d to calendar %d', $event->getSourceCalendarId(), $event->getTargetCalendarId()) ); } catch (Throwable $e) { // Any error with activities shouldn't abort the calendar deletion, so we just log it diff --git a/apps/dav/lib/Listener/AddMissingIndicesListener.php b/apps/dav/lib/Listener/AddMissingIndicesListener.php new file mode 100644 index 00000000000..d3a1cf4b224 --- /dev/null +++ b/apps/dav/lib/Listener/AddMissingIndicesListener.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\Listener; + +use OCP\DB\Events\AddMissingIndicesEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** + * @template-implements IEventListener<Event|AddMissingIndicesEvent> + */ +class AddMissingIndicesListener implements IEventListener { + + public function handle(Event $event): void { + if (!($event instanceof AddMissingIndicesEvent)) { + return; + } + $event->addMissingIndex( + 'dav_shares', + 'dav_shares_resourceid_type', + ['resourceid', 'type'] + ); + $event->addMissingIndex( + 'dav_shares', + 'dav_shares_resourceid_access', + ['resourceid', 'access'] + ); + $event->addMissingIndex( + 'calendarobjects', + 'calobjects_by_uid_index', + ['calendarid', 'calendartype', 'uid'] + ); + } + +} diff --git a/apps/dav/lib/Listener/AddressbookListener.php b/apps/dav/lib/Listener/AddressbookListener.php index 3c17d399d4e..4e38ce50dfd 100644 --- a/apps/dav/lib/Listener/AddressbookListener.php +++ b/apps/dav/lib/Listener/AddressbookListener.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Listener; @@ -36,17 +19,12 @@ use Psr\Log\LoggerInterface; use Throwable; use function sprintf; +/** @template-implements IEventListener<AddressBookCreatedEvent|AddressBookUpdatedEvent|AddressBookDeletedEvent|AddressBookShareUpdatedEvent> */ class AddressbookListener implements IEventListener { - /** @var ActivityBackend */ - private $activityBackend; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(ActivityBackend $activityBackend, - LoggerInterface $logger) { - $this->activityBackend = $activityBackend; - $this->logger = $logger; + public function __construct( + private ActivityBackend $activityBackend, + private LoggerInterface $logger, + ) { } public function handle(Event $event): void { diff --git a/apps/dav/lib/Listener/BirthdayListener.php b/apps/dav/lib/Listener/BirthdayListener.php new file mode 100644 index 00000000000..3a464d668f9 --- /dev/null +++ b/apps/dav/lib/Listener/BirthdayListener.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Listener; + +use OCA\DAV\CalDAV\BirthdayService; +use OCA\DAV\Events\CardCreatedEvent; +use OCA\DAV\Events\CardDeletedEvent; +use OCA\DAV\Events\CardUpdatedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** @template-implements IEventListener<CardCreatedEvent|CardUpdatedEvent|CardDeletedEvent> */ +class BirthdayListener implements IEventListener { + public function __construct( + private BirthdayService $birthdayService, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof CardCreatedEvent || $event instanceof CardUpdatedEvent) { + $cardData = $event->getCardData(); + + $this->birthdayService->onCardChanged($event->getAddressBookId(), $cardData['uri'], $cardData['carddata']); + } + + if ($event instanceof CardDeletedEvent) { + $cardData = $event->getCardData(); + $this->birthdayService->onCardDeleted($event->getAddressBookId(), $cardData['uri']); + } + } +} diff --git a/apps/dav/lib/Listener/CalendarContactInteractionListener.php b/apps/dav/lib/Listener/CalendarContactInteractionListener.php index 04c759d5c3c..a7f00e452c4 100644 --- a/apps/dav/lib/Listener/CalendarContactInteractionListener.php +++ b/apps/dav/lib/Listener/CalendarContactInteractionListener.php @@ -3,32 +3,15 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Listener; use OCA\DAV\Connector\Sabre\Principal; -use OCA\DAV\Events\CalendarObjectCreatedEvent; -use OCA\DAV\Events\CalendarObjectUpdatedEvent; use OCA\DAV\Events\CalendarShareUpdatedEvent; +use OCP\Calendar\Events\CalendarObjectCreatedEvent; +use OCP\Calendar\Events\CalendarObjectUpdatedEvent; use OCP\Contacts\Events\ContactInteractedWithEvent; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventDispatcher; @@ -43,37 +26,19 @@ use Sabre\VObject\Property; use Sabre\VObject\Reader; use Throwable; use function strlen; -use function strpos; use function substr; +/** @template-implements IEventListener<CalendarObjectCreatedEvent|CalendarObjectUpdatedEvent|CalendarShareUpdatedEvent> */ class CalendarContactInteractionListener implements IEventListener { private const URI_USERS = 'principals/users/'; - /** @var IEventDispatcher */ - private $dispatcher; - - /** @var IUserSession */ - private $userSession; - - /** @var Principal */ - private $principalConnector; - - /** @var IMailer */ - private $mailer; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(IEventDispatcher $dispatcher, - IUserSession $userSession, - Principal $principalConnector, - IMailer $mailer, - LoggerInterface $logger) { - $this->dispatcher = $dispatcher; - $this->userSession = $userSession; - $this->principalConnector = $principalConnector; - $this->mailer = $mailer; - $this->logger = $logger; + public function __construct( + private IEventDispatcher $dispatcher, + private IUserSession $userSession, + private Principal $principalConnector, + private IMailer $mailer, + private LoggerInterface $logger, + ) { } public function handle(Event $event): void { @@ -130,7 +95,7 @@ class CalendarContactInteractionListener implements IEventListener { // Invalid principal return; } - if (strpos($principal, self::URI_USERS) !== 0) { + if (!str_starts_with($principal, self::URI_USERS)) { // Not a user principal return; } @@ -159,7 +124,7 @@ class CalendarContactInteractionListener implements IEventListener { } $mailTo = $attendee->getValue(); - if (strpos($mailTo, 'mailto:') !== 0) { + if (!str_starts_with($mailTo, 'mailto:')) { // Doesn't look like an email continue; } diff --git a/apps/dav/lib/Listener/CalendarDeletionDefaultUpdaterListener.php b/apps/dav/lib/Listener/CalendarDeletionDefaultUpdaterListener.php index 7bb535383c0..0cfc435eb8c 100644 --- a/apps/dav/lib/Listener/CalendarDeletionDefaultUpdaterListener.php +++ b/apps/dav/lib/Listener/CalendarDeletionDefaultUpdaterListener.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Listener; @@ -31,23 +14,16 @@ use OCP\EventDispatcher\IEventListener; use OCP\IConfig; use Psr\Log\LoggerInterface; use Throwable; -use function strpos; /** - * @template-implements IEventListener<\OCA\DAV\Events\CalendarDeletedEvent> + * @template-implements IEventListener<CalendarDeletedEvent> */ class CalendarDeletionDefaultUpdaterListener implements IEventListener { - /** @var IConfig */ - private $config; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(IConfig $config, - LoggerInterface $logger) { - $this->config = $config; - $this->logger = $logger; + public function __construct( + private IConfig $config, + private LoggerInterface $logger, + ) { } /** @@ -61,7 +37,7 @@ class CalendarDeletionDefaultUpdaterListener implements IEventListener { try { $principalUri = $event->getCalendarData()['principaluri']; - if (strpos($principalUri, 'principals/users') !== 0) { + if (!str_starts_with($principalUri, 'principals/users')) { $this->logger->debug('Default calendar needs no update because the deleted calendar does not belong to a user principal'); return; } diff --git a/apps/dav/lib/Listener/CalendarObjectReminderUpdaterListener.php b/apps/dav/lib/Listener/CalendarObjectReminderUpdaterListener.php index 3c168f6105c..a58fb3524ab 100644 --- a/apps/dav/lib/Listener/CalendarObjectReminderUpdaterListener.php +++ b/apps/dav/lib/Listener/CalendarObjectReminderUpdaterListener.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Listener; @@ -30,40 +13,27 @@ use OCA\DAV\CalDAV\Reminder\Backend as ReminderBackend; use OCA\DAV\CalDAV\Reminder\ReminderService; use OCA\DAV\Events\CalendarDeletedEvent; use OCA\DAV\Events\CalendarMovedToTrashEvent; -use OCA\DAV\Events\CalendarObjectCreatedEvent; -use OCA\DAV\Events\CalendarObjectDeletedEvent; -use OCA\DAV\Events\CalendarObjectMovedToTrashEvent; -use OCA\DAV\Events\CalendarObjectRestoredEvent; -use OCA\DAV\Events\CalendarObjectUpdatedEvent; use OCA\DAV\Events\CalendarRestoredEvent; +use OCP\Calendar\Events\CalendarObjectCreatedEvent; +use OCP\Calendar\Events\CalendarObjectDeletedEvent; +use OCP\Calendar\Events\CalendarObjectMovedToTrashEvent; +use OCP\Calendar\Events\CalendarObjectRestoredEvent; +use OCP\Calendar\Events\CalendarObjectUpdatedEvent; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use Psr\Log\LoggerInterface; use Throwable; use function sprintf; +/** @template-implements IEventListener<CalendarMovedToTrashEvent|CalendarDeletedEvent|CalendarRestoredEvent|CalendarObjectCreatedEvent|CalendarObjectUpdatedEvent|CalendarObjectMovedToTrashEvent|CalendarObjectRestoredEvent|CalendarObjectDeletedEvent> */ class CalendarObjectReminderUpdaterListener implements IEventListener { - /** @var ReminderBackend */ - private $reminderBackend; - - /** @var ReminderService */ - private $reminderService; - - /** @var CalDavBackend */ - private $calDavBackend; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(ReminderBackend $reminderBackend, - ReminderService $reminderService, - CalDavBackend $calDavBackend, - LoggerInterface $logger) { - $this->reminderBackend = $reminderBackend; - $this->reminderService = $reminderService; - $this->calDavBackend = $calDavBackend; - $this->logger = $logger; + public function __construct( + private ReminderBackend $reminderBackend, + private ReminderService $reminderService, + private CalDavBackend $calDavBackend, + private LoggerInterface $logger, + ) { } public function handle(Event $event): void { diff --git a/apps/dav/lib/Listener/CalendarPublicationListener.php b/apps/dav/lib/Listener/CalendarPublicationListener.php new file mode 100644 index 00000000000..94a0a208d4e --- /dev/null +++ b/apps/dav/lib/Listener/CalendarPublicationListener.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Listener; + +use OCA\DAV\CalDAV\Activity\Backend; +use OCA\DAV\Events\CalendarPublishedEvent; +use OCA\DAV\Events\CalendarUnpublishedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** @template-implements IEventListener<CalendarPublishedEvent|CalendarUnpublishedEvent> */ +class CalendarPublicationListener implements IEventListener { + public function __construct( + private Backend $activityBackend, + private LoggerInterface $logger, + ) { + } + + /** + * In case the user has set their default calendar to the deleted one + */ + public function handle(Event $event): void { + if ($event instanceof CalendarPublishedEvent) { + $this->logger->debug('Creating activity for Calendar being published'); + + $this->activityBackend->onCalendarPublication( + $event->getCalendarData(), + true + ); + } elseif ($event instanceof CalendarUnpublishedEvent) { + $this->logger->debug('Creating activity for Calendar being unpublished'); + + $this->activityBackend->onCalendarPublication( + $event->getCalendarData(), + false + ); + } + } +} diff --git a/apps/dav/lib/Listener/CalendarShareUpdateListener.php b/apps/dav/lib/Listener/CalendarShareUpdateListener.php new file mode 100644 index 00000000000..b673d5d2e42 --- /dev/null +++ b/apps/dav/lib/Listener/CalendarShareUpdateListener.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Listener; + +use OCA\DAV\CalDAV\Activity\Backend; +use OCA\DAV\Events\CalendarShareUpdatedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** @template-implements IEventListener<CalendarShareUpdatedEvent> */ +class CalendarShareUpdateListener implements IEventListener { + public function __construct( + private Backend $activityBackend, + private LoggerInterface $logger, + ) { + } + + /** + * In case the user has set their default calendar to the deleted one + */ + public function handle(Event $event): void { + if (!($event instanceof CalendarShareUpdatedEvent)) { + // Not what we subscribed to + return; + } + + $this->logger->debug('Creating activity for Calendar having its shares updated'); + + $this->activityBackend->onCalendarUpdateShares( + $event->getCalendarData(), + $event->getOldShares(), + $event->getAdded(), + $event->getRemoved() + ); + + // Here we should recalculate if reminders should be sent to new or old sharees + } +} diff --git a/apps/dav/lib/Listener/CardListener.php b/apps/dav/lib/Listener/CardListener.php index 0281127c858..b9fd1a7f64b 100644 --- a/apps/dav/lib/Listener/CardListener.php +++ b/apps/dav/lib/Listener/CardListener.php @@ -3,30 +3,13 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Listener; -use OCA\DAV\CardDAV\Activity\Provider\Card; use OCA\DAV\CardDAV\Activity\Backend as ActivityBackend; +use OCA\DAV\CardDAV\Activity\Provider\Card; use OCA\DAV\Events\CardCreatedEvent; use OCA\DAV\Events\CardDeletedEvent; use OCA\DAV\Events\CardUpdatedEvent; @@ -36,17 +19,12 @@ use Psr\Log\LoggerInterface; use Throwable; use function sprintf; +/** @template-implements IEventListener<CardCreatedEvent|CardUpdatedEvent|CardDeletedEvent> */ class CardListener implements IEventListener { - /** @var ActivityBackend */ - private $activityBackend; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(ActivityBackend $activityBackend, - LoggerInterface $logger) { - $this->activityBackend = $activityBackend; - $this->logger = $logger; + public function __construct( + private ActivityBackend $activityBackend, + private LoggerInterface $logger, + ) { } public function handle(Event $event): void { diff --git a/apps/dav/lib/Listener/ClearPhotoCacheListener.php b/apps/dav/lib/Listener/ClearPhotoCacheListener.php new file mode 100644 index 00000000000..eb599d33871 --- /dev/null +++ b/apps/dav/lib/Listener/ClearPhotoCacheListener.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Listener; + +use OCA\DAV\CardDAV\PhotoCache; +use OCA\DAV\Events\CardDeletedEvent; +use OCA\DAV\Events\CardUpdatedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** @template-implements IEventListener<CardUpdatedEvent|CardDeletedEvent> */ +class ClearPhotoCacheListener implements IEventListener { + public function __construct( + private PhotoCache $photoCache, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof CardUpdatedEvent || $event instanceof CardDeletedEvent) { + $cardData = $event->getCardData(); + + $this->photoCache->delete($event->getAddressBookId(), $cardData['uri']); + } + } +} diff --git a/apps/dav/lib/Listener/DavAdminSettingsListener.php b/apps/dav/lib/Listener/DavAdminSettingsListener.php new file mode 100644 index 00000000000..69501915208 --- /dev/null +++ b/apps/dav/lib/Listener/DavAdminSettingsListener.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Listener; + +use OCA\DAV\AppInfo\Application; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IAppConfig; +use OCP\Settings\Events\DeclarativeSettingsGetValueEvent; +use OCP\Settings\Events\DeclarativeSettingsSetValueEvent; + +/** @template-implements IEventListener<DeclarativeSettingsGetValueEvent|DeclarativeSettingsSetValueEvent> */ +class DavAdminSettingsListener implements IEventListener { + + public function __construct( + private IAppConfig $config, + ) { + } + + public function handle(Event $event): void { + + /** @var DeclarativeSettingsGetValueEvent|DeclarativeSettingsSetValueEvent $event */ + if ($event->getApp() !== Application::APP_ID) { + return; + } + + if ($event->getFormId() !== 'dav-admin-system-address-book') { + return; + } + + if ($event instanceof DeclarativeSettingsGetValueEvent) { + $this->handleGetValue($event); + return; + } + + if ($event instanceof DeclarativeSettingsSetValueEvent) { + $this->handleSetValue($event); + return; + } + + } + + private function handleGetValue(DeclarativeSettingsGetValueEvent $event): void { + + if ($event->getFieldId() === 'system_addressbook_enabled') { + $event->setValue((int)$this->config->getValueBool('dav', 'system_addressbook_exposed', true)); + } + + } + + private function handleSetValue(DeclarativeSettingsSetValueEvent $event): void { + + if ($event->getFieldId() === 'system_addressbook_enabled') { + $this->config->setValueBool('dav', 'system_addressbook_exposed', (bool)$event->getValue()); + $event->stopPropagation(); + } + + } + +} diff --git a/apps/dav/lib/Listener/OutOfOfficeListener.php b/apps/dav/lib/Listener/OutOfOfficeListener.php new file mode 100644 index 00000000000..45728aa35d3 --- /dev/null +++ b/apps/dav/lib/Listener/OutOfOfficeListener.php @@ -0,0 +1,179 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Listener; + +use DateTimeImmutable; +use DateTimeZone; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Calendar; +use OCA\DAV\CalDAV\CalendarHome; +use OCA\DAV\CalDAV\TimezoneService; +use OCA\DAV\ServerFactory; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IConfig; +use OCP\User\Events\OutOfOfficeChangedEvent; +use OCP\User\Events\OutOfOfficeClearedEvent; +use OCP\User\Events\OutOfOfficeScheduledEvent; +use OCP\User\IOutOfOfficeData; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Exception\NotFound; +use Sabre\VObject\Component\VCalendar; +use function fclose; +use function fopen; +use function fwrite; +use function rewind; + +/** + * @template-implements IEventListener<OutOfOfficeScheduledEvent|OutOfOfficeChangedEvent|OutOfOfficeClearedEvent> + */ +class OutOfOfficeListener implements IEventListener { + public function __construct( + private ServerFactory $serverFactory, + private IConfig $appConfig, + private TimezoneService $timezoneService, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof OutOfOfficeScheduledEvent) { + $userId = $event->getData()->getUser()->getUID(); + $principal = "principals/users/$userId"; + $calendarNode = $this->getCalendarNode($principal, $userId); + if ($calendarNode === null) { + return; + } + $tzId = $this->timezoneService->getUserTimezone($userId) ?? $this->timezoneService->getDefaultTimezone(); + $vCalendarEvent = $this->createVCalendarEvent($event->getData(), $tzId); + $stream = fopen('php://memory', 'rb+'); + try { + fwrite($stream, $vCalendarEvent->serialize()); + rewind($stream); + $calendarNode->createFile( + $this->getEventFileName($event->getData()->getId()), + $stream, + ); + } finally { + fclose($stream); + } + } elseif ($event instanceof OutOfOfficeChangedEvent) { + $userId = $event->getData()->getUser()->getUID(); + $principal = "principals/users/$userId"; + $calendarNode = $this->getCalendarNode($principal, $userId); + if ($calendarNode === null) { + return; + } + $tzId = $this->timezoneService->getUserTimezone($userId) ?? $this->timezoneService->getDefaultTimezone(); + $vCalendarEvent = $this->createVCalendarEvent($event->getData(), $tzId); + try { + $oldEvent = $calendarNode->getChild($this->getEventFileName($event->getData()->getId())); + $oldEvent->put($vCalendarEvent->serialize()); + return; + } catch (NotFound) { + $stream = fopen('php://memory', 'rb+'); + try { + fwrite($stream, $vCalendarEvent->serialize()); + rewind($stream); + $calendarNode->createFile( + $this->getEventFileName($event->getData()->getId()), + $stream, + ); + } finally { + fclose($stream); + } + } + } elseif ($event instanceof OutOfOfficeClearedEvent) { + $userId = $event->getData()->getUser()->getUID(); + $principal = "principals/users/$userId"; + $calendarNode = $this->getCalendarNode($principal, $userId); + if ($calendarNode === null) { + return; + } + try { + $oldEvent = $calendarNode->getChild($this->getEventFileName($event->getData()->getId())); + $oldEvent->delete(); + } catch (NotFound) { + // The user must have deleted it or the default calendar changed -> ignore + return; + } + } + } + + private function getCalendarNode(string $principal, string $userId): ?Calendar { + $invitationServer = $this->serverFactory->createInviationResponseServer(false); + $server = $invitationServer->getServer(); + + /** @var \OCA\DAV\CalDAV\Plugin $caldavPlugin */ + $caldavPlugin = $server->getPlugin('caldav'); + $calendarHomePath = $caldavPlugin->getCalendarHomeForPrincipal($principal); + if ($calendarHomePath === null) { + $this->logger->debug('Principal has no calendar home path'); + return null; + } + try { + /** @var CalendarHome $calendarHome */ + $calendarHome = $server->tree->getNodeForPath($calendarHomePath); + } catch (NotFound $e) { + $this->logger->debug('Calendar home not found', [ + 'exception' => $e, + ]); + return null; + } + $uri = $this->appConfig->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI); + try { + $calendarNode = $calendarHome->getChild($uri); + } catch (NotFound $e) { + $this->logger->debug('Personal calendar does not exist', [ + 'exception' => $e, + 'uri' => $uri, + ]); + return null; + } + if (!($calendarNode instanceof Calendar)) { + $this->logger->warning('Personal calendar node is not a calendar'); + return null; + } + if ($calendarNode->isDeleted()) { + $this->logger->warning('Personal calendar has been deleted'); + return null; + } + + return $calendarNode; + } + + private function getEventFileName(string $id): string { + return "out_of_office_$id.ics"; + } + + private function createVCalendarEvent(IOutOfOfficeData $data, string $tzId): VCalendar { + $shortMessage = $data->getShortMessage(); + $longMessage = $data->getMessage(); + $start = (new DateTimeImmutable) + ->setTimezone(new DateTimeZone($tzId)) + ->setTimestamp($data->getStartDate()) + ->setTime(0, 0); + $end = (new DateTimeImmutable()) + ->setTimezone(new DateTimeZone($tzId)) + ->setTimestamp($data->getEndDate()) + ->modify('+ 1 days') + ->setTime(0, 0); + $vCalendar = new VCalendar(); + $vCalendar->add('VEVENT', [ + 'SUMMARY' => $shortMessage, + 'DESCRIPTION' => $longMessage, + 'STATUS' => 'CONFIRMED', + 'DTSTART' => $start, + 'DTEND' => $end, + 'X-NEXTCLOUD-OUT-OF-OFFICE' => $data->getId(), + ]); + return $vCalendar; + } +} diff --git a/apps/dav/lib/Listener/SubscriptionListener.php b/apps/dav/lib/Listener/SubscriptionListener.php new file mode 100644 index 00000000000..fc9dfcf122d --- /dev/null +++ b/apps/dav/lib/Listener/SubscriptionListener.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Listener; + +use OCA\DAV\BackgroundJob\RefreshWebcalJob; +use OCA\DAV\CalDAV\Reminder\Backend as ReminderBackend; +use OCA\DAV\CalDAV\WebcalCaching\RefreshWebcalService; +use OCA\DAV\Events\SubscriptionCreatedEvent; +use OCA\DAV\Events\SubscriptionDeletedEvent; +use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** @template-implements IEventListener<SubscriptionCreatedEvent|SubscriptionDeletedEvent> */ +class SubscriptionListener implements IEventListener { + public function __construct( + private IJobList $jobList, + private RefreshWebcalService $refreshWebcalService, + private ReminderBackend $reminderBackend, + private LoggerInterface $logger, + ) { + } + + /** + * In case the user has set their default calendar to the deleted one + */ + public function handle(Event $event): void { + if ($event instanceof SubscriptionCreatedEvent) { + $subscriptionId = $event->getSubscriptionId(); + $subscriptionData = $event->getSubscriptionData(); + + $this->logger->debug('Refreshing webcal data for subscription ' . $subscriptionId); + $this->refreshWebcalService->refreshSubscription( + (string)$subscriptionData['principaluri'], + (string)$subscriptionData['uri'] + ); + + $this->logger->debug('Scheduling webcal data refreshment for subscription ' . $subscriptionId); + $this->jobList->add(RefreshWebcalJob::class, [ + 'principaluri' => $subscriptionData['principaluri'], + 'uri' => $subscriptionData['uri'] + ]); + } elseif ($event instanceof SubscriptionDeletedEvent) { + $subscriptionId = $event->getSubscriptionId(); + $subscriptionData = $event->getSubscriptionData(); + + $this->logger->debug('Removing refresh webcal job for subscription ' . $subscriptionId); + $this->jobList->remove(RefreshWebcalJob::class, [ + 'principaluri' => $subscriptionData['principaluri'], + 'uri' => $subscriptionData['uri'] + ]); + + $this->logger->debug('Cleaning all reminders for subscription ' . $subscriptionId); + $this->reminderBackend->cleanRemindersForCalendar($subscriptionId); + } + } +} diff --git a/apps/dav/lib/Listener/TrustedServerRemovedListener.php b/apps/dav/lib/Listener/TrustedServerRemovedListener.php new file mode 100644 index 00000000000..9adbcfc14c2 --- /dev/null +++ b/apps/dav/lib/Listener/TrustedServerRemovedListener.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Listener; + +use OCA\DAV\CardDAV\CardDavBackend; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Federation\Events\TrustedServerRemovedEvent; + +/** @template-implements IEventListener<TrustedServerRemovedEvent> */ +class TrustedServerRemovedListener implements IEventListener { + public function __construct( + private CardDavBackend $cardDavBackend, + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof TrustedServerRemovedEvent) { + return; + } + $addressBookUri = $event->getUrlHash(); + $addressBook = $this->cardDavBackend->getAddressBooksByUri('principals/system/system', $addressBookUri); + if (!is_null($addressBook)) { + $this->cardDavBackend->deleteAddressBook($addressBook['id']); + } + } +} diff --git a/apps/dav/lib/Listener/UserEventsListener.php b/apps/dav/lib/Listener/UserEventsListener.php new file mode 100644 index 00000000000..a6b09b70fa0 --- /dev/null +++ b/apps/dav/lib/Listener/UserEventsListener.php @@ -0,0 +1,186 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Listener; + +use OCA\DAV\BackgroundJob\UserStatusAutomation; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CardDAV\CardDavBackend; +use OCA\DAV\CardDAV\SyncService; +use OCA\DAV\Service\ExampleContactService; +use OCA\DAV\Service\ExampleEventService; +use OCP\Accounts\UserUpdatedEvent; +use OCP\BackgroundJob\IJobList; +use OCP\Defaults; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IUser; +use OCP\IUserManager; +use OCP\User\Events\BeforeUserDeletedEvent; +use OCP\User\Events\BeforeUserIdUnassignedEvent; +use OCP\User\Events\UserChangedEvent; +use OCP\User\Events\UserCreatedEvent; +use OCP\User\Events\UserDeletedEvent; +use OCP\User\Events\UserFirstTimeLoggedInEvent; +use OCP\User\Events\UserIdAssignedEvent; +use OCP\User\Events\UserIdUnassignedEvent; +use Psr\Log\LoggerInterface; + +/** @template-implements IEventListener<UserFirstTimeLoggedInEvent|UserIdAssignedEvent|BeforeUserIdUnassignedEvent|UserIdUnassignedEvent|BeforeUserDeletedEvent|UserDeletedEvent|UserCreatedEvent|UserChangedEvent|UserUpdatedEvent> */ +class UserEventsListener implements IEventListener { + + /** @var IUser[] */ + private array $usersToDelete = []; + + private array $calendarsToDelete = []; + private array $subscriptionsToDelete = []; + private array $addressBooksToDelete = []; + + public function __construct( + private IUserManager $userManager, + private SyncService $syncService, + private CalDavBackend $calDav, + private CardDavBackend $cardDav, + private Defaults $themingDefaults, + private ExampleContactService $exampleContactService, + private ExampleEventService $exampleEventService, + private LoggerInterface $logger, + private IJobList $jobList, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof UserCreatedEvent) { + $this->postCreateUser($event->getUser()); + } elseif ($event instanceof UserIdAssignedEvent) { + $user = $this->userManager->get($event->getUserId()); + if ($user !== null) { + $this->postCreateUser($user); + } + } elseif ($event instanceof BeforeUserDeletedEvent) { + $this->preDeleteUser($event->getUser()); + } elseif ($event instanceof BeforeUserIdUnassignedEvent) { + $this->preUnassignedUserId($event->getUserId()); + } elseif ($event instanceof UserDeletedEvent) { + $this->postDeleteUser($event->getUid()); + } elseif ($event instanceof UserIdUnassignedEvent) { + $this->postDeleteUser($event->getUserId()); + } elseif ($event instanceof UserChangedEvent) { + $this->changeUser($event->getUser(), $event->getFeature()); + } elseif ($event instanceof UserFirstTimeLoggedInEvent) { + $this->firstLogin($event->getUser()); + } elseif ($event instanceof UserUpdatedEvent) { + $this->updateUser($event->getUser()); + } + } + + public function postCreateUser(IUser $user): void { + $this->syncService->updateUser($user); + } + + public function updateUser(IUser $user): void { + $this->syncService->updateUser($user); + } + + public function preDeleteUser(IUser $user): void { + $uid = $user->getUID(); + $userPrincipalUri = 'principals/users/' . $uid; + $this->usersToDelete[$uid] = $user; + $this->calendarsToDelete[$uid] = $this->calDav->getUsersOwnCalendars($userPrincipalUri); + $this->subscriptionsToDelete[$uid] = $this->calDav->getSubscriptionsForUser($userPrincipalUri); + $this->addressBooksToDelete[$uid] = $this->cardDav->getUsersOwnAddressBooks($userPrincipalUri); + } + + public function preUnassignedUserId(string $uid): void { + $user = $this->userManager->get($uid); + if ($user !== null) { + $this->usersToDelete[$uid] = $user; + } + } + + public function postDeleteUser(string $uid): void { + if (isset($this->usersToDelete[$uid])) { + $this->syncService->deleteUser($this->usersToDelete[$uid]); + } + + foreach ($this->calendarsToDelete[$uid] as $calendar) { + $this->calDav->deleteCalendar( + $calendar['id'], + true // Make sure the data doesn't go into the trashbin, a new user with the same UID would later see it otherwise + ); + } + + foreach ($this->subscriptionsToDelete[$uid] as $subscription) { + $this->calDav->deleteSubscription( + $subscription['id'], + ); + } + $this->calDav->deleteAllSharesByUser('principals/users/' . $uid); + + foreach ($this->addressBooksToDelete[$uid] as $addressBook) { + $this->cardDav->deleteAddressBook($addressBook['id']); + } + + $this->jobList->remove(UserStatusAutomation::class, ['userId' => $uid]); + + unset($this->calendarsToDelete[$uid]); + unset($this->subscriptionsToDelete[$uid]); + unset($this->addressBooksToDelete[$uid]); + } + + public function changeUser(IUser $user, string $feature): void { + // This case is already covered by the account manager firing up a signal + // later on + if ($feature !== 'eMailAddress' && $feature !== 'displayName') { + $this->syncService->updateUser($user); + } + } + + public function firstLogin(IUser $user): void { + $principal = 'principals/users/' . $user->getUID(); + + $calendarId = null; + if ($this->calDav->getCalendarsForUserCount($principal) === 0) { + try { + $calendarId = $this->calDav->createCalendar($principal, CalDavBackend::PERSONAL_CALENDAR_URI, [ + '{DAV:}displayname' => CalDavBackend::PERSONAL_CALENDAR_NAME, + '{http://apple.com/ns/ical/}calendar-color' => $this->themingDefaults->getColorPrimary(), + 'components' => 'VEVENT' + ]); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } + if ($calendarId !== null) { + try { + $this->exampleEventService->createExampleEvent($calendarId); + } catch (\Exception $e) { + $this->logger->error('Failed to create example event: ' . $e->getMessage(), [ + 'exception' => $e, + 'userId' => $user->getUID(), + 'calendarId' => $calendarId, + ]); + } + } + + $addressBookId = null; + if ($this->cardDav->getAddressBooksForUserCount($principal) === 0) { + try { + $addressBookId = $this->cardDav->createAddressBook($principal, CardDavBackend::PERSONAL_ADDRESSBOOK_URI, [ + '{DAV:}displayname' => CardDavBackend::PERSONAL_ADDRESSBOOK_NAME, + ]); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } + if ($addressBookId) { + $this->exampleContactService->createDefaultContact($addressBookId); + } + } +} diff --git a/apps/dav/lib/Listener/UserPreferenceListener.php b/apps/dav/lib/Listener/UserPreferenceListener.php new file mode 100644 index 00000000000..5f5fed05348 --- /dev/null +++ b/apps/dav/lib/Listener/UserPreferenceListener.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Listener; + +use OCA\DAV\BackgroundJob\UserStatusAutomation; +use OCP\BackgroundJob\IJobList; +use OCP\Config\BeforePreferenceDeletedEvent; +use OCP\Config\BeforePreferenceSetEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** @template-implements IEventListener<BeforePreferenceSetEvent|BeforePreferenceDeletedEvent> */ +class UserPreferenceListener implements IEventListener { + + public function __construct( + protected IJobList $jobList, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof BeforePreferenceSetEvent) { + if ($event->getAppId() === 'dav' && $event->getConfigKey() === 'user_status_automation' && $event->getConfigValue() === 'yes') { + $event->setValid(true); + + // Not the cleanest way, but we just add the job in the before event. + // If something ever turns wrong the first execution will remove the job again. + // We also first delete the current job, so the next run time is reset. + $this->jobList->remove(UserStatusAutomation::class, ['userId' => $event->getUserId()]); + $this->jobList->add(UserStatusAutomation::class, ['userId' => $event->getUserId()]); + } + } elseif ($event instanceof BeforePreferenceDeletedEvent) { + if ($event->getAppId() === 'dav' && $event->getConfigKey() === 'user_status_automation') { + $event->setValid(true); + } + } + } +} diff --git a/apps/dav/lib/Migration/BuildCalendarSearchIndex.php b/apps/dav/lib/Migration/BuildCalendarSearchIndex.php index c8a649f3449..d8f906f22ee 100644 --- a/apps/dav/lib/Migration/BuildCalendarSearchIndex.php +++ b/apps/dav/lib/Migration/BuildCalendarSearchIndex.php @@ -1,28 +1,8 @@ <?php + /** - * @copyright 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; @@ -34,26 +14,11 @@ use OCP\Migration\IRepairStep; class BuildCalendarSearchIndex implements IRepairStep { - /** @var IDBConnection */ - private $db; - - /** @var IJobList */ - private $jobList; - - /** @var IConfig */ - private $config; - - /** - * @param IDBConnection $db - * @param IJobList $jobList - * @param IConfig $config - */ - public function __construct(IDBConnection $db, - IJobList $jobList, - IConfig $config) { - $this->db = $db; - $this->jobList = $jobList; - $this->config = $config; + public function __construct( + private IDBConnection $db, + private IJobList $jobList, + private IConfig $config, + ) { } /** @@ -76,8 +41,8 @@ class BuildCalendarSearchIndex implements IRepairStep { $query = $this->db->getQueryBuilder(); $query->select($query->createFunction('MAX(' . $query->getColumnName('id') . ')')) ->from('calendarobjects'); - $result = $query->execute(); - $maxId = (int) $result->fetchOne(); + $result = $query->executeQuery(); + $maxId = (int)$result->fetchOne(); $result->closeCursor(); $output->info('Add background job'); diff --git a/apps/dav/lib/Migration/BuildCalendarSearchIndexBackgroundJob.php b/apps/dav/lib/Migration/BuildCalendarSearchIndexBackgroundJob.php index 6a315f2a150..da8f31e7d3d 100644 --- a/apps/dav/lib/Migration/BuildCalendarSearchIndexBackgroundJob.php +++ b/apps/dav/lib/Migration/BuildCalendarSearchIndexBackgroundJob.php @@ -1,79 +1,39 @@ <?php + +declare(strict_types=1); + /** - * @copyright 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\Migration; -use OC\BackgroundJob\QueuedJob; use OCA\DAV\CalDAV\CalDavBackend; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; use OCP\IDBConnection; -use OCP\ILogger; +use Psr\Log\LoggerInterface; class BuildCalendarSearchIndexBackgroundJob extends QueuedJob { - - /** @var IDBConnection */ - private $db; - - /** @var CalDavBackend */ - private $calDavBackend; - - /** @var ILogger */ - private $logger; - - /** @var IJobList */ - private $jobList; - - /** @var ITimeFactory */ - private $timeFactory; - - /** - * @param IDBConnection $db - * @param CalDavBackend $calDavBackend - * @param ILogger $logger - * @param IJobList $jobList - * @param ITimeFactory $timeFactory - */ - public function __construct(IDBConnection $db, - CalDavBackend $calDavBackend, - ILogger $logger, - IJobList $jobList, - ITimeFactory $timeFactory) { - $this->db = $db; - $this->calDavBackend = $calDavBackend; - $this->logger = $logger; - $this->jobList = $jobList; - $this->timeFactory = $timeFactory; + public function __construct( + private IDBConnection $db, + private CalDavBackend $calDavBackend, + private LoggerInterface $logger, + private IJobList $jobList, + ITimeFactory $timeFactory, + ) { + parent::__construct($timeFactory); } public function run($arguments) { - $offset = (int) $arguments['offset']; - $stopAt = (int) $arguments['stopAt']; + $offset = (int)$arguments['offset']; + $stopAt = (int)$arguments['stopAt']; - $this->logger->info('Building calendar index (' . $offset .'/' . $stopAt . ')'); + $this->logger->info('Building calendar index (' . $offset . '/' . $stopAt . ')'); - $startTime = $this->timeFactory->getTime(); - while (($this->timeFactory->getTime() - $startTime) < 15) { + $startTime = $this->time->getTime(); + while (($this->time->getTime() - $startTime) < 15) { $offset = $this->buildIndex($offset, $stopAt); if ($offset >= $stopAt) { break; @@ -105,7 +65,7 @@ class BuildCalendarSearchIndexBackgroundJob extends QueuedJob { ->orderBy('id', 'ASC') ->setMaxResults(500); - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch(\PDO::FETCH_ASSOC)) { $offset = $row['id']; diff --git a/apps/dav/lib/Migration/BuildSocialSearchIndex.php b/apps/dav/lib/Migration/BuildSocialSearchIndex.php index ae2eb084e2b..a808034365a 100644 --- a/apps/dav/lib/Migration/BuildSocialSearchIndex.php +++ b/apps/dav/lib/Migration/BuildSocialSearchIndex.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author call-me-matt <nextcloud@matthiasheinisch.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; @@ -31,26 +14,16 @@ use OCP\Migration\IRepairStep; class BuildSocialSearchIndex implements IRepairStep { - /** @var IDBConnection */ - private $db; - - /** @var IJobList */ - private $jobList; - - /** @var IConfig */ - private $config; - /** * @param IDBConnection $db * @param IJobList $jobList * @param IConfig $config */ - public function __construct(IDBConnection $db, - IJobList $jobList, - IConfig $config) { - $this->db = $db; - $this->jobList = $jobList; - $this->config = $config; + public function __construct( + private IDBConnection $db, + private IJobList $jobList, + private IConfig $config, + ) { } /** diff --git a/apps/dav/lib/Migration/BuildSocialSearchIndexBackgroundJob.php b/apps/dav/lib/Migration/BuildSocialSearchIndexBackgroundJob.php index 98afecc3b7d..fab61d56fd6 100644 --- a/apps/dav/lib/Migration/BuildSocialSearchIndexBackgroundJob.php +++ b/apps/dav/lib/Migration/BuildSocialSearchIndexBackgroundJob.php @@ -1,75 +1,37 @@ <?php + +declare(strict_types=1); + /** - * @copyright 2020 Matthias Heinisch <nextcloud@matthiasheinisch.de> - * - * @author call-me-matt <nextcloud@matthiasheinisch.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; -use OC\BackgroundJob\QueuedJob; use OCA\DAV\CardDAV\CardDavBackend; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; -use OCP\ILogger; +use Psr\Log\LoggerInterface; class BuildSocialSearchIndexBackgroundJob extends QueuedJob { - - /** @var IDBConnection */ - private $db; - - /** @var CardDavBackend */ - private $davBackend; - - /** @var ILogger */ - private $logger; - - /** @var IJobList */ - private $jobList; - - /** @var ITimeFactory */ - private $timeFactory; - - /** - * @param IDBConnection $db - * @param CardDavBackend $davBackend - * @param ILogger $logger - * @param IJobList $jobList - * @param ITimeFactory $timeFactory - */ - public function __construct(IDBConnection $db, - CardDavBackend $davBackend, - ILogger $logger, - IJobList $jobList, - ITimeFactory $timeFactory) { - $this->db = $db; - $this->davBackend = $davBackend; - $this->logger = $logger; - $this->jobList = $jobList; - $this->timeFactory = $timeFactory; + public function __construct( + private IDBConnection $db, + private CardDavBackend $davBackend, + private LoggerInterface $logger, + private IJobList $jobList, + ITimeFactory $timeFactory, + ) { + parent::__construct($timeFactory); } public function run($arguments) { $offset = $arguments['offset']; $stopAt = $arguments['stopAt']; - $this->logger->info('Indexing social profile data (' . $offset .'/' . $stopAt . ')'); + $this->logger->info('Indexing social profile data (' . $offset . '/' . $stopAt . ')'); $offset = $this->buildIndex($offset, $stopAt); @@ -90,7 +52,7 @@ class BuildSocialSearchIndexBackgroundJob extends QueuedJob { * @return int */ private function buildIndex($offset, $stopAt) { - $startTime = $this->timeFactory->getTime(); + $startTime = $this->time->getTime(); // get contacts with social profiles $query = $this->db->getQueryBuilder(); @@ -98,8 +60,9 @@ class BuildSocialSearchIndexBackgroundJob extends QueuedJob { ->from('cards', 'c') ->orderBy('id', 'ASC') ->where($query->expr()->like('carddata', $query->createNamedParameter('%SOCIALPROFILE%'))) + ->andWhere($query->expr()->gt('id', $query->createNamedParameter((int)$offset, IQueryBuilder::PARAM_INT))) ->setMaxResults(100); - $social_cards = $query->execute()->fetchAll(); + $social_cards = $query->executeQuery()->fetchAll(); if (empty($social_cards)) { return $stopAt; @@ -108,10 +71,14 @@ class BuildSocialSearchIndexBackgroundJob extends QueuedJob { // refresh identified contacts in order to re-index foreach ($social_cards as $contact) { $offset = $contact['id']; - $this->davBackend->updateCard($contact['addressbookid'], $contact['uri'], $contact['carddata']); + $cardData = $contact['carddata']; + if (is_resource($cardData) && (get_resource_type($cardData) === 'stream')) { + $cardData = stream_get_contents($cardData); + } + $this->davBackend->updateCard($contact['addressbookid'], $contact['uri'], $cardData); // stop after 15sec (to be continued with next chunk) - if (($this->timeFactory->getTime() - $startTime) > 15) { + if (($this->time->getTime() - $startTime) > 15) { break; } } diff --git a/apps/dav/lib/Migration/CalDAVRemoveEmptyValue.php b/apps/dav/lib/Migration/CalDAVRemoveEmptyValue.php index 5e575347d95..24e182e46eb 100644 --- a/apps/dav/lib/Migration/CalDAVRemoveEmptyValue.php +++ b/apps/dav/lib/Migration/CalDAVRemoveEmptyValue.php @@ -1,58 +1,26 @@ <?php + /** - * @copyright 2017 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @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\Migration; -use Doctrine\DBAL\Platforms\OraclePlatform; use OCA\DAV\CalDAV\CalDavBackend; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; -use OCP\ILogger; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; use Sabre\VObject\InvalidDataException; class CalDAVRemoveEmptyValue implements IRepairStep { - /** @var IDBConnection */ - private $db; - - /** @var CalDavBackend */ - private $calDavBackend; - - /** @var ILogger */ - private $logger; - - /** - * @param IDBConnection $db - * @param CalDavBackend $calDavBackend - * @param ILogger $logger - */ - public function __construct(IDBConnection $db, CalDavBackend $calDavBackend, ILogger $logger) { - $this->db = $db; - $this->calDavBackend = $calDavBackend; - $this->logger = $logger; + public function __construct( + private IDBConnection $db, + private CalDavBackend $calDavBackend, + private LoggerInterface $logger, + ) { } public function getName() { @@ -100,13 +68,13 @@ class CalDAVRemoveEmptyValue implements IRepairStep { } protected function getInvalidObjects($pattern) { - if ($this->db->getDatabasePlatform() instanceof OraclePlatform) { + if ($this->db->getDatabaseProvider() === IDBConnection::PLATFORM_ORACLE) { $rows = []; $chunkSize = 500; $query = $this->db->getQueryBuilder(); $query->select($query->func()->count('*', 'num_entries')) ->from('calendarobjects'); - $result = $query->execute(); + $result = $query->executeQuery(); $count = $result->fetchOne(); $result->closeCursor(); @@ -118,7 +86,7 @@ class CalDAVRemoveEmptyValue implements IRepairStep { ->setMaxResults($chunkSize); for ($chunk = 0; $chunk < $numChunks; $chunk++) { $query->setFirstResult($chunk * $chunkSize); - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { if (mb_strpos($row['calendardata'], $pattern) !== false) { @@ -143,7 +111,7 @@ class CalDAVRemoveEmptyValue implements IRepairStep { IQueryBuilder::PARAM_STR )); - $result = $query->execute(); + $result = $query->executeQuery(); $rows = $result->fetchAll(); $result->closeCursor(); diff --git a/apps/dav/lib/Migration/ChunkCleanup.php b/apps/dav/lib/Migration/ChunkCleanup.php index 918023552da..edd9a26109e 100644 --- a/apps/dav/lib/Migration/ChunkCleanup.php +++ b/apps/dav/lib/Migration/ChunkCleanup.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; @@ -38,23 +21,12 @@ use OCP\Migration\IRepairStep; class ChunkCleanup implements IRepairStep { - /** @var IConfig */ - private $config; - /** @var IUserManager */ - private $userManager; - /** @var IRootFolder */ - private $rootFolder; - /** @var IJobList */ - private $jobList; - - public function __construct(IConfig $config, - IUserManager $userManager, - IRootFolder $rootFolder, - IJobList $jobList) { - $this->config = $config; - $this->userManager = $userManager; - $this->rootFolder = $rootFolder; - $this->jobList = $jobList; + public function __construct( + private IConfig $config, + private IUserManager $userManager, + private IRootFolder $rootFolder, + private IJobList $jobList, + ) { } public function getName(): string { @@ -65,11 +37,12 @@ class ChunkCleanup implements IRepairStep { // If we already ran this onec there is no need to run it again if ($this->config->getAppValue('dav', 'chunks_migrated', '0') === '1') { $output->info('Cleanup not required'); + return; } $output->startProgress(); // Loop over all seen users - $this->userManager->callForSeenUsers(function (IUser $user) use ($output) { + $this->userManager->callForSeenUsers(function (IUser $user) use ($output): void { try { $userFolder = $this->rootFolder->getUserFolder($user->getUID()); $userRoot = $userFolder->getParent(); diff --git a/apps/dav/lib/Migration/CreateSystemAddressBookStep.php b/apps/dav/lib/Migration/CreateSystemAddressBookStep.php new file mode 100644 index 00000000000..ec07c72e7a7 --- /dev/null +++ b/apps/dav/lib/Migration/CreateSystemAddressBookStep.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Migration; + +use OCA\DAV\CardDAV\SyncService; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class CreateSystemAddressBookStep implements IRepairStep { + + public function __construct( + private SyncService $syncService, + ) { + } + + public function getName(): string { + return 'Create system address book'; + } + + public function run(IOutput $output): void { + $this->syncService->ensureLocalSystemAddressBookExists(); + } +} diff --git a/apps/dav/lib/Migration/DeleteSchedulingObjects.php b/apps/dav/lib/Migration/DeleteSchedulingObjects.php new file mode 100644 index 00000000000..3cb3c9c9b10 --- /dev/null +++ b/apps/dav/lib/Migration/DeleteSchedulingObjects.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Migration; + +use OCA\DAV\BackgroundJob\DeleteOutdatedSchedulingObjects; +use OCA\DAV\CalDAV\CalDavBackend; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class DeleteSchedulingObjects implements IRepairStep { + public function __construct( + private IJobList $jobList, + private ITimeFactory $time, + private CalDavBackend $calDavBackend, + ) { + } + + public function getName(): string { + return 'Handle outdated scheduling events'; + } + + public function run(IOutput $output): void { + $output->info('Cleaning up old scheduling events'); + $time = $this->time->getTime() - (60 * 60); + $this->calDavBackend->deleteOutdatedSchedulingObjects($time, 50000); + if (!$this->jobList->has(DeleteOutdatedSchedulingObjects::class, null)) { + $output->info('Adding background job to delete old scheduling objects'); + $this->jobList->add(DeleteOutdatedSchedulingObjects::class, null); + } + } +} diff --git a/apps/dav/lib/Migration/FixBirthdayCalendarComponent.php b/apps/dav/lib/Migration/FixBirthdayCalendarComponent.php index 6aa499c8b1a..6833ca2ffa6 100644 --- a/apps/dav/lib/Migration/FixBirthdayCalendarComponent.php +++ b/apps/dav/lib/Migration/FixBirthdayCalendarComponent.php @@ -1,23 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud GmbH. - * - * @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 ownCloud GmbH. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Migration; @@ -28,16 +13,9 @@ use OCP\Migration\IRepairStep; class FixBirthdayCalendarComponent implements IRepairStep { - /** @var IDBConnection */ - private $connection; - - /** - * FixBirthdayCalendarComponent constructor. - * - * @param IDBConnection $connection - */ - public function __construct(IDBConnection $connection) { - $this->connection = $connection; + public function __construct( + private IDBConnection $connection, + ) { } /** @@ -55,7 +33,7 @@ class FixBirthdayCalendarComponent implements IRepairStep { $updated = $query->update('calendars') ->set('components', $query->createNamedParameter('VEVENT')) ->where($query->expr()->eq('uri', $query->createNamedParameter(BirthdayService::BIRTHDAY_CALENDAR_URI))) - ->execute(); + ->executeStatement(); $output->info("$updated birthday calendars updated."); } diff --git a/apps/dav/lib/Migration/RefreshWebcalJobRegistrar.php b/apps/dav/lib/Migration/RefreshWebcalJobRegistrar.php index ae930712859..cd4b8b31f4d 100644 --- a/apps/dav/lib/Migration/RefreshWebcalJobRegistrar.php +++ b/apps/dav/lib/Migration/RefreshWebcalJobRegistrar.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; @@ -34,21 +16,16 @@ use OCP\Migration\IRepairStep; class RefreshWebcalJobRegistrar implements IRepairStep { - /** @var IDBConnection */ - private $connection; - - /** @var IJobList */ - private $jobList; - /** * FixBirthdayCalendarComponent constructor. * * @param IDBConnection $connection * @param IJobList $jobList */ - public function __construct(IDBConnection $connection, IJobList $jobList) { - $this->connection = $connection; - $this->jobList = $jobList; + public function __construct( + private IDBConnection $connection, + private IJobList $jobList, + ) { } /** diff --git a/apps/dav/lib/Migration/RegenerateBirthdayCalendars.php b/apps/dav/lib/Migration/RegenerateBirthdayCalendars.php index 29547b09ff7..ef8e9002e9d 100644 --- a/apps/dav/lib/Migration/RegenerateBirthdayCalendars.php +++ b/apps/dav/lib/Migration/RegenerateBirthdayCalendars.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright 2019 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; @@ -31,20 +14,14 @@ use OCP\Migration\IRepairStep; class RegenerateBirthdayCalendars implements IRepairStep { - /** @var IJobList */ - private $jobList; - - /** @var IConfig */ - private $config; - /** * @param IJobList $jobList * @param IConfig $config */ - public function __construct(IJobList $jobList, - IConfig $config) { - $this->jobList = $jobList; - $this->config = $config; + public function __construct( + private IJobList $jobList, + private IConfig $config, + ) { } /** diff --git a/apps/dav/lib/Migration/RegisterBuildReminderIndexBackgroundJob.php b/apps/dav/lib/Migration/RegisterBuildReminderIndexBackgroundJob.php index f488d85bde2..7f74390f883 100644 --- a/apps/dav/lib/Migration/RegisterBuildReminderIndexBackgroundJob.php +++ b/apps/dav/lib/Migration/RegisterBuildReminderIndexBackgroundJob.php @@ -3,28 +3,8 @@ declare(strict_types=1); /** - * @copyright 2019 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; @@ -42,15 +22,6 @@ use OCP\Migration\IRepairStep; */ class RegisterBuildReminderIndexBackgroundJob implements IRepairStep { - /** @var IDBConnection */ - private $db; - - /** @var IJobList */ - private $jobList; - - /** @var IConfig */ - private $config; - /** @var string */ private const CONFIG_KEY = 'buildCalendarReminderIndex'; @@ -59,12 +30,11 @@ class RegisterBuildReminderIndexBackgroundJob implements IRepairStep { * @param IJobList $jobList * @param IConfig $config */ - public function __construct(IDBConnection $db, - IJobList $jobList, - IConfig $config) { - $this->db = $db; - $this->jobList = $jobList; - $this->config = $config; + public function __construct( + private IDBConnection $db, + private IJobList $jobList, + private IConfig $config, + ) { } /** @@ -87,8 +57,8 @@ class RegisterBuildReminderIndexBackgroundJob implements IRepairStep { $query = $this->db->getQueryBuilder(); $query->select($query->createFunction('MAX(' . $query->getColumnName('id') . ')')) ->from('calendarobjects'); - $result = $query->execute(); - $maxId = (int) $result->fetchOne(); + $result = $query->executeQuery(); + $maxId = (int)$result->fetchOne(); $result->closeCursor(); $output->info('Add background job'); diff --git a/apps/dav/lib/Migration/RegisterUpdateCalendarResourcesRoomBackgroundJob.php b/apps/dav/lib/Migration/RegisterUpdateCalendarResourcesRoomBackgroundJob.php new file mode 100644 index 00000000000..9d77aefafd2 --- /dev/null +++ b/apps/dav/lib/Migration/RegisterUpdateCalendarResourcesRoomBackgroundJob.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Migration; + +use OCA\DAV\BackgroundJob\UpdateCalendarResourcesRoomsBackgroundJob; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class RegisterUpdateCalendarResourcesRoomBackgroundJob implements IRepairStep { + public function __construct( + private readonly IJobList $jobList, + ) { + } + + public function getName() { + return 'Register a background job to update rooms and resources'; + } + + public function run(IOutput $output) { + $this->jobList->add(UpdateCalendarResourcesRoomsBackgroundJob::class); + } +} diff --git a/apps/dav/lib/Migration/RemoveClassifiedEventActivity.php b/apps/dav/lib/Migration/RemoveClassifiedEventActivity.php index 36108ddadfa..f0d208f4f33 100644 --- a/apps/dav/lib/Migration/RemoveClassifiedEventActivity.php +++ b/apps/dav/lib/Migration/RemoveClassifiedEventActivity.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019 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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; @@ -33,11 +15,9 @@ use OCP\Migration\IRepairStep; class RemoveClassifiedEventActivity implements IRepairStep { - /** @var IDBConnection */ - private $connection; - - public function __construct(IDBConnection $connection) { - $this->connection = $connection; + public function __construct( + private IDBConnection $connection, + ) { } /** @@ -76,7 +56,7 @@ class RemoveClassifiedEventActivity implements IRepairStep { ->from('calendarobjects', 'o') ->leftJoin('o', 'calendars', 'c', $query->expr()->eq('c.id', 'o.calendarid')) ->where($query->expr()->eq('o.classification', $query->createNamedParameter(CalDavBackend::CLASSIFICATION_PRIVATE))); - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { if ($row['principaluri'] === null) { @@ -87,7 +67,7 @@ class RemoveClassifiedEventActivity implements IRepairStep { ->setParameter('type', 'calendar') ->setParameter('calendar_id', $row['calendarid']) ->setParameter('event_uid', '%' . $this->connection->escapeLikeParameter('{"id":"' . $row['uid'] . '"') . '%'); - $deletedEvents += $delete->execute(); + $deletedEvents += $delete->executeStatement(); } $result->closeCursor(); @@ -110,7 +90,7 @@ class RemoveClassifiedEventActivity implements IRepairStep { ->from('calendarobjects', 'o') ->leftJoin('o', 'calendars', 'c', $query->expr()->eq('c.id', 'o.calendarid')) ->where($query->expr()->eq('o.classification', $query->createNamedParameter(CalDavBackend::CLASSIFICATION_CONFIDENTIAL))); - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { if ($row['principaluri'] === null) { @@ -122,7 +102,7 @@ class RemoveClassifiedEventActivity implements IRepairStep { ->setParameter('calendar_id', $row['calendarid']) ->setParameter('event_uid', '%' . $this->connection->escapeLikeParameter('{"id":"' . $row['uid'] . '"') . '%') ->setParameter('filtered_name', '%' . $this->connection->escapeLikeParameter('{"id":"' . $row['uid'] . '","name":"Busy"') . '%'); - $deletedEvents += $delete->execute(); + $deletedEvents += $delete->executeStatement(); } $result->closeCursor(); diff --git a/apps/dav/lib/Migration/RemoveDeletedUsersCalendarSubscriptions.php b/apps/dav/lib/Migration/RemoveDeletedUsersCalendarSubscriptions.php index 38d395b2c81..e2b2b701e74 100644 --- a/apps/dav/lib/Migration/RemoveDeletedUsersCalendarSubscriptions.php +++ b/apps/dav/lib/Migration/RemoveDeletedUsersCalendarSubscriptions.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Thomas Citharel <nextcloud@tcit.fr> - * - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; @@ -32,12 +15,6 @@ use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; class RemoveDeletedUsersCalendarSubscriptions implements IRepairStep { - /** @var IDBConnection */ - private $connection; - - /** @var IUserManager */ - private $userManager; - /** @var int */ private $progress = 0; @@ -46,9 +23,10 @@ class RemoveDeletedUsersCalendarSubscriptions implements IRepairStep { private const SUBSCRIPTIONS_CHUNK_SIZE = 1000; - public function __construct(IDBConnection $connection, IUserManager $userManager) { - $this->connection = $connection; - $this->userManager = $userManager; + public function __construct( + private IDBConnection $connection, + private IUserManager $userManager, + ) { } /** @@ -113,7 +91,7 @@ class RemoveDeletedUsersCalendarSubscriptions implements IRepairStep { while ($row = $result->fetch()) { $username = $this->getPrincipal($row['principaluri']); if (!$this->userManager->userExists($username)) { - $this->orphanSubscriptionIds[] = (int) $row['id']; + $this->orphanSubscriptionIds[] = (int)$row['id']; } } $result->closeCursor(); diff --git a/apps/dav/lib/Migration/RemoveObjectProperties.php b/apps/dav/lib/Migration/RemoveObjectProperties.php new file mode 100644 index 00000000000..f09293ae0bb --- /dev/null +++ b/apps/dav/lib/Migration/RemoveObjectProperties.php @@ -0,0 +1,48 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Migration; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class RemoveObjectProperties implements IRepairStep { + private const RESOURCE_TYPE_PROPERTY = '{DAV:}resourcetype'; + private const ME_CARD_PROPERTY = '{http://calendarserver.org/ns/}me-card'; + private const CALENDAR_TRANSP_PROPERTY = '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp'; + + /** + * RemoveObjectProperties constructor. + * + * @param IDBConnection $connection + */ + public function __construct( + private IDBConnection $connection, + ) { + } + + /** + * @inheritdoc + */ + public function getName() { + return 'Remove invalid object properties'; + } + + /** + * @inheritdoc + */ + public function run(IOutput $output) { + $query = $this->connection->getQueryBuilder(); + $updated = $query->delete('properties') + ->where($query->expr()->in('propertyname', $query->createNamedParameter([self::RESOURCE_TYPE_PROPERTY, self::ME_CARD_PROPERTY, self::CALENDAR_TRANSP_PROPERTY], IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->eq('propertyvalue', $query->createNamedParameter('Object'), IQueryBuilder::PARAM_STR)) + ->executeStatement(); + + $output->info("$updated invalid object properties removed."); + } +} diff --git a/apps/dav/lib/Migration/RemoveOrphanEventsAndContacts.php b/apps/dav/lib/Migration/RemoveOrphanEventsAndContacts.php index 4789a74d98a..143dc3cd1e6 100644 --- a/apps/dav/lib/Migration/RemoveOrphanEventsAndContacts.php +++ b/apps/dav/lib/Migration/RemoveOrphanEventsAndContacts.php @@ -3,108 +3,50 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; -use OCA\DAV\CalDAV\CalDavBackend; -use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IDBConnection; +use OCA\DAV\BackgroundJob\CleanupOrphanedChildrenJob; +use OCP\BackgroundJob\IJobList; use OCP\Migration\IOutput; use OCP\Migration\IRepairStep; class RemoveOrphanEventsAndContacts implements IRepairStep { - - /** @var IDBConnection */ - private $connection; - - public function __construct(IDBConnection $connection) { - $this->connection = $connection; + public function __construct( + private readonly IJobList $jobList, + ) { } - /** - * @inheritdoc - */ public function getName(): string { - return 'Clean up orphan event and contact data'; + return 'Queue jobs to clean up orphan event and contact data'; } - /** - * @inheritdoc - */ - public function run(IOutput $output) { - $orphanItems = $this->removeOrphanChildren('calendarobjects', 'calendars', 'calendarid'); - $output->info(sprintf('%d events without a calendar have been cleaned up', $orphanItems)); - $orphanItems = $this->removeOrphanChildren('calendarobjects_props', 'calendarobjects', 'objectid'); - $output->info(sprintf('%d properties without an events have been cleaned up', $orphanItems)); - $orphanItems = $this->removeOrphanChildren('calendarchanges', 'calendars', 'calendarid'); - $output->info(sprintf('%d changes without a calendar have been cleaned up', $orphanItems)); + public function run(IOutput $output): void { + $this->queueJob('calendarobjects', 'calendars', 'calendarid', '%d events without a calendar have been cleaned up'); + $this->queueJob('calendarobjects_props', 'calendarobjects', 'objectid', '%d properties without an events have been cleaned up'); + $this->queueJob('calendarchanges', 'calendars', 'calendarid', '%d changes without a calendar have been cleaned up'); - $orphanItems = $this->removeOrphanChildren('calendarobjects', 'calendarsubscriptions', 'calendarid'); - $output->info(sprintf('%d cached events without a calendar subscription have been cleaned up', $orphanItems)); - $orphanItems = $this->removeOrphanChildren('calendarchanges', 'calendarsubscriptions', 'calendarid'); - $output->info(sprintf('%d changes without a calendar subscription have been cleaned up', $orphanItems)); + $this->queueJob('calendarobjects', 'calendarsubscriptions', 'calendarid', '%d cached events without a calendar subscription have been cleaned up'); + $this->queueJob('calendarchanges', 'calendarsubscriptions', 'calendarid', '%d changes without a calendar subscription have been cleaned up'); - $orphanItems = $this->removeOrphanChildren('cards', 'addressbooks', 'addressbookid'); - $output->info(sprintf('%d contacts without an addressbook have been cleaned up', $orphanItems)); - $orphanItems = $this->removeOrphanChildren('cards_properties', 'cards', 'cardid'); - $output->info(sprintf('%d properties without a contact have been cleaned up', $orphanItems)); - $orphanItems = $this->removeOrphanChildren('addressbookchanges', 'addressbooks', 'addressbookid'); - $output->info(sprintf('%d changes without an addressbook have been cleaned up', $orphanItems)); + $this->queueJob('cards', 'addressbooks', 'addressbookid', '%d contacts without an addressbook have been cleaned up'); + $this->queueJob('cards_properties', 'cards', 'cardid', '%d properties without a contact have been cleaned up'); + $this->queueJob('addressbookchanges', 'addressbooks', 'addressbookid', '%d changes without an addressbook have been cleaned up'); } - protected function removeOrphanChildren($childTable, $parentTable, $parentId): int { - $qb = $this->connection->getQueryBuilder(); - - $qb->select('c.id') - ->from($childTable, 'c') - ->leftJoin('c', $parentTable, 'p', $qb->expr()->eq('c.' . $parentId, 'p.id')) - ->where($qb->expr()->isNull('p.id')); - - if (\in_array($parentTable, ['calendars', 'calendarsubscriptions'], true)) { - $calendarType = $parentTable === 'calendarsubscriptions' ? CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION : CalDavBackend::CALENDAR_TYPE_CALENDAR; - $qb->andWhere($qb->expr()->eq('c.calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); - } - - $result = $qb->execute(); - - $orphanItems = []; - while ($row = $result->fetch()) { - $orphanItems[] = (int) $row['id']; - } - $result->closeCursor(); - - if (!empty($orphanItems)) { - $qb->delete($childTable) - ->where($qb->expr()->in('id', $qb->createParameter('ids'))); - - $orphanItemsBatch = array_chunk($orphanItems, 200); - foreach ($orphanItemsBatch as $items) { - $qb->setParameter('ids', $items, IQueryBuilder::PARAM_INT_ARRAY); - $qb->execute(); - } - } - - return count($orphanItems); + private function queueJob( + string $childTable, + string $parentTable, + string $parentId, + string $logMessage, + ): void { + $this->jobList->add(CleanupOrphanedChildrenJob::class, [ + CleanupOrphanedChildrenJob::ARGUMENT_CHILD_TABLE => $childTable, + CleanupOrphanedChildrenJob::ARGUMENT_PARENT_TABLE => $parentTable, + CleanupOrphanedChildrenJob::ARGUMENT_PARENT_ID => $parentId, + CleanupOrphanedChildrenJob::ARGUMENT_LOG_MESSAGE => $logMessage, + ]); } } diff --git a/apps/dav/lib/Migration/Version1004Date20170825134824.php b/apps/dav/lib/Migration/Version1004Date20170825134824.php index a7cbaa78ef2..4bf9637b697 100644 --- a/apps/dav/lib/Migration/Version1004Date20170825134824.php +++ b/apps/dav/lib/Migration/Version1004Date20170825134824.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright 2017, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.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\Migration; @@ -383,6 +364,7 @@ class Version1004Date20170825134824 extends SimpleMigrationStep { ]); $table->setPrimaryKey(['id']); $table->addIndex(['principaluri'], 'schedulobj_principuri_index'); + $table->addIndex(['lastmodified'], 'schedulobj_lastmodified_idx'); } if (!$schema->hasTable('cards_properties')) { @@ -490,6 +472,9 @@ class Version1004Date20170825134824 extends SimpleMigrationStep { ]); $table->setPrimaryKey(['id']); $table->addUniqueIndex(['principaluri', 'resourceid', 'type', 'publicuri'], 'dav_shares_index'); + // modified on 2024-6-21 to add performance improving indices on new instances + $table->addIndex(['resourceid', 'type'], 'dav_shares_resourceid_type'); + $table->addIndex(['resourceid', 'access'], 'dav_shares_resourceid_access'); } return $schema; } diff --git a/apps/dav/lib/Migration/Version1004Date20170919104507.php b/apps/dav/lib/Migration/Version1004Date20170919104507.php index 54548f30aca..678d92d2b83 100644 --- a/apps/dav/lib/Migration/Version1004Date20170919104507.php +++ b/apps/dav/lib/Migration/Version1004Date20170919104507.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; diff --git a/apps/dav/lib/Migration/Version1004Date20170924124212.php b/apps/dav/lib/Migration/Version1004Date20170924124212.php index 623a92a12dd..4d221e91132 100644 --- a/apps/dav/lib/Migration/Version1004Date20170924124212.php +++ b/apps/dav/lib/Migration/Version1004Date20170924124212.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright 2017, Roeland Jago Douma <roeland@famdouma.nl> - * - * @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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; @@ -41,11 +24,15 @@ class Version1004Date20170924124212 extends SimpleMigrationStep { $schema = $schemaClosure(); $table = $schema->getTable('cards'); - $table->addIndex(['addressbookid'], 'cards_abid'); + // Dropped in Version1030Date20240205103243 because cards_abid is redundant with cards_abiduri + // $table->addIndex(['addressbookid'], 'cards_abid'); $table->addIndex(['addressbookid', 'uri'], 'cards_abiduri'); $table = $schema->getTable('cards_properties'); - $table->addIndex(['addressbookid'], 'cards_prop_abid'); + // Removed later on + // $table->addIndex(['addressbookid'], 'cards_prop_abid'); + // Added later on + $table->addIndex(['addressbookid', 'name', 'value'], 'cards_prop_abid_name_value', ); return $schema; } diff --git a/apps/dav/lib/Migration/Version1004Date20170926103422.php b/apps/dav/lib/Migration/Version1004Date20170926103422.php index 6d88effc240..ec56e035006 100644 --- a/apps/dav/lib/Migration/Version1004Date20170926103422.php +++ b/apps/dav/lib/Migration/Version1004Date20170926103422.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; @@ -32,7 +15,7 @@ class Version1004Date20170926103422 extends BigIntMigration { /** * @return array Returns an array with the following structure - * ['table1' => ['column1', 'column2'], ...] + * ['table1' => ['column1', 'column2'], ...] * @since 13.0.0 */ protected function getColumnsByTable() { diff --git a/apps/dav/lib/Migration/Version1005Date20180413093149.php b/apps/dav/lib/Migration/Version1005Date20180413093149.php index 1ee767ce547..db071c65d98 100644 --- a/apps/dav/lib/Migration/Version1005Date20180413093149.php +++ b/apps/dav/lib/Migration/Version1005Date20180413093149.php @@ -3,32 +3,13 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; @@ -48,7 +29,7 @@ class Version1005Date20180413093149 extends SimpleMigrationStep { if (!$schema->hasTable('directlink')) { $table = $schema->createTable('directlink'); - $table->addColumn('id',Types::BIGINT, [ + $table->addColumn('id', Types::BIGINT, [ 'autoincrement' => true, 'notnull' => true, 'length' => 11, diff --git a/apps/dav/lib/Migration/Version1005Date20180530124431.php b/apps/dav/lib/Migration/Version1005Date20180530124431.php index ae057f74599..b5f9ff26962 100644 --- a/apps/dav/lib/Migration/Version1005Date20180530124431.php +++ b/apps/dav/lib/Migration/Version1005Date20180530124431.php @@ -1,33 +1,13 @@ <?php + /** - * @copyright 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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\Migration; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; diff --git a/apps/dav/lib/Migration/Version1006Date20180619154313.php b/apps/dav/lib/Migration/Version1006Date20180619154313.php index c607a14c2a9..231861a68c4 100644 --- a/apps/dav/lib/Migration/Version1006Date20180619154313.php +++ b/apps/dav/lib/Migration/Version1006Date20180619154313.php @@ -1,33 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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\Migration; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; diff --git a/apps/dav/lib/Migration/Version1006Date20180628111625.php b/apps/dav/lib/Migration/Version1006Date20180628111625.php index 7ce2edccc28..f4be26e6ad0 100644 --- a/apps/dav/lib/Migration/Version1006Date20180628111625.php +++ b/apps/dav/lib/Migration/Version1006Date20180628111625.php @@ -3,34 +3,13 @@ declare(strict_types=1); /** - * @copyright 2018 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; @@ -70,6 +49,7 @@ class Version1006Date20180628111625 extends SimpleMigrationStep { $calendarObjectsTable->dropIndex('calobjects_index'); } $calendarObjectsTable->addUniqueIndex(['calendarid', 'calendartype', 'uri'], 'calobjects_index'); + $calendarObjectsTable->addUniqueIndex(['calendarid', 'calendartype', 'uid'], 'calobjects_by_uid_index'); } if ($schema->hasTable('calendarobjects_props')) { diff --git a/apps/dav/lib/Migration/Version1008Date20181030113700.php b/apps/dav/lib/Migration/Version1008Date20181030113700.php index 694f096ff3e..d2354a185df 100644 --- a/apps/dav/lib/Migration/Version1008Date20181030113700.php +++ b/apps/dav/lib/Migration/Version1008Date20181030113700.php @@ -3,35 +3,15 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018, John Molakvoæ (skjnldsv@protonmail.com) - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; use Closure; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; diff --git a/apps/dav/lib/Migration/Version1008Date20181105104826.php b/apps/dav/lib/Migration/Version1008Date20181105104826.php index 86ce4c33ce5..82612307cbb 100644 --- a/apps/dav/lib/Migration/Version1008Date20181105104826.php +++ b/apps/dav/lib/Migration/Version1008Date20181105104826.php @@ -3,50 +3,28 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; use Closure; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; class Version1008Date20181105104826 extends SimpleMigrationStep { - /** @var IDBConnection */ - private $connection; - /** * Version1008Date20181105104826 constructor. * * @param IDBConnection $connection */ - public function __construct(IDBConnection $connection) { - $this->connection = $connection; + public function __construct( + private IDBConnection $connection, + ) { } /** diff --git a/apps/dav/lib/Migration/Version1008Date20181105104833.php b/apps/dav/lib/Migration/Version1008Date20181105104833.php index 4b4e9cc535e..3d4094d8072 100644 --- a/apps/dav/lib/Migration/Version1008Date20181105104833.php +++ b/apps/dav/lib/Migration/Version1008Date20181105104833.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; diff --git a/apps/dav/lib/Migration/Version1008Date20181105110300.php b/apps/dav/lib/Migration/Version1008Date20181105110300.php index e275b2a8e1e..72e8dee1bf8 100644 --- a/apps/dav/lib/Migration/Version1008Date20181105110300.php +++ b/apps/dav/lib/Migration/Version1008Date20181105110300.php @@ -3,50 +3,28 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; use Closure; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\IDBConnection; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; class Version1008Date20181105110300 extends SimpleMigrationStep { - /** @var IDBConnection */ - private $connection; - /** * Version1008Date20181105110300 constructor. * * @param IDBConnection $connection */ - public function __construct(IDBConnection $connection) { - $this->connection = $connection; + public function __construct( + private IDBConnection $connection, + ) { } /** diff --git a/apps/dav/lib/Migration/Version1008Date20181105112049.php b/apps/dav/lib/Migration/Version1008Date20181105112049.php index 8dd7d695e68..eb18eacb0b1 100644 --- a/apps/dav/lib/Migration/Version1008Date20181105112049.php +++ b/apps/dav/lib/Migration/Version1008Date20181105112049.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; diff --git a/apps/dav/lib/Migration/Version1008Date20181114084440.php b/apps/dav/lib/Migration/Version1008Date20181114084440.php index 91f4211e3fa..3718f16f54a 100644 --- a/apps/dav/lib/Migration/Version1008Date20181114084440.php +++ b/apps/dav/lib/Migration/Version1008Date20181114084440.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018, 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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; diff --git a/apps/dav/lib/Migration/Version1011Date20190725113607.php b/apps/dav/lib/Migration/Version1011Date20190725113607.php index 1191f0e8878..4524dca9c83 100644 --- a/apps/dav/lib/Migration/Version1011Date20190725113607.php +++ b/apps/dav/lib/Migration/Version1011Date20190725113607.php @@ -3,33 +3,13 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; diff --git a/apps/dav/lib/Migration/Version1011Date20190806104428.php b/apps/dav/lib/Migration/Version1011Date20190806104428.php index a6f8772ec08..183dcd4bf1e 100644 --- a/apps/dav/lib/Migration/Version1011Date20190806104428.php +++ b/apps/dav/lib/Migration/Version1011Date20190806104428.php @@ -3,34 +3,14 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; use Closure; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; diff --git a/apps/dav/lib/Migration/Version1012Date20190808122342.php b/apps/dav/lib/Migration/Version1012Date20190808122342.php index 7bb39db6bf1..717bcbc1119 100644 --- a/apps/dav/lib/Migration/Version1012Date20190808122342.php +++ b/apps/dav/lib/Migration/Version1012Date20190808122342.php @@ -3,34 +3,13 @@ declare(strict_types=1); /** - * @copyright 2019, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; -use OCP\DB\Types; use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; @@ -47,8 +26,8 @@ class Version1012Date20190808122342 extends SimpleMigrationStep { * @since 17.0.0 */ public function changeSchema(IOutput $output, - \Closure $schemaClosure, - array $options):?ISchemaWrapper { + \Closure $schemaClosure, + array $options):?ISchemaWrapper { /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); diff --git a/apps/dav/lib/Migration/Version1016Date20201109085907.php b/apps/dav/lib/Migration/Version1016Date20201109085907.php index 6828c71301a..f0f4b8b77c1 100644 --- a/apps/dav/lib/Migration/Version1016Date20201109085907.php +++ b/apps/dav/lib/Migration/Version1016Date20201109085907.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; diff --git a/apps/dav/lib/Migration/Version1017Date20210216083742.php b/apps/dav/lib/Migration/Version1017Date20210216083742.php index c5347d773d1..43bece200b8 100644 --- a/apps/dav/lib/Migration/Version1017Date20210216083742.php +++ b/apps/dav/lib/Migration/Version1017Date20210216083742.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; diff --git a/apps/dav/lib/Migration/Version1018Date20210312100735.php b/apps/dav/lib/Migration/Version1018Date20210312100735.php index b0ea5146caa..8199d3e9cd2 100644 --- a/apps/dav/lib/Migration/Version1018Date20210312100735.php +++ b/apps/dav/lib/Migration/Version1018Date20210312100735.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Migration; diff --git a/apps/dav/lib/Migration/Version1024Date20211221144219.php b/apps/dav/lib/Migration/Version1024Date20211221144219.php new file mode 100644 index 00000000000..656a50809cd --- /dev/null +++ b/apps/dav/lib/Migration/Version1024Date20211221144219.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Migration; + +use Closure; +use Doctrine\DBAL\Schema\SchemaException; +use OCA\DAV\DAV\CustomPropertiesBackend; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Auto-generated migration step: Please modify to your needs! + */ +class Version1024Date20211221144219 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + */ + public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + * @throws SchemaException + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + $propertiesTable = $schema->getTable('properties'); + + if ($propertiesTable->hasColumn('valuetype')) { + return null; + } + $propertiesTable->addColumn('valuetype', Types::SMALLINT, [ + 'notnull' => false, + 'default' => CustomPropertiesBackend::PROPERTY_TYPE_STRING + ]); + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } +} diff --git a/apps/dav/lib/Migration/Version1025Date20240308063933.php b/apps/dav/lib/Migration/Version1025Date20240308063933.php new file mode 100644 index 00000000000..d84acf8fea9 --- /dev/null +++ b/apps/dav/lib/Migration/Version1025Date20240308063933.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Migration; + +use Closure; +use OCP\AppFramework\Services\IAppConfig; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\DB\Types; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1025Date20240308063933 extends SimpleMigrationStep { + + public function __construct( + private IAppConfig $appConfig, + private IDBConnection $db, + ) { + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + foreach (['addressbookchanges', 'calendarchanges'] as $tableName) { + $table = $schema->getTable($tableName); + if (!$table->hasColumn('created_at')) { + $table->addColumn('created_at', Types::INTEGER, [ + 'notnull' => true, + 'length' => 4, + 'default' => 0, + ]); + } + } + + return $schema; + } + + public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void { + // The threshold is higher than the default of \OCA\DAV\BackgroundJob\PruneOutdatedSyncTokensJob + // but small enough to fit into a cluster transaction size. + // For a 50k users instance that would still keep 10 changes on average. + $limit = max(1, (int)$this->appConfig->getAppValue('totalNumberOfSyncTokensToKeep', '500000')); + + foreach (['addressbookchanges', 'calendarchanges'] as $tableName) { + $thresholdSelect = $this->db->getQueryBuilder(); + $thresholdSelect->select('id') + ->from($tableName) + ->orderBy('id', 'desc') + ->setFirstResult($limit) + ->setMaxResults(1); + $oldestIdResult = $thresholdSelect->executeQuery(); + $oldestId = $oldestIdResult->fetchColumn(); + $oldestIdResult->closeCursor(); + + $qb = $this->db->getQueryBuilder(); + + $update = $qb->update($tableName) + ->set('created_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)) + ->where( + $qb->expr()->eq('created_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)), + ); + + // If there is a lot of data we only set timestamp for the most recent rows + // because the rest will be deleted by \OCA\DAV\BackgroundJob\PruneOutdatedSyncTokensJob + // anyway. + if ($oldestId !== false) { + $update->andWhere($qb->expr()->gt('id', $qb->createNamedParameter($oldestId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + } + + $updated = $update->executeStatement(); + + $output->debug('Added a default creation timestamp to ' . $updated . ' rows in ' . $tableName); + } + } + +} diff --git a/apps/dav/lib/Migration/Version1027Date20230504122946.php b/apps/dav/lib/Migration/Version1027Date20230504122946.php new file mode 100644 index 00000000000..89c192a8419 --- /dev/null +++ b/apps/dav/lib/Migration/Version1027Date20230504122946.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Migration; + +use Closure; +use OCA\DAV\CardDAV\SyncService; +use OCP\DB\ISchemaWrapper; +use OCP\IConfig; +use OCP\IUserManager; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; +use Psr\Log\LoggerInterface; +use Throwable; + +class Version1027Date20230504122946 extends SimpleMigrationStep { + public function __construct( + private SyncService $syncService, + private LoggerInterface $logger, + private IUserManager $userManager, + private IConfig $config, + ) { + } + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + if ($this->userManager->countSeenUsers() > 100 || $this->userManager->countUsersTotal(100) >= 100) { + $this->config->setAppValue('dav', 'needs_system_address_book_sync', 'yes'); + $output->info('Could not sync system address books during update - too many user records have been found. Please call occ dav:sync-system-addressbook manually.'); + return; + } + + try { + $this->syncService->syncInstance(); + $this->config->setAppValue('dav', 'needs_system_address_book_sync', 'no'); + } catch (Throwable $e) { + $this->config->setAppValue('dav', 'needs_system_address_book_sync', 'yes'); + $this->logger->error('Could not sync system address books during update', [ + 'exception' => $e, + ]); + $output->warning('System address book sync failed. See logs for details'); + } + } +} diff --git a/apps/dav/lib/Migration/Version1029Date20221114151721.php b/apps/dav/lib/Migration/Version1029Date20221114151721.php new file mode 100644 index 00000000000..dba5e0b1a48 --- /dev/null +++ b/apps/dav/lib/Migration/Version1029Date20221114151721.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Migration; + +use Closure; +use Doctrine\DBAL\Schema\SchemaException; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1029Date20221114151721 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + * @throws SchemaException + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + $calendarObjectsTable = $schema->getTable('calendarobjects'); + if (!$calendarObjectsTable->hasIndex('calobj_clssfction_index')) { + $calendarObjectsTable->addIndex(['classification'], 'calobj_clssfction_index'); + return $schema; + } + return null; + } + +} diff --git a/apps/dav/lib/Migration/Version1029Date20231004091403.php b/apps/dav/lib/Migration/Version1029Date20231004091403.php new file mode 100644 index 00000000000..1dcbf9c5dfc --- /dev/null +++ b/apps/dav/lib/Migration/Version1029Date20231004091403.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1029Date20231004091403 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('dav_absence')) { + $table = $schema->createTable('dav_absence'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('first_day', Types::STRING, [ + 'length' => 10, + 'notnull' => true, + ]); + $table->addColumn('last_day', Types::STRING, [ + 'length' => 10, + 'notnull' => true, + ]); + $table->addColumn('status', Types::STRING, [ + 'length' => 100, + 'notnull' => true, + ]); + $table->addColumn('message', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addUniqueIndex(['user_id'], 'dav_absence_uid_idx'); + } else { + $table = $schema->getTable('dav_absence'); + } + + if ($table->getPrimaryKey() === null) { + $table->setPrimaryKey(['id'], 'dav_absence_id_idx'); + } + + return $schema; + } +} diff --git a/apps/dav/lib/Migration/Version1030Date20240205103243.php b/apps/dav/lib/Migration/Version1030Date20240205103243.php new file mode 100644 index 00000000000..72cbcafc444 --- /dev/null +++ b/apps/dav/lib/Migration/Version1030Date20240205103243.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1030Date20240205103243 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $tableCards = $schema->getTable('cards'); + + if ($tableCards->hasIndex('cards_abiduri') && $tableCards->hasIndex('cards_abid')) { + $tableCards->dropIndex('cards_abid'); + } + + return $schema; + } +} diff --git a/apps/dav/lib/Migration/Version1031Date20240610134258.php b/apps/dav/lib/Migration/Version1031Date20240610134258.php new file mode 100644 index 00000000000..c1242ceb7db --- /dev/null +++ b/apps/dav/lib/Migration/Version1031Date20240610134258.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\Attributes\AddColumn; +use OCP\Migration\Attributes\ColumnType; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +#[AddColumn(table: 'dav_absence', name: 'replacement_user_id', type: ColumnType::STRING)] +#[AddColumn(table: 'dav_absence', name: 'replacement_user_display_name', type: ColumnType::STRING)] +class Version1031Date20240610134258 extends SimpleMigrationStep { + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $tableDavAbsence = $schema->getTable('dav_absence'); + + if (!$tableDavAbsence->hasColumn('replacement_user_id')) { + $tableDavAbsence->addColumn('replacement_user_id', Types::STRING, [ + 'notnull' => false, + 'default' => '', + 'length' => 64, + ]); + } + + if (!$tableDavAbsence->hasColumn('replacement_user_display_name')) { + $tableDavAbsence->addColumn('replacement_user_display_name', Types::STRING, [ + 'notnull' => false, + 'default' => '', + 'length' => 64, + ]); + } + + return $schema; + } + +} diff --git a/apps/dav/lib/Model/ExampleEvent.php b/apps/dav/lib/Model/ExampleEvent.php new file mode 100644 index 00000000000..d2a5b8ad2d1 --- /dev/null +++ b/apps/dav/lib/Model/ExampleEvent.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Model; + +use Sabre\VObject\Component\VCalendar; + +/** + * Simple DTO to store a parsed example event and its UID. + */ +final class ExampleEvent { + public function __construct( + private readonly VCalendar $vCalendar, + private readonly string $uid, + ) { + } + + public function getUid(): string { + return $this->uid; + } + + public function getIcs(): string { + return $this->vCalendar->serialize(); + } +} diff --git a/apps/dav/lib/Paginate/LimitedCopyIterator.php b/apps/dav/lib/Paginate/LimitedCopyIterator.php new file mode 100644 index 00000000000..7f19885bc7d --- /dev/null +++ b/apps/dav/lib/Paginate/LimitedCopyIterator.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Paginate; + +/** + * Save a copy of the first X items into a separate iterator + * + * This allows us to pass the iterator to the cache while keeping a copy + * of the required items. + * + * @extends \AppendIterator<int, int, \Iterator<int, int>> + */ +class LimitedCopyIterator extends \AppendIterator { + private array $skipped = []; + private array $copy = []; + + public function __construct(\Traversable $iterator, int $count, int $offset = 0) { + parent::__construct(); + + if (!$iterator instanceof \Iterator) { + $iterator = new \IteratorIterator($iterator); + } + $iterator = new \NoRewindIterator($iterator); + + $i = 0; + while ($iterator->valid() && ++$i <= $offset) { + $this->skipped[] = $iterator->current(); + $iterator->next(); + } + + while ($iterator->valid() && count($this->copy) < $count) { + $this->copy[] = $iterator->current(); + $iterator->next(); + } + + $this->append(new \ArrayIterator($this->skipped)); + $this->append($this->getRequestedItems()); + $this->append($iterator); + } + + public function getRequestedItems(): \Iterator { + return new \ArrayIterator($this->copy); + } +} diff --git a/apps/dav/lib/Paginate/PaginateCache.php b/apps/dav/lib/Paginate/PaginateCache.php new file mode 100644 index 00000000000..58219b03621 --- /dev/null +++ b/apps/dav/lib/Paginate/PaginateCache.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Paginate; + +use Generator; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IDBConnection; +use OCP\Security\ISecureRandom; + +class PaginateCache { + public const TTL = 60 * 60; + private const CACHE_COUNT_SUFFIX = 'count'; + + private ICache $cache; + + public function __construct( + private IDBConnection $database, + private ISecureRandom $random, + ICacheFactory $cacheFactory, + ) { + $this->cache = $cacheFactory->createDistributed('pagination_'); + } + + /** + * @param string $uri + * @param \Iterator $items + * @return array{'token': string, 'count': int} + */ + public function store(string $uri, \Iterator $items): array { + $token = $this->random->generate(32); + $cacheKey = $this->buildCacheKey($uri, $token); + + $count = 0; + foreach ($items as $item) { + // Add small margin to avoid fetching valid count and then expired entries + $this->cache->set($cacheKey . $count, $item, self::TTL + 60); + ++$count; + } + $this->cache->set($cacheKey . self::CACHE_COUNT_SUFFIX, $count, self::TTL); + + return ['token' => $token, 'count' => $count]; + } + + /** + * @return Generator<mixed> + */ + public function get(string $uri, string $token, int $offset, int $count): Generator { + $cacheKey = $this->buildCacheKey($uri, $token); + $nbItems = $this->cache->get($cacheKey . self::CACHE_COUNT_SUFFIX); + if (!$nbItems || $offset > $nbItems) { + return []; + } + + $lastItem = min($nbItems, $offset + $count); + for ($i = $offset; $i < $lastItem; ++$i) { + yield $this->cache->get($cacheKey . $i); + } + } + + public function exists(string $uri, string $token): bool { + return $this->cache->get($this->buildCacheKey($uri, $token) . self::CACHE_COUNT_SUFFIX) > 0; + } + + private function buildCacheKey(string $uri, string $token): string { + return $token . '_' . crc32($uri) . '_'; + } +} diff --git a/apps/dav/lib/Paginate/PaginatePlugin.php b/apps/dav/lib/Paginate/PaginatePlugin.php new file mode 100644 index 00000000000..c5da18f5c47 --- /dev/null +++ b/apps/dav/lib/Paginate/PaginatePlugin.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\DAV\Paginate; + +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class PaginatePlugin extends ServerPlugin { + public const PAGINATE_HEADER = 'X-NC-Paginate'; + public const PAGINATE_TOTAL_HEADER = 'X-NC-Paginate-Total'; + public const PAGINATE_TOKEN_HEADER = 'X-NC-Paginate-Token'; + public const PAGINATE_OFFSET_HEADER = 'X-NC-Paginate-Offset'; + public const PAGINATE_COUNT_HEADER = 'X-NC-Paginate-Count'; + + /** @var Server */ + private $server; + + public function __construct( + private PaginateCache $cache, + private int $pageSize = 100, + ) { + } + + public function initialize(Server $server): void { + $this->server = $server; + $server->on('beforeMultiStatus', [$this, 'onMultiStatus']); + $server->on('method:SEARCH', [$this, 'onMethod'], 1); + $server->on('method:PROPFIND', [$this, 'onMethod'], 1); + $server->on('method:REPORT', [$this, 'onMethod'], 1); + } + + public function getFeatures(): array { + return ['nc-paginate']; + } + + public function onMultiStatus(&$fileProperties): void { + $request = $this->server->httpRequest; + if (is_array($fileProperties)) { + $fileProperties = new \ArrayIterator($fileProperties); + } + $url = $request->getUrl(); + if ( + $request->hasHeader(self::PAGINATE_HEADER) + && (!$request->hasHeader(self::PAGINATE_TOKEN_HEADER) || !$this->cache->exists($url, $request->getHeader(self::PAGINATE_TOKEN_HEADER))) + ) { + $pageSize = (int)$request->getHeader(self::PAGINATE_COUNT_HEADER) ?: $this->pageSize; + $offset = (int)$request->getHeader(self::PAGINATE_OFFSET_HEADER); + $copyIterator = new LimitedCopyIterator($fileProperties, $pageSize, $offset); + ['token' => $token, 'count' => $count] = $this->cache->store($url, $copyIterator); + + $fileProperties = $copyIterator->getRequestedItems(); + $this->server->httpResponse->addHeader(self::PAGINATE_HEADER, 'true'); + $this->server->httpResponse->addHeader(self::PAGINATE_TOKEN_HEADER, $token); + $this->server->httpResponse->addHeader(self::PAGINATE_TOTAL_HEADER, (string)$count); + $request->setHeader(self::PAGINATE_TOKEN_HEADER, $token); + } + } + + public function onMethod(RequestInterface $request, ResponseInterface $response) { + $url = $this->server->httpRequest->getUrl(); + if ( + $request->hasHeader(self::PAGINATE_TOKEN_HEADER) + && $request->hasHeader(self::PAGINATE_OFFSET_HEADER) + && $this->cache->exists($url, $request->getHeader(self::PAGINATE_TOKEN_HEADER)) + ) { + $token = $request->getHeader(self::PAGINATE_TOKEN_HEADER); + $offset = (int)$request->getHeader(self::PAGINATE_OFFSET_HEADER); + $count = (int)$request->getHeader(self::PAGINATE_COUNT_HEADER) ?: $this->pageSize; + + $items = $this->cache->get($url, $token, $offset, $count); + + $response->setStatus(207); + $response->addHeader(self::PAGINATE_HEADER, 'true'); + $response->setHeader('Content-Type', 'application/xml; charset=utf-8'); + $response->setHeader('Vary', 'Brief,Prefer'); + + $prefer = $this->server->getHTTPPrefer(); + $minimal = $prefer['return'] === 'minimal'; + + $data = $this->server->generateMultiStatus($items, $minimal); + $response->setBody($data); + + return false; + } + } +} diff --git a/apps/dav/lib/Profiler/ProfilerPlugin.php b/apps/dav/lib/Profiler/ProfilerPlugin.php index 672ca4010b7..455760fc2bf 100644 --- a/apps/dav/lib/Profiler/ProfilerPlugin.php +++ b/apps/dav/lib/Profiler/ProfilerPlugin.php @@ -1,24 +1,9 @@ -<?php declare(strict_types = 1); +<?php + +declare(strict_types = 1); /** - * @copyright 2021 Carl Schwan <carl@carlschwan.eu> - * - * @author Carl Schwan <carl@carlschwan.eu> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Profiler; @@ -29,10 +14,9 @@ use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class ProfilerPlugin extends \Sabre\DAV\ServerPlugin { - private IRequest $request; - - public function __construct(IRequest $request) { - $this->request = $request; + public function __construct( + private IRequest $request, + ) { } /** @return void */ diff --git a/apps/dav/lib/Provisioning/Apple/AppleProvisioningNode.php b/apps/dav/lib/Provisioning/Apple/AppleProvisioningNode.php index 614ddabb7ef..bb098a0f107 100644 --- a/apps/dav/lib/Provisioning/Apple/AppleProvisioningNode.php +++ b/apps/dav/lib/Provisioning/Apple/AppleProvisioningNode.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Provisioning\Apple; @@ -32,13 +15,12 @@ use Sabre\DAV\PropPatch; class AppleProvisioningNode implements INode, IProperties { public const FILENAME = 'apple-provisioning.mobileconfig'; - protected $timeFactory; - /** * @param ITimeFactory $timeFactory */ - public function __construct(ITimeFactory $timeFactory) { - $this->timeFactory = $timeFactory; + public function __construct( + protected ITimeFactory $timeFactory, + ) { } /** @@ -76,7 +58,7 @@ class AppleProvisioningNode implements INode, IProperties { return [ '{DAV:}getcontentlength' => 42, - '{DAV:}getlastmodified' => $datetime->format(\DateTimeInterface::RFC2822), + '{DAV:}getlastmodified' => $datetime->format(\DateTimeInterface::RFC7231), ]; } diff --git a/apps/dav/lib/Provisioning/Apple/AppleProvisioningPlugin.php b/apps/dav/lib/Provisioning/Apple/AppleProvisioningPlugin.php index f92bc4f3a0d..258138caa42 100644 --- a/apps/dav/lib/Provisioning/Apple/AppleProvisioningPlugin.php +++ b/apps/dav/lib/Provisioning/Apple/AppleProvisioningPlugin.php @@ -1,31 +1,12 @@ <?php + /** - * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Nils Wittenbrink <nilswittenbrink@web.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Provisioning\Apple; +use OCP\AppFramework\Http; use OCP\IL10N; use OCP\IRequest; use OCP\IURLGenerator; @@ -36,61 +17,28 @@ use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class AppleProvisioningPlugin extends ServerPlugin { - /** * @var Server */ protected $server; /** - * @var IURLGenerator - */ - protected $urlGenerator; - - /** - * @var IUserSession - */ - protected $userSession; - - /** * @var \OC_Defaults */ protected $themingDefaults; /** - * @var IRequest - */ - protected $request; - - /** - * @var IL10N - */ - protected $l10n; - - /** - * @var \closure - */ - protected $uuidClosure; - - /** * AppleProvisioningPlugin constructor. - * - * @param IUserSession $userSession - * @param IURLGenerator $urlGenerator - * @param \OC_Defaults $themingDefaults - * @param IRequest $request - * @param IL10N $l10n - * @param \closure $uuidClosure */ - public function __construct(IUserSession $userSession, IURLGenerator $urlGenerator, - \OC_Defaults $themingDefaults, IRequest $request, - IL10N $l10n, \closure $uuidClosure) { - $this->userSession = $userSession; - $this->urlGenerator = $urlGenerator; + public function __construct( + protected IUserSession $userSession, + protected IURLGenerator $urlGenerator, + \OC_Defaults $themingDefaults, + protected IRequest $request, + protected IL10N $l10n, + protected \Closure $uuidClosure, + ) { $this->themingDefaults = $themingDefaults; - $this->request = $request; - $this->l10n = $l10n; - $this->uuidClosure = $uuidClosure; } /** @@ -120,7 +68,7 @@ class AppleProvisioningPlugin extends ServerPlugin { $useSSL = ($serverProtocol === 'https'); if (!$useSSL) { - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setHeader('Content-Type', 'text/plain; charset=utf-8'); $response->setBody($this->l10n->t('Your %s needs to be configured to use HTTPS in order to use CalDAV and CardDAV with iOS/macOS.', [$this->themingDefaults->getName()])); @@ -129,11 +77,7 @@ class AppleProvisioningPlugin extends ServerPlugin { $absoluteURL = $this->urlGenerator->getBaseUrl(); $parsedUrl = parse_url($absoluteURL); - if (isset($parsedUrl['port'])) { - $serverPort = $parsedUrl['port']; - } else { - $serverPort = 443; - } + $serverPort = $parsedUrl['port'] ?? 443; $server_url = $parsedUrl['host']; $description = $this->themingDefaults->getName(); @@ -157,7 +101,7 @@ class AppleProvisioningPlugin extends ServerPlugin { $filename = $userId . '-' . AppleProvisioningNode::FILENAME; $xmlSkeleton = $this->getTemplate(); - $body = vsprintf($xmlSkeleton, array_map(function ($v) { + $body = vsprintf($xmlSkeleton, array_map(function (string $v) { return \htmlspecialchars($v, ENT_XML1, 'UTF-8'); }, [ $description, @@ -182,7 +126,7 @@ class AppleProvisioningPlugin extends ServerPlugin { ] )); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); $response->setHeader('Content-Type', 'application/xml; charset=utf-8'); $response->setBody($body); diff --git a/apps/dav/lib/ResponseDefinitions.php b/apps/dav/lib/ResponseDefinitions.php new file mode 100644 index 00000000000..3deafad6704 --- /dev/null +++ b/apps/dav/lib/ResponseDefinitions.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV; + +use OCA\DAV\CalDAV\UpcomingEvent; + +/** + * @psalm-type DAVOutOfOfficeDataCommon = array{ + * userId: string, + * message: string, + * replacementUserId: ?string, + * replacementUserDisplayName: ?string, + * } + * + * @psalm-type DAVOutOfOfficeData = DAVOutOfOfficeDataCommon&array{ + * id: int, + * firstDay: string, + * lastDay: string, + * status: string, + * } + * + * @todo this is a copy of \OCP\User\IOutOfOfficeData + * @psalm-type DAVCurrentOutOfOfficeData = DAVOutOfOfficeDataCommon&array{ + * id: string, + * startDate: int, + * endDate: int, + * shortMessage: string, + * } + * + * @see UpcomingEvent::jsonSerialize + * @psalm-type DAVUpcomingEvent = array{ + * uri: string, + * calendarUri: string, + * start: ?int, + * summary: ?string, + * location: ?string, + * } + */ +class ResponseDefinitions { +} diff --git a/apps/dav/lib/RootCollection.php b/apps/dav/lib/RootCollection.php index 2e5952f6efd..870aa0d4540 100644 --- a/apps/dav/lib/RootCollection.php +++ b/apps/dav/lib/RootCollection.php @@ -1,29 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV; @@ -36,47 +16,63 @@ use OCA\DAV\CalDAV\Proxy\ProxyMapper; use OCA\DAV\CalDAV\PublicCalendarRoot; use OCA\DAV\CalDAV\ResourceBooking\ResourcePrincipalBackend; use OCA\DAV\CalDAV\ResourceBooking\RoomPrincipalBackend; +use OCA\DAV\CalDAV\Sharing\Backend; use OCA\DAV\CardDAV\AddressBookRoot; use OCA\DAV\CardDAV\CardDavBackend; use OCA\DAV\Connector\Sabre\Principal; use OCA\DAV\DAV\GroupPrincipalBackend; use OCA\DAV\DAV\SystemPrincipalBackend; use OCA\DAV\Provisioning\Apple\AppleProvisioningNode; +use OCA\DAV\SystemTag\SystemTagsByIdCollection; +use OCA\DAV\SystemTag\SystemTagsInUseCollection; +use OCA\DAV\SystemTag\SystemTagsRelationsCollection; use OCA\DAV\Upload\CleanupService; +use OCP\Accounts\IAccountManager; use OCP\App\IAppManager; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Comments\ICommentsManager; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\IRootFolder; use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use OCP\Server; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; use Psr\Log\LoggerInterface; use Sabre\DAV\SimpleCollection; class RootCollection extends SimpleCollection { public function __construct() { $l10n = \OC::$server->getL10N('dav'); - $random = \OC::$server->getSecureRandom(); - $logger = \OC::$server->getLogger(); - $psrLogger = \OC::$server->get(LoggerInterface::class); - $userManager = \OC::$server->getUserManager(); - $userSession = \OC::$server->getUserSession(); - $groupManager = \OC::$server->getGroupManager(); - $shareManager = \OC::$server->getShareManager(); - $db = \OC::$server->getDatabaseConnection(); - $dispatcher = \OC::$server->get(IEventDispatcher::class); - $legacyDispatcher = \OC::$server->getEventDispatcher(); - $config = \OC::$server->get(IConfig::class); - $proxyMapper = \OC::$server->query(ProxyMapper::class); + $random = Server::get(ISecureRandom::class); + $logger = Server::get(LoggerInterface::class); + $userManager = Server::get(IUserManager::class); + $userSession = Server::get(IUserSession::class); + $groupManager = Server::get(IGroupManager::class); + $shareManager = Server::get(\OCP\Share\IManager::class); + $db = Server::get(IDBConnection::class); + $dispatcher = Server::get(IEventDispatcher::class); + $config = Server::get(IConfig::class); + $proxyMapper = Server::get(ProxyMapper::class); + $rootFolder = Server::get(IRootFolder::class); $userPrincipalBackend = new Principal( $userManager, $groupManager, + Server::get(IAccountManager::class), $shareManager, - \OC::$server->getUserSession(), - \OC::$server->getAppManager(), + Server::get(IUserSession::class), + Server::get(IAppManager::class), $proxyMapper, - \OC::$server->get(KnownUserService::class), - \OC::$server->getConfig(), + Server::get(KnownUserService::class), + Server::get(IConfig::class), \OC::$server->getL10NFactory() ); + $groupPrincipalBackend = new GroupPrincipalBackend($groupManager, $userSession, $shareManager, $config); $calendarResourcePrincipalBackend = new ResourcePrincipalBackend($db, $userSession, $groupManager, $logger, $proxyMapper); $calendarRoomPrincipalBackend = new RoomPrincipalBackend($db, $userSession, $groupManager, $logger, $proxyMapper); @@ -91,10 +87,8 @@ class RootCollection extends SimpleCollection { $systemPrincipals = new Collection(new SystemPrincipalBackend(), 'principals/system'); $systemPrincipals->disableListing = $disableListing; $calendarResourcePrincipals = new Collection($calendarResourcePrincipalBackend, 'principals/calendar-resources'); - $calendarResourcePrincipals->disableListing = $disableListing; $calendarRoomPrincipals = new Collection($calendarRoomPrincipalBackend, 'principals/calendar-rooms'); - $calendarRoomPrincipals->disableListing = $disableListing; - + $calendarSharingBackend = Server::get(Backend::class); $filesCollection = new Files\RootCollection($userPrincipalBackend, 'principals/users'); $filesCollection->disableListing = $disableListing; @@ -102,64 +96,82 @@ class RootCollection extends SimpleCollection { $db, $userPrincipalBackend, $userManager, - $groupManager, $random, $logger, $dispatcher, - $legacyDispatcher, - $config + $config, + $calendarSharingBackend, + false, ); - $userCalendarRoot = new CalendarRoot($userPrincipalBackend, $caldavBackend, 'principals/users', $psrLogger); + $userCalendarRoot = new CalendarRoot($userPrincipalBackend, $caldavBackend, 'principals/users', $logger); $userCalendarRoot->disableListing = $disableListing; - $resourceCalendarRoot = new CalendarRoot($calendarResourcePrincipalBackend, $caldavBackend, 'principals/calendar-resources', $psrLogger); + $resourceCalendarRoot = new CalendarRoot($calendarResourcePrincipalBackend, $caldavBackend, 'principals/calendar-resources', $logger); $resourceCalendarRoot->disableListing = $disableListing; - $roomCalendarRoot = new CalendarRoot($calendarRoomPrincipalBackend, $caldavBackend, 'principals/calendar-rooms', $psrLogger); + $roomCalendarRoot = new CalendarRoot($calendarRoomPrincipalBackend, $caldavBackend, 'principals/calendar-rooms', $logger); $roomCalendarRoot->disableListing = $disableListing; - $publicCalendarRoot = new PublicCalendarRoot($caldavBackend, $l10n, $config, $psrLogger); - $publicCalendarRoot->disableListing = $disableListing; + $publicCalendarRoot = new PublicCalendarRoot($caldavBackend, $l10n, $config, $logger); - $systemTagCollection = new SystemTag\SystemTagsByIdCollection( - \OC::$server->getSystemTagManager(), - \OC::$server->getUserSession(), - $groupManager - ); - $systemTagRelationsCollection = new SystemTag\SystemTagsRelationsCollection( - \OC::$server->getSystemTagManager(), - \OC::$server->getSystemTagObjectMapper(), - \OC::$server->getUserSession(), + $systemTagCollection = Server::get(SystemTagsByIdCollection::class); + $systemTagRelationsCollection = new SystemTagsRelationsCollection( + Server::get(ISystemTagManager::class), + Server::get(ISystemTagObjectMapper::class), + Server::get(IUserSession::class), $groupManager, - \OC::$server->getEventDispatcher() + $dispatcher, + $rootFolder, ); + $systemTagInUseCollection = Server::get(SystemTagsInUseCollection::class); $commentsCollection = new Comments\RootCollection( - \OC::$server->getCommentsManager(), + Server::get(ICommentsManager::class), $userManager, - \OC::$server->getUserSession(), - \OC::$server->getEventDispatcher(), - \OC::$server->getLogger() + Server::get(IUserSession::class), + $dispatcher, + $logger ); - $pluginManager = new PluginManager(\OC::$server, \OC::$server->query(IAppManager::class)); - $usersCardDavBackend = new CardDavBackend($db, $userPrincipalBackend, $userManager, $groupManager, $dispatcher, $legacyDispatcher); - $usersAddressBookRoot = new AddressBookRoot($userPrincipalBackend, $usersCardDavBackend, $pluginManager, 'principals/users'); + $contactsSharingBackend = Server::get(\OCA\DAV\CardDAV\Sharing\Backend::class); + $config = Server::get(IConfig::class); + + $pluginManager = new PluginManager(\OC::$server, Server::get(IAppManager::class)); + $usersCardDavBackend = new CardDavBackend( + $db, + $userPrincipalBackend, + $userManager, + $dispatcher, + $contactsSharingBackend, + $config + ); + $usersAddressBookRoot = new AddressBookRoot($userPrincipalBackend, $usersCardDavBackend, $pluginManager, $userSession->getUser(), $groupManager, 'principals/users'); $usersAddressBookRoot->disableListing = $disableListing; - $systemCardDavBackend = new CardDavBackend($db, $userPrincipalBackend, $userManager, $groupManager, $dispatcher, $legacyDispatcher); - $systemAddressBookRoot = new AddressBookRoot(new SystemPrincipalBackend(), $systemCardDavBackend, $pluginManager, 'principals/system'); + $systemCardDavBackend = new CardDavBackend( + $db, + $userPrincipalBackend, + $userManager, + $dispatcher, + $contactsSharingBackend, + $config + ); + $systemAddressBookRoot = new AddressBookRoot(new SystemPrincipalBackend(), $systemCardDavBackend, $pluginManager, $userSession->getUser(), $groupManager, 'principals/system'); $systemAddressBookRoot->disableListing = $disableListing; $uploadCollection = new Upload\RootCollection( $userPrincipalBackend, 'principals/users', - \OC::$server->query(CleanupService::class)); + Server::get(CleanupService::class), + $rootFolder, + $userSession, + $shareManager, + ); $uploadCollection->disableListing = $disableListing; $avatarCollection = new Avatars\RootCollection($userPrincipalBackend, 'principals/users'); $avatarCollection->disableListing = $disableListing; $appleProvisioning = new AppleProvisioningNode( - \OC::$server->query(ITimeFactory::class)); + Server::get(ITimeFactory::class)); $children = [ new SimpleCollection('principals', [ @@ -180,6 +192,7 @@ class RootCollection extends SimpleCollection { $systemAddressBookRoot]), $systemTagCollection, $systemTagRelationsCollection, + $systemTagInUseCollection, $commentsCollection, $uploadCollection, $avatarCollection, diff --git a/apps/dav/lib/Search/ACalendarSearchProvider.php b/apps/dav/lib/Search/ACalendarSearchProvider.php index 91904d5e8d6..331d05cb500 100644 --- a/apps/dav/lib/Search/ACalendarSearchProvider.php +++ b/apps/dav/lib/Search/ACalendarSearchProvider.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, 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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Search; @@ -40,18 +23,6 @@ use Sabre\VObject\Reader; */ abstract class ACalendarSearchProvider implements IProvider { - /** @var IAppManager */ - protected $appManager; - - /** @var IL10N */ - protected $l10n; - - /** @var IURLGenerator */ - protected $urlGenerator; - - /** @var CalDavBackend */ - protected $backend; - /** * ACalendarSearchProvider constructor. * @@ -60,14 +31,12 @@ abstract class ACalendarSearchProvider implements IProvider { * @param IURLGenerator $urlGenerator * @param CalDavBackend $backend */ - public function __construct(IAppManager $appManager, - IL10N $l10n, - IURLGenerator $urlGenerator, - CalDavBackend $backend) { - $this->appManager = $appManager; - $this->l10n = $l10n; - $this->urlGenerator = $urlGenerator; - $this->backend = $backend; + public function __construct( + protected IAppManager $appManager, + protected IL10N $l10n, + protected IURLGenerator $urlGenerator, + protected CalDavBackend $backend, + ) { } /** @@ -81,7 +50,7 @@ abstract class ACalendarSearchProvider implements IProvider { $calendars = $this->backend->getCalendarsForUser($principalUri); $calendarsById = []; foreach ($calendars as $calendar) { - $calendarsById[(int) $calendar['id']] = $calendar; + $calendarsById[(int)$calendar['id']] = $calendar; } return $calendarsById; @@ -98,7 +67,7 @@ abstract class ACalendarSearchProvider implements IProvider { $subscriptions = $this->backend->getSubscriptionsForUser($principalUri); $subscriptionsById = []; foreach ($subscriptions as $subscription) { - $subscriptionsById[(int) $subscription['id']] = $subscription; + $subscriptionsById[(int)$subscription['id']] = $subscription; } return $subscriptionsById; diff --git a/apps/dav/lib/Search/ContactsSearchProvider.php b/apps/dav/lib/Search/ContactsSearchProvider.php index a7c2969016b..158c0d0813e 100644 --- a/apps/dav/lib/Search/ContactsSearchProvider.php +++ b/apps/dav/lib/Search/ContactsSearchProvider.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Search; @@ -32,31 +13,24 @@ use OCP\App\IAppManager; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUser; -use OCP\Search\IProvider; +use OCP\Search\FilterDefinition; +use OCP\Search\IFilter; +use OCP\Search\IFilteringProvider; use OCP\Search\ISearchQuery; use OCP\Search\SearchResult; use OCP\Search\SearchResultEntry; use Sabre\VObject\Component\VCard; use Sabre\VObject\Reader; -class ContactsSearchProvider implements IProvider { - - /** @var IAppManager */ - private $appManager; - - /** @var IL10N */ - private $l10n; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var CardDavBackend */ - private $backend; +class ContactsSearchProvider implements IFilteringProvider { + private static array $searchPropertiesRestricted = [ + 'N', + 'FN', + 'NICKNAME', + 'EMAIL', + ]; - /** - * @var string[] - */ - private static $searchProperties = [ + private static array $searchProperties = [ 'N', 'FN', 'NICKNAME', @@ -68,22 +42,12 @@ class ContactsSearchProvider implements IProvider { 'NOTE', ]; - /** - * ContactsSearchProvider constructor. - * - * @param IAppManager $appManager - * @param IL10N $l10n - * @param IURLGenerator $urlGenerator - * @param CardDavBackend $backend - */ - public function __construct(IAppManager $appManager, - IL10N $l10n, - IURLGenerator $urlGenerator, - CardDavBackend $backend) { - $this->appManager = $appManager; - $this->l10n = $l10n; - $this->urlGenerator = $urlGenerator; - $this->backend = $backend; + public function __construct( + private IAppManager $appManager, + private IL10N $l10n, + private IURLGenerator $urlGenerator, + private CardDavBackend $backend, + ) { } /** @@ -100,19 +64,14 @@ class ContactsSearchProvider implements IProvider { return $this->l10n->t('Contacts'); } - /** - * @inheritDoc - */ - public function getOrder(string $route, array $routeParameters): int { - if ($route === 'contacts.Page.index') { - return -1; + public function getOrder(string $route, array $routeParameters): ?int { + if ($this->appManager->isEnabledForUser('contacts')) { + return $route === 'contacts.Page.index' ? -1 : 25; } - return 25; + + return null; } - /** - * @inheritDoc - */ public function search(IUser $user, ISearchQuery $query): SearchResult { if (!$this->appManager->isEnabledForUser('contacts', $user)) { return SearchResult::complete($this->getName(), []); @@ -122,17 +81,21 @@ class ContactsSearchProvider implements IProvider { $addressBooks = $this->backend->getAddressBooksForUser($principalUri); $addressBooksById = []; foreach ($addressBooks as $addressBook) { - $addressBooksById[(int) $addressBook['id']] = $addressBook; + $addressBooksById[(int)$addressBook['id']] = $addressBook; } $searchResults = $this->backend->searchPrincipalUri( $principalUri, - $query->getTerm(), - self::$searchProperties, + $query->getFilter('term')?->get() ?? '', + $query->getFilter('title-only')?->get() ? self::$searchPropertiesRestricted : self::$searchProperties, [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), - ] + 'since' => $query->getFilter('since'), + 'until' => $query->getFilter('until'), + 'person' => $this->getPersonDisplayName($query->getFilter('person')), + 'company' => $query->getFilter('company'), + ], ); $formattedResults = \array_map(function (array $contactRow) use ($addressBooksById):SearchResultEntry { $addressBook = $addressBooksById[$contactRow['addressbookid']]; @@ -146,9 +109,14 @@ class ContactsSearchProvider implements IProvider { $title = (string)$vCard->FN; $subline = $this->generateSubline($vCard); - $resourceUrl = $this->getDeepLinkToContactsApp($addressBook['uri'], (string) $vCard->UID); + $resourceUrl = $this->getDeepLinkToContactsApp($addressBook['uri'], (string)$vCard->UID); + + $result = new SearchResultEntry($thumbnailUrl, $title, $subline, $resourceUrl, 'icon-contacts-dark', true); + $result->addAttribute('displayName', $title); + $result->addAttribute('email', $subline); + $result->addAttribute('phoneNumber', (string)$vCard->TEL); - return new SearchResultEntry($thumbnailUrl, $title, $subline, $resourceUrl, 'icon-contacts-dark', true); + return $result; }, $searchResults); return SearchResult::paginated( @@ -157,16 +125,19 @@ class ContactsSearchProvider implements IProvider { $query->getCursor() + count($formattedResults) ); } + private function getPersonDisplayName(?IFilter $person): ?string { + $user = $person?->get(); + if ($user instanceof IUser) { + return $user->getDisplayName(); + } + return null; + } - /** - * @param string $principalUri - * @param string $addressBookUri - * @param string $contactsUri - * @return string - */ - protected function getDavUrlForContact(string $principalUri, - string $addressBookUri, - string $contactsUri): string { + protected function getDavUrlForContact( + string $principalUri, + string $addressBookUri, + string $contactsUri, + ): string { [, $principalType, $principalId] = explode('/', $principalUri, 3); return $this->urlGenerator->getAbsoluteURL( @@ -178,13 +149,10 @@ class ContactsSearchProvider implements IProvider { ); } - /** - * @param string $addressBookUri - * @param string $contactUid - * @return string - */ - protected function getDeepLinkToContactsApp(string $addressBookUri, - string $contactUid): string { + protected function getDeepLinkToContactsApp( + string $addressBookUri, + string $contactUid, + ): string { return $this->urlGenerator->getAbsoluteURL( $this->urlGenerator->linkToRoute('contacts.contacts.direct', [ 'contact' => $contactUid . '~' . $addressBookUri @@ -192,10 +160,6 @@ class ContactsSearchProvider implements IProvider { ); } - /** - * @param VCard $vCard - * @return string - */ protected function generateSubline(VCard $vCard): string { $emailAddresses = $vCard->select('EMAIL'); if (!is_array($emailAddresses) || empty($emailAddresses)) { @@ -204,4 +168,24 @@ class ContactsSearchProvider implements IProvider { return (string)$emailAddresses[0]; } + + public function getSupportedFilters(): array { + return [ + 'term', + 'since', + 'until', + 'person', + 'title-only', + ]; + } + + public function getAlternateIds(): array { + return []; + } + + public function getCustomFilters(): array { + return [ + new FilterDefinition('company'), + ]; + } } diff --git a/apps/dav/lib/Search/EventsSearchProvider.php b/apps/dav/lib/Search/EventsSearchProvider.php index 07fc90397ed..55fba40918a 100644 --- a/apps/dav/lib/Search/EventsSearchProvider.php +++ b/apps/dav/lib/Search/EventsSearchProvider.php @@ -3,46 +3,32 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Search; use OCA\DAV\CalDAV\CalDavBackend; use OCP\IUser; +use OCP\Search\IFilteringProvider; use OCP\Search\ISearchQuery; use OCP\Search\SearchResult; use OCP\Search\SearchResultEntry; use Sabre\VObject\Component; use Sabre\VObject\DateTimeParser; use Sabre\VObject\Property; +use Sabre\VObject\Property\ICalendar\DateTime; +use function array_combine; +use function array_fill; +use function array_key_exists; +use function array_map; /** * Class EventsSearchProvider * * @package OCA\DAV\Search */ -class EventsSearchProvider extends ACalendarSearchProvider { - +class EventsSearchProvider extends ACalendarSearchProvider implements IFilteringProvider { /** * @var string[] */ @@ -85,18 +71,21 @@ class EventsSearchProvider extends ACalendarSearchProvider { /** * @inheritDoc */ - public function getOrder(string $route, array $routeParameters): int { - if ($route === 'calendar.View.index') { - return -1; + public function getOrder(string $route, array $routeParameters): ?int { + if ($this->appManager->isEnabledForUser('calendar')) { + return $route === 'calendar.View.index' ? -1 : 30; } - return 30; + + return null; } /** * @inheritDoc */ - public function search(IUser $user, - ISearchQuery $query): SearchResult { + public function search( + IUser $user, + ISearchQuery $query, + ): SearchResult { if (!$this->appManager->isEnabledForUser('calendar', $user)) { return SearchResult::complete($this->getName(), []); } @@ -105,18 +94,60 @@ class EventsSearchProvider extends ACalendarSearchProvider { $calendarsById = $this->getSortedCalendars($principalUri); $subscriptionsById = $this->getSortedSubscriptions($principalUri); - $searchResults = $this->backend->searchPrincipalUri( - $principalUri, - $query->getTerm(), - [self::$componentType], - self::$searchProperties, - self::$searchParameters, - [ - 'limit' => $query->getLimit(), - 'offset' => $query->getCursor(), - ] - ); - $formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById):SearchResultEntry { + /** @var string|null $term */ + $term = $query->getFilter('term')?->get(); + if ($term === null) { + $searchResults = []; + } else { + $searchResults = $this->backend->searchPrincipalUri( + $principalUri, + $term, + [self::$componentType], + self::$searchProperties, + self::$searchParameters, + [ + 'limit' => $query->getLimit(), + 'offset' => $query->getCursor(), + 'timerange' => [ + 'start' => $query->getFilter('since')?->get(), + 'end' => $query->getFilter('until')?->get(), + ], + ] + ); + } + /** @var IUser|null $person */ + $person = $query->getFilter('person')?->get(); + $personDisplayName = $person?->getDisplayName(); + if ($personDisplayName !== null) { + $attendeeSearchResults = $this->backend->searchPrincipalUri( + $principalUri, + $personDisplayName, + [self::$componentType], + ['ATTENDEE'], + self::$searchParameters, + [ + 'limit' => $query->getLimit(), + 'offset' => $query->getCursor(), + 'timerange' => [ + 'start' => $query->getFilter('since')?->get(), + 'end' => $query->getFilter('until')?->get(), + ], + ], + ); + + $searchResultIndex = array_combine( + array_map(fn ($event) => $event['calendarid'] . '-' . $event['uri'], $searchResults), + array_fill(0, count($searchResults), null), + ); + foreach ($attendeeSearchResults as $attendeeResult) { + if (array_key_exists($attendeeResult['calendarid'] . '-' . $attendeeResult['uri'], $searchResultIndex)) { + // Duplicate + continue; + } + $searchResults[] = $attendeeResult; + } + } + $formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById): SearchResultEntry { $component = $this->getPrimaryComponent($eventRow['calendardata'], self::$componentType); $title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled event')); $subline = $this->generateSubline($component); @@ -127,8 +158,16 @@ class EventsSearchProvider extends ACalendarSearchProvider { $calendar = $subscriptionsById[$eventRow['calendarid']]; } $resourceUrl = $this->getDeepLinkToCalendarApp($calendar['principaluri'], $calendar['uri'], $eventRow['uri']); + $result = new SearchResultEntry('', $title, $subline, $resourceUrl, 'icon-calendar-dark', false); + + $dtStart = $component->DTSTART; + + if ($dtStart instanceof DateTime) { + $startDateTime = $dtStart->getDateTime()->format('U'); + $result->addAttribute('createdAt', $startDateTime); + } - return new SearchResultEntry('', $title, $subline, $resourceUrl, 'icon-calendar-dark', false); + return $result; }, $searchResults); return SearchResult::paginated( @@ -138,15 +177,11 @@ class EventsSearchProvider extends ACalendarSearchProvider { ); } - /** - * @param string $principalUri - * @param string $calendarUri - * @param string $calendarObjectUri - * @return string - */ - protected function getDeepLinkToCalendarApp(string $principalUri, - string $calendarUri, - string $calendarObjectUri): string { + protected function getDeepLinkToCalendarApp( + string $principalUri, + string $calendarUri, + string $calendarObjectUri, + ): string { $davUrl = $this->getDavUrlForCalendarObject($principalUri, $calendarUri, $calendarObjectUri); // This route will automatically figure out what recurrence-id to open return $this->urlGenerator->getAbsoluteURL( @@ -156,15 +191,11 @@ class EventsSearchProvider extends ACalendarSearchProvider { ); } - /** - * @param string $principalUri - * @param string $calendarUri - * @param string $calendarObjectUri - * @return string - */ - protected function getDavUrlForCalendarObject(string $principalUri, - string $calendarUri, - string $calendarObjectUri): string { + protected function getDavUrlForCalendarObject( + string $principalUri, + string $calendarUri, + string $calendarObjectUri, + ): string { [,, $principalId] = explode('/', $principalUri, 3); return $this->urlGenerator->linkTo('', 'remote.php') . '/dav/calendars/' @@ -173,10 +204,6 @@ class EventsSearchProvider extends ACalendarSearchProvider { . $calendarObjectUri; } - /** - * @param Component $eventComponent - * @return string - */ protected function generateSubline(Component $eventComponent): string { $dtStart = $eventComponent->DTSTART; $dtEnd = $this->getDTEndForEvent($eventComponent); @@ -207,10 +234,6 @@ class EventsSearchProvider extends ACalendarSearchProvider { return "$formattedStartDate $formattedStartTime - $formattedEndDate $formattedEndTime"; } - /** - * @param Component $eventComponent - * @return Property - */ protected function getDTEndForEvent(Component $eventComponent):Property { if (isset($eventComponent->DTEND)) { $end = $eventComponent->DTEND; @@ -233,13 +256,27 @@ class EventsSearchProvider extends ACalendarSearchProvider { return $end; } - /** - * @param \DateTime $dtStart - * @param \DateTime $dtEnd - * @return bool - */ - protected function isDayEqual(\DateTime $dtStart, - \DateTime $dtEnd) { + protected function isDayEqual( + \DateTime $dtStart, + \DateTime $dtEnd, + ): bool { return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); } + + public function getSupportedFilters(): array { + return [ + 'term', + 'person', + 'since', + 'until', + ]; + } + + public function getAlternateIds(): array { + return []; + } + + public function getCustomFilters(): array { + return []; + } } diff --git a/apps/dav/lib/Search/TasksSearchProvider.php b/apps/dav/lib/Search/TasksSearchProvider.php index 763720ee4ae..15baf070e81 100644 --- a/apps/dav/lib/Search/TasksSearchProvider.php +++ b/apps/dav/lib/Search/TasksSearchProvider.php @@ -3,28 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Search; @@ -41,7 +21,6 @@ use Sabre\VObject\Component; * @package OCA\DAV\Search */ class TasksSearchProvider extends ACalendarSearchProvider { - /** * @var string[] */ @@ -78,18 +57,21 @@ class TasksSearchProvider extends ACalendarSearchProvider { /** * @inheritDoc */ - public function getOrder(string $route, array $routeParameters): int { - if ($route === 'tasks.Page.index') { - return -1; + public function getOrder(string $route, array $routeParameters): ?int { + if ($this->appManager->isEnabledForUser('tasks')) { + return $route === 'tasks.Page.index' ? -1 : 35; } - return 35; + + return null; } /** * @inheritDoc */ - public function search(IUser $user, - ISearchQuery $query): SearchResult { + public function search( + IUser $user, + ISearchQuery $query, + ): SearchResult { if (!$this->appManager->isEnabledForUser('tasks', $user)) { return SearchResult::complete($this->getName(), []); } @@ -100,13 +82,15 @@ class TasksSearchProvider extends ACalendarSearchProvider { $searchResults = $this->backend->searchPrincipalUri( $principalUri, - $query->getTerm(), + $query->getFilter('term')?->get() ?? '', [self::$componentType], self::$searchProperties, self::$searchParameters, [ 'limit' => $query->getLimit(), 'offset' => $query->getCursor(), + 'since' => $query->getFilter('since'), + 'until' => $query->getFilter('until'), ] ); $formattedResults = \array_map(function (array $taskRow) use ($calendarsById, $subscriptionsById):SearchResultEntry { @@ -131,26 +115,19 @@ class TasksSearchProvider extends ACalendarSearchProvider { ); } - /** - * @param string $calendarUri - * @param string $taskUri - * @return string - */ - protected function getDeepLinkToTasksApp(string $calendarUri, - string $taskUri): string { + protected function getDeepLinkToTasksApp( + string $calendarUri, + string $taskUri, + ): string { return $this->urlGenerator->getAbsoluteURL( $this->urlGenerator->linkToRoute('tasks.page.index') - . '#/calendars/' + . 'calendars/' . $calendarUri . '/tasks/' . $taskUri ); } - /** - * @param Component $taskComponent - * @return string - */ protected function generateSubline(Component $taskComponent): string { if ($taskComponent->COMPLETED) { $completedDateTime = new \DateTime($taskComponent->COMPLETED->getDateTime()->format(\DateTimeInterface::ATOM)); diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 589e6c2bd6c..a92e162f1b0 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -1,53 +1,34 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Brandon Kirsch <brandonkirsch@github.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV; -use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin; -use OCP\Diagnostics\IEventLogger; -use OCP\Profiler\IProfiler; -use OCA\DAV\Profiler\ProfilerPlugin; -use OCP\AppFramework\Http\Response; -use Psr\Log\LoggerInterface; +use OC\Files\Filesystem; use OCA\DAV\AppInfo\PluginManager; +use OCA\DAV\BulkUpload\BulkUploadPlugin; +use OCA\DAV\CalDAV\BirthdayCalendar\EnablePlugin; use OCA\DAV\CalDAV\BirthdayService; +use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\CalDAV\EventComparisonService; +use OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin; +use OCA\DAV\CalDAV\Publishing\PublishPlugin; +use OCA\DAV\CalDAV\Schedule\IMipPlugin; +use OCA\DAV\CalDAV\Schedule\IMipService; +use OCA\DAV\CalDAV\Security\RateLimitingPlugin; +use OCA\DAV\CalDAV\Validation\CalDavValidatePlugin; use OCA\DAV\CardDAV\HasPhotoPlugin; use OCA\DAV\CardDAV\ImageExportPlugin; use OCA\DAV\CardDAV\MultiGetExportPlugin; use OCA\DAV\CardDAV\PhotoCache; +use OCA\DAV\CardDAV\Security\CardDavRateLimitingPlugin; +use OCA\DAV\CardDAV\Validation\CardDavValidatePlugin; use OCA\DAV\Comments\CommentsPlugin; use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin; +use OCA\DAV\Connector\Sabre\AppleQuirksPlugin; use OCA\DAV\Connector\Sabre\Auth; use OCA\DAV\Connector\Sabre\BearerAuth; use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin; @@ -57,64 +38,104 @@ use OCA\DAV\Connector\Sabre\CommentPropertiesPlugin; use OCA\DAV\Connector\Sabre\CopyEtagHeaderPlugin; use OCA\DAV\Connector\Sabre\DavAclPlugin; use OCA\DAV\Connector\Sabre\DummyGetResponsePlugin; +use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin; use OCA\DAV\Connector\Sabre\FakeLockerPlugin; use OCA\DAV\Connector\Sabre\FilesPlugin; use OCA\DAV\Connector\Sabre\FilesReportPlugin; +use OCA\DAV\Connector\Sabre\LockPlugin; +use OCA\DAV\Connector\Sabre\MaintenancePlugin; use OCA\DAV\Connector\Sabre\PropfindCompressionPlugin; +use OCA\DAV\Connector\Sabre\PropFindMonitorPlugin; use OCA\DAV\Connector\Sabre\QuotaPlugin; +use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin; use OCA\DAV\Connector\Sabre\SharesPlugin; use OCA\DAV\Connector\Sabre\TagsPlugin; +use OCA\DAV\Connector\Sabre\ZipFolderPlugin; use OCA\DAV\DAV\CustomPropertiesBackend; use OCA\DAV\DAV\PublicAuth; +use OCA\DAV\DAV\ViewOnlyPlugin; +use OCA\DAV\Events\SabrePluginAddEvent; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\DAV\Files\BrowserErrorPagePlugin; +use OCA\DAV\Files\FileSearchBackend; use OCA\DAV\Files\LazySearchBackend; -use OCA\DAV\BulkUpload\BulkUploadPlugin; +use OCA\DAV\Paginate\PaginatePlugin; +use OCA\DAV\Profiler\ProfilerPlugin; use OCA\DAV\Provisioning\Apple\AppleProvisioningPlugin; use OCA\DAV\SystemTag\SystemTagPlugin; use OCA\DAV\Upload\ChunkingPlugin; +use OCA\DAV\Upload\ChunkingV2Plugin; +use OCA\DAV\Upload\UploadAutoMkcolPlugin; +use OCA\Theming\ThemingDefaults; +use OCP\Accounts\IAccountManager; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Comments\ICommentsManager; +use OCP\Defaults; +use OCP\Diagnostics\IEventLogger; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\IFilenameValidator; +use OCP\Files\IRootFolder; +use OCP\FilesMetadata\IFilesMetadataManager; +use OCP\IAppConfig; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IPreview; use OCP\IRequest; +use OCP\ISession; +use OCP\ITagManager; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Mail\IMailer; +use OCP\Profiler\IProfiler; use OCP\SabrePluginEvent; +use OCP\Security\Bruteforce\IThrottler; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; +use Psr\Log\LoggerInterface; use Sabre\CardDAV\VCFExportPlugin; use Sabre\DAV\Auth\Plugin; use Sabre\DAV\UUIDUtil; use SearchDAV\DAV\SearchPlugin; class Server { - private IRequest $request; - private string $baseUri; public Connector\Sabre\Server $server; private IProfiler $profiler; - public function __construct(IRequest $request, string $baseUri) { - $this->profiler = \OC::$server->get(IProfiler::class); + public function __construct( + private IRequest $request, + private string $baseUri, + ) { + $debugEnabled = \OCP\Server::get(IConfig::class)->getSystemValue('debug', false); + $this->profiler = \OCP\Server::get(IProfiler::class); if ($this->profiler->isEnabled()) { /** @var IEventLogger $eventLogger */ - $eventLogger = \OC::$server->get(IEventLogger::class); + $eventLogger = \OCP\Server::get(IEventLogger::class); $eventLogger->start('runtime', 'DAV Runtime'); } - $this->request = $request; - $this->baseUri = $baseUri; - $logger = \OC::$server->getLogger(); - $dispatcher = \OC::$server->getEventDispatcher(); - /** @var IEventDispatcher $newDispatcher */ - $newDispatcher = \OC::$server->query(IEventDispatcher::class); + $logger = \OCP\Server::get(LoggerInterface::class); + $eventDispatcher = \OCP\Server::get(IEventDispatcher::class); $root = new RootCollection(); $this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root)); + $this->server->setLogger($logger); // Add maintenance plugin - $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\MaintenancePlugin(\OC::$server->getConfig(), \OC::$server->getL10N('dav'))); + $this->server->addPlugin(new MaintenancePlugin(\OCP\Server::get(IConfig::class), \OC::$server->getL10N('dav'))); + + $this->server->addPlugin(new AppleQuirksPlugin()); // Backends $authBackend = new Auth( - \OC::$server->getSession(), - \OC::$server->getUserSession(), - \OC::$server->getRequest(), - \OC::$server->getTwoFactorAuthManager(), - \OC::$server->getBruteForceThrottler() + \OCP\Server::get(ISession::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(IRequest::class), + \OCP\Server::get(\OC\Authentication\TwoFactorAuth\Manager::class), + \OCP\Server::get(IThrottler::class) ); // Set URL explicitly due to reverse-proxy situations @@ -122,7 +143,10 @@ class Server { $this->server->setBaseUri($this->baseUri); $this->server->addPlugin(new ProfilerPlugin($this->request)); - $this->server->addPlugin(new BlockLegacyClientPlugin(\OC::$server->getConfig())); + $this->server->addPlugin(new BlockLegacyClientPlugin( + \OCP\Server::get(IConfig::class), + \OCP\Server::get(ThemingDefaults::class), + )); $this->server->addPlugin(new AnonymousOptionsPlugin()); $authPlugin = new Plugin(); $authPlugin->addBackend(new PublicAuth()); @@ -130,29 +154,32 @@ class Server { // allow setup of additional auth backends $event = new SabrePluginEvent($this->server); - $dispatcher->dispatch('OCA\DAV\Connector\Sabre::authInit', $event); + $eventDispatcher->dispatch('OCA\DAV\Connector\Sabre::authInit', $event); $newAuthEvent = new SabrePluginAuthInitEvent($this->server); - $newDispatcher->dispatchTyped($newAuthEvent); + $eventDispatcher->dispatchTyped($newAuthEvent); $bearerAuthBackend = new BearerAuth( - \OC::$server->getUserSession(), - \OC::$server->getSession(), - \OC::$server->getRequest() + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(ISession::class), + \OCP\Server::get(IRequest::class), + \OCP\Server::get(IConfig::class), ); $authPlugin->addBackend($bearerAuthBackend); // because we are throwing exceptions this plugin has to be the last one $authPlugin->addBackend($authBackend); // debugging - if (\OC::$server->getConfig()->getSystemValue('debug', false)) { + if ($debugEnabled) { + $this->server->debugEnabled = true; + $this->server->addPlugin(new PropFindMonitorPlugin()); $this->server->addPlugin(new \Sabre\DAV\Browser\Plugin()); } else { $this->server->addPlugin(new DummyGetResponsePlugin()); } - $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin('webdav', $logger)); - $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\LockPlugin()); + $this->server->addPlugin(new ExceptionLoggerPlugin('webdav', $logger)); + $this->server->addPlugin(new LockPlugin()); $this->server->addPlugin(new \Sabre\DAV\Sync\Plugin()); // acl @@ -167,61 +194,69 @@ class Server { // calendar plugins if ($this->requestIsForSubtree(['calendars', 'public-calendars', 'system-calendars', 'principals'])) { + $this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class))); $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); - $this->server->addPlugin(new \OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin(\OC::$server->getConfig(), \OC::$server->getLogger())); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OC::$server->getConfig())); - if (\OC::$server->getConfig()->getAppValue('dav', 'sendInvitations', 'yes') === 'yes') { - $this->server->addPlugin(\OC::$server->query(\OCA\DAV\CalDAV\Schedule\IMipPlugin::class)); - } + $this->server->addPlugin(new ICSExportPlugin(\OCP\Server::get(IConfig::class), $logger)); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OCP\Server::get(IConfig::class), \OCP\Server::get(LoggerInterface::class), \OCP\Server::get(DefaultCalendarValidator::class))); - $this->server->addPlugin(\OC::$server->get(\OCA\DAV\CalDAV\Trashbin\Plugin::class)); - $this->server->addPlugin(new \OCA\DAV\CalDAV\WebcalCaching\Plugin($request)); - $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); + $this->server->addPlugin(\OCP\Server::get(\OCA\DAV\CalDAV\Trashbin\Plugin::class)); + $this->server->addPlugin(new \OCA\DAV\CalDAV\WebcalCaching\Plugin($this->request)); + if (\OCP\Server::get(IConfig::class)->getAppValue('dav', 'allow_calendar_link_subscriptions', 'yes') === 'yes') { + $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); + } $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin()); - $this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest(), \OC::$server->getConfig())); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Publishing\PublishPlugin( - \OC::$server->getConfig(), - \OC::$server->getURLGenerator() + $this->server->addPlugin(new PublishPlugin( + \OCP\Server::get(IConfig::class), + \OCP\Server::get(IURLGenerator::class) )); + + $this->server->addPlugin(\OCP\Server::get(RateLimitingPlugin::class)); + $this->server->addPlugin(\OCP\Server::get(CalDavValidatePlugin::class)); } // addressbook plugins if ($this->requestIsForSubtree(['addressbooks', 'principals'])) { - $this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest(), \OC::$server->getConfig())); + $this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class))); $this->server->addPlugin(new \OCA\DAV\CardDAV\Plugin()); $this->server->addPlugin(new VCFExportPlugin()); $this->server->addPlugin(new MultiGetExportPlugin()); $this->server->addPlugin(new HasPhotoPlugin()); - $this->server->addPlugin(new ImageExportPlugin(new PhotoCache( - \OC::$server->getAppDataDir('dav-photocache'), - \OC::$server->getLogger()) - )); + $this->server->addPlugin(new ImageExportPlugin(\OCP\Server::get(PhotoCache::class))); + + $this->server->addPlugin(\OCP\Server::get(CardDavRateLimitingPlugin::class)); + $this->server->addPlugin(\OCP\Server::get(CardDavValidatePlugin::class)); } // system tags plugins - $this->server->addPlugin(new SystemTagPlugin( - \OC::$server->getSystemTagManager(), - \OC::$server->getGroupManager(), - \OC::$server->getUserSession() - )); + $this->server->addPlugin(\OCP\Server::get(SystemTagPlugin::class)); // comments plugin $this->server->addPlugin(new CommentsPlugin( - \OC::$server->getCommentsManager(), - \OC::$server->getUserSession() + \OCP\Server::get(ICommentsManager::class), + \OCP\Server::get(IUserSession::class) )); $this->server->addPlugin(new CopyEtagHeaderPlugin()); - $this->server->addPlugin(new RequestIdHeaderPlugin(\OC::$server->get(IRequest::class))); + $this->server->addPlugin(new RequestIdHeaderPlugin(\OCP\Server::get(IRequest::class))); + $this->server->addPlugin(new UploadAutoMkcolPlugin()); + $this->server->addPlugin(new ChunkingV2Plugin(\OCP\Server::get(ICacheFactory::class))); $this->server->addPlugin(new ChunkingPlugin()); + $this->server->addPlugin(new ZipFolderPlugin( + $this->server->tree, + $logger, + $eventDispatcher, + )); + $this->server->addPlugin(\OCP\Server::get(PaginatePlugin::class)); // allow setup of additional plugins - $dispatcher->dispatch('OCA\DAV\Connector\Sabre::addPlugin', $event); + $eventDispatcher->dispatch('OCA\DAV\Connector\Sabre::addPlugin', $event); + $typedEvent = new SabrePluginAddEvent($this->server); + $eventDispatcher->dispatchTyped($typedEvent); // Some WebDAV clients do require Class 2 WebDAV support (locking), since // we do not provide locking we emulate it using a fake locking plugin. - if ($request->isUserAgent([ + if ($this->request->isUserAgent([ '/WebDAVFS/', '/OneNote/', '/^Microsoft-WebDAV/',// Microsoft-WebDAV-MiniRedir/6.1.7601 @@ -237,21 +272,29 @@ class Server { $this->server->addPlugin(new SearchPlugin($lazySearchBackend)); // wait with registering these until auth is handled and the filesystem is setup - $this->server->on('beforeMethod:*', function () use ($root, $lazySearchBackend) { + $this->server->on('beforeMethod:*', function () use ($root, $lazySearchBackend, $logger): void { + // Allow view-only plugin for webdav requests + $this->server->addPlugin(new ViewOnlyPlugin( + \OC::$server->getUserFolder(), + )); + // custom properties plugin must be the last one - $userSession = \OC::$server->getUserSession(); + $userSession = \OCP\Server::get(IUserSession::class); $user = $userSession->getUser(); if ($user !== null) { - $view = \OC\Files\Filesystem::getView(); + $view = Filesystem::getView(); + $config = \OCP\Server::get(IConfig::class); $this->server->addPlugin( new FilesPlugin( $this->server->tree, - \OC::$server->getConfig(), + $config, $this->request, - \OC::$server->getPreviewManager(), - \OC::$server->getUserSession(), + \OCP\Server::get(IPreview::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(IFilenameValidator::class), + \OCP\Server::get(IAccountManager::class), false, - !\OC::$server->getConfig()->getSystemValue('debug', false) + $config->getSystemValueBool('debug', false) === false, ) ); $this->server->addPlugin(new ChecksumUpdatePlugin()); @@ -259,9 +302,11 @@ class Server { $this->server->addPlugin( new \Sabre\DAV\PropertyStorage\Plugin( new CustomPropertiesBackend( + $this->server, $this->server->tree, - \OC::$server->getDatabaseConnection(), - \OC::$server->getUserSession()->getUser() + \OCP\Server::get(IDBConnection::class), + \OCP\Server::get(IUserSession::class)->getUser(), + \OCP\Server::get(DefaultCalendarValidator::class), ) ) ); @@ -271,55 +316,75 @@ class Server { } $this->server->addPlugin( new TagsPlugin( - $this->server->tree, \OC::$server->getTagManager() + $this->server->tree, \OCP\Server::get(ITagManager::class), \OCP\Server::get(IEventDispatcher::class), \OCP\Server::get(IUserSession::class) ) ); + // TODO: switch to LazyUserFolder $userFolder = \OC::$server->getUserFolder(); + $shareManager = \OCP\Server::get(\OCP\Share\IManager::class); $this->server->addPlugin(new SharesPlugin( $this->server->tree, $userSession, $userFolder, - \OC::$server->getShareManager() + $shareManager, )); $this->server->addPlugin(new CommentPropertiesPlugin( - \OC::$server->getCommentsManager(), + \OCP\Server::get(ICommentsManager::class), $userSession )); + if (\OCP\Server::get(IConfig::class)->getAppValue('dav', 'sendInvitations', 'yes') === 'yes') { + $this->server->addPlugin(new IMipPlugin( + \OCP\Server::get(IAppConfig::class), + \OCP\Server::get(IMailer::class), + \OCP\Server::get(LoggerInterface::class), + \OCP\Server::get(ITimeFactory::class), + \OCP\Server::get(Defaults::class), + $userSession, + \OCP\Server::get(IMipService::class), + \OCP\Server::get(EventComparisonService::class), + \OCP\Server::get(\OCP\Mail\Provider\IManager::class) + )); + } $this->server->addPlugin(new \OCA\DAV\CalDAV\Search\SearchPlugin()); if ($view !== null) { $this->server->addPlugin(new FilesReportPlugin( $this->server->tree, $view, - \OC::$server->getSystemTagManager(), - \OC::$server->getSystemTagObjectMapper(), - \OC::$server->getTagManager(), + \OCP\Server::get(ISystemTagManager::class), + \OCP\Server::get(ISystemTagObjectMapper::class), + \OCP\Server::get(ITagManager::class), $userSession, - \OC::$server->getGroupManager(), + \OCP\Server::get(IGroupManager::class), $userFolder, - \OC::$server->getAppManager() + \OCP\Server::get(IAppManager::class) )); - $lazySearchBackend->setBackend(new \OCA\DAV\Files\FileSearchBackend( + $lazySearchBackend->setBackend(new FileSearchBackend( + $this->server, $this->server->tree, $user, - \OC::$server->getRootFolder(), - \OC::$server->getShareManager(), - $view + \OCP\Server::get(IRootFolder::class), + $shareManager, + $view, + \OCP\Server::get(IFilesMetadataManager::class) )); - $logger = \OC::$server->get(LoggerInterface::class); $this->server->addPlugin( - new BulkUploadPlugin($userFolder, $logger) + new BulkUploadPlugin( + $userFolder, + $logger + ) ); } - $this->server->addPlugin(new \OCA\DAV\CalDAV\BirthdayCalendar\EnablePlugin( - \OC::$server->getConfig(), - \OC::$server->query(BirthdayService::class) + $this->server->addPlugin(new EnablePlugin( + \OCP\Server::get(IConfig::class), + \OCP\Server::get(BirthdayService::class), + $user )); $this->server->addPlugin(new AppleProvisioningPlugin( - \OC::$server->getUserSession(), - \OC::$server->getURLGenerator(), - \OC::$server->getThemingDefaults(), - \OC::$server->getRequest(), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(IURLGenerator::class), + \OCP\Server::get(ThemingDefaults::class), + \OCP\Server::get(IRequest::class), \OC::$server->getL10N('dav'), function () { return UUIDUtil::getUUID(); @@ -330,7 +395,7 @@ class Server { // register plugins from apps $pluginManager = new PluginManager( \OC::$server, - \OC::$server->getAppManager() + \OCP\Server::get(IAppManager::class) ); foreach ($pluginManager->getAppPlugins() as $appPlugin) { $this->server->addPlugin($appPlugin); @@ -347,13 +412,13 @@ class Server { public function exec() { /** @var IEventLogger $eventLogger */ - $eventLogger = \OC::$server->get(IEventLogger::class); + $eventLogger = \OCP\Server::get(IEventLogger::class); $eventLogger->start('dav_server_exec', ''); - $this->server->exec(); + $this->server->start(); $eventLogger->end('dav_server_exec'); if ($this->profiler->isEnabled()) { $eventLogger->end('runtime'); - $profile = $this->profiler->collect(\OC::$server->get(IRequest::class), new Response()); + $profile = $this->profiler->collect(\OCP\Server::get(IRequest::class), new Response()); $this->profiler->saveProfile($profile); } } @@ -361,10 +426,11 @@ class Server { private function requestIsForSubtree(array $subTrees): bool { foreach ($subTrees as $subTree) { $subTree = trim($subTree, ' /'); - if (strpos($this->server->getRequestUri(), $subTree.'/') === 0) { + if (str_starts_with($this->server->getRequestUri(), $subTree . '/')) { return true; } } return false; } + } diff --git a/apps/dav/lib/ServerFactory.php b/apps/dav/lib/ServerFactory.php new file mode 100644 index 00000000000..f632ee6015d --- /dev/null +++ b/apps/dav/lib/ServerFactory.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV; + +use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; +use OCA\DAV\Connector\Sabre\Server; + +class ServerFactory { + + public function createInviationResponseServer(bool $public): InvitationResponseServer { + return new InvitationResponseServer(false); + } + + public function createAttendeeAvailabilityServer(): Server { + return (new InvitationResponseServer(false))->getServer(); + } +} diff --git a/apps/dav/lib/Service/AbsenceService.php b/apps/dav/lib/Service/AbsenceService.php new file mode 100644 index 00000000000..7cbc0386d43 --- /dev/null +++ b/apps/dav/lib/Service/AbsenceService.php @@ -0,0 +1,155 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Service; + +use InvalidArgumentException; +use OCA\DAV\BackgroundJob\OutOfOfficeEventDispatcherJob; +use OCA\DAV\CalDAV\TimezoneService; +use OCA\DAV\Db\Absence; +use OCA\DAV\Db\AbsenceMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUser; +use OCP\User\Events\OutOfOfficeChangedEvent; +use OCP\User\Events\OutOfOfficeClearedEvent; +use OCP\User\Events\OutOfOfficeScheduledEvent; +use OCP\User\IOutOfOfficeData; + +class AbsenceService { + public function __construct( + private AbsenceMapper $absenceMapper, + private IEventDispatcher $eventDispatcher, + private IJobList $jobList, + private TimezoneService $timezoneService, + private ITimeFactory $timeFactory, + ) { + } + + /** + * @param string $firstDay The first day (inclusive) of the absence formatted as YYYY-MM-DD. + * @param string $lastDay The last day (inclusive) of the absence formatted as YYYY-MM-DD. + * + * @throws \OCP\DB\Exception + * @throws InvalidArgumentException If no user with the given user id exists. + */ + public function createOrUpdateAbsence( + IUser $user, + string $firstDay, + string $lastDay, + string $status, + string $message, + ?string $replacementUserId = null, + ?string $replacementUserDisplayName = null, + ): Absence { + try { + $absence = $this->absenceMapper->findByUserId($user->getUID()); + } catch (DoesNotExistException) { + $absence = new Absence(); + } + + $absence->setUserId($user->getUID()); + $absence->setFirstDay($firstDay); + $absence->setLastDay($lastDay); + $absence->setStatus($status); + $absence->setMessage($message); + $absence->setReplacementUserId($replacementUserId); + $absence->setReplacementUserDisplayName($replacementUserDisplayName); + + if ($absence->getId() === null) { + $absence = $this->absenceMapper->insert($absence); + $eventData = $absence->toOutOufOfficeData( + $user, + $this->timezoneService->getUserTimezone($user->getUID()) ?? $this->timezoneService->getDefaultTimezone(), + ); + $this->eventDispatcher->dispatchTyped(new OutOfOfficeScheduledEvent($eventData)); + } else { + $absence = $this->absenceMapper->update($absence); + $eventData = $absence->toOutOufOfficeData( + $user, + $this->timezoneService->getUserTimezone($user->getUID()) ?? $this->timezoneService->getDefaultTimezone(), + ); + $this->eventDispatcher->dispatchTyped(new OutOfOfficeChangedEvent($eventData)); + } + + $now = $this->timeFactory->getTime(); + if ($eventData->getStartDate() > $now) { + $this->jobList->scheduleAfter( + OutOfOfficeEventDispatcherJob::class, + $eventData->getStartDate(), + [ + 'id' => $absence->getId(), + 'event' => OutOfOfficeEventDispatcherJob::EVENT_START, + ], + ); + } + if ($eventData->getEndDate() > $now) { + $this->jobList->scheduleAfter( + OutOfOfficeEventDispatcherJob::class, + $eventData->getEndDate(), + [ + 'id' => $absence->getId(), + 'event' => OutOfOfficeEventDispatcherJob::EVENT_END, + ], + ); + } + + return $absence; + } + + /** + * @throws \OCP\DB\Exception + */ + public function clearAbsence(IUser $user): void { + try { + $absence = $this->absenceMapper->findByUserId($user->getUID()); + } catch (DoesNotExistException $e) { + // Nothing to clear + return; + } + $this->absenceMapper->delete($absence); + $this->jobList->remove(OutOfOfficeEventDispatcherJob::class); + $eventData = $absence->toOutOufOfficeData( + $user, + $this->timezoneService->getUserTimezone($user->getUID()) ?? $this->timezoneService->getDefaultTimezone(), + ); + $this->eventDispatcher->dispatchTyped(new OutOfOfficeClearedEvent($eventData)); + } + + public function getAbsence(string $userId): ?Absence { + try { + return $this->absenceMapper->findByUserId($userId); + } catch (DoesNotExistException $e) { + return null; + } + } + + public function getCurrentAbsence(IUser $user): ?IOutOfOfficeData { + try { + $absence = $this->absenceMapper->findByUserId($user->getUID()); + $oooData = $absence->toOutOufOfficeData( + $user, + $this->timezoneService->getUserTimezone($user->getUID()) ?? $this->timezoneService->getDefaultTimezone(), + ); + if ($this->isInEffect($oooData)) { + return $oooData; + } + } catch (DoesNotExistException) { + // Nothing there to process + } + return null; + } + + public function isInEffect(IOutOfOfficeData $absence): bool { + $now = $this->timeFactory->getTime(); + return $absence->getStartDate() <= $now && $absence->getEndDate() >= $now; + } +} diff --git a/apps/dav/lib/Service/ExampleContactService.php b/apps/dav/lib/Service/ExampleContactService.php new file mode 100644 index 00000000000..6ed6c66cbb3 --- /dev/null +++ b/apps/dav/lib/Service/ExampleContactService.php @@ -0,0 +1,132 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Service; + +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CardDAV\CardDavBackend; +use OCP\AppFramework\Services\IAppConfig; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; + +class ExampleContactService { + private readonly IAppData $appData; + + public function __construct( + IAppDataFactory $appDataFactory, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + private readonly CardDavBackend $cardDav, + ) { + $this->appData = $appDataFactory->get(Application::APP_ID); + } + + public function isDefaultContactEnabled(): bool { + return $this->appConfig->getAppValueBool('enableDefaultContact', true); + } + + public function setDefaultContactEnabled(bool $value): void { + $this->appConfig->setAppValueBool('enableDefaultContact', $value); + } + + public function getCard(): ?string { + try { + $folder = $this->appData->getFolder('defaultContact'); + } catch (NotFoundException $e) { + return null; + } + + if (!$folder->fileExists('defaultContact.vcf')) { + return null; + } + + return $folder->getFile('defaultContact.vcf')->getContent(); + } + + public function setCard(?string $cardData = null) { + try { + $folder = $this->appData->getFolder('defaultContact'); + } catch (NotFoundException $e) { + $folder = $this->appData->newFolder('defaultContact'); + } + + $isCustom = true; + if (is_null($cardData)) { + $cardData = file_get_contents(__DIR__ . '/../ExampleContentFiles/exampleContact.vcf'); + $isCustom = false; + } + + if (!$cardData) { + throw new \Exception('Could not read exampleContact.vcf'); + } + + $file = (!$folder->fileExists('defaultContact.vcf')) ? $folder->newFile('defaultContact.vcf') : $folder->getFile('defaultContact.vcf'); + $file->putContent($cardData); + + $this->appConfig->setAppValueBool('hasCustomDefaultContact', $isCustom); + } + + public function defaultContactExists(): bool { + try { + $folder = $this->appData->getFolder('defaultContact'); + } catch (NotFoundException $e) { + return false; + } + return $folder->fileExists('defaultContact.vcf'); + } + + public function createDefaultContact(int $addressBookId): void { + if (!$this->isDefaultContactEnabled()) { + return; + } + + try { + $folder = $this->appData->getFolder('defaultContact'); + $defaultContactFile = $folder->getFile('defaultContact.vcf'); + $data = $defaultContactFile->getContent(); + } catch (\Exception $e) { + $this->logger->error('Couldn\'t get default contact file', ['exception' => $e]); + return; + } + + // Make sure the UID is unique + $newUid = Uuid::v4()->toRfc4122(); + $newRev = date('Ymd\THis\Z'); + $vcard = \Sabre\VObject\Reader::read($data, \Sabre\VObject\Reader::OPTION_FORGIVING); + if ($vcard->UID) { + $vcard->UID->setValue($newUid); + } else { + $vcard->add('UID', $newUid); + } + if ($vcard->REV) { + $vcard->REV->setValue($newRev); + } else { + $vcard->add('REV', $newRev); + } + + // Level 3 means that the document is invalid + // https://sabre.io/vobject/vcard/#validating-vcard + $level3Warnings = array_filter($vcard->validate(), static function ($warning) { + return $warning['level'] === 3; + }); + + if (!empty($level3Warnings)) { + $this->logger->error('Default contact is invalid', ['warnings' => $level3Warnings]); + return; + } + try { + $this->cardDav->createCard($addressBookId, 'default', $vcard->serialize(), false); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } +} diff --git a/apps/dav/lib/Service/ExampleEventService.php b/apps/dav/lib/Service/ExampleEventService.php new file mode 100644 index 00000000000..3b2b07fe416 --- /dev/null +++ b/apps/dav/lib/Service/ExampleEventService.php @@ -0,0 +1,205 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Service; + +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\Exception\ExampleEventException; +use OCA\DAV\Model\ExampleEvent; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IAppConfig; +use OCP\IL10N; +use OCP\Security\ISecureRandom; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; + +class ExampleEventService { + private const FOLDER_NAME = 'example_event'; + private const FILE_NAME = 'example_event.ics'; + private const ENABLE_CONFIG_KEY = 'create_example_event'; + + public function __construct( + private readonly CalDavBackend $calDavBackend, + private readonly ISecureRandom $random, + private readonly ITimeFactory $time, + private readonly IAppData $appData, + private readonly IAppConfig $appConfig, + private readonly IL10N $l10n, + ) { + } + + public function createExampleEvent(int $calendarId): void { + if (!$this->shouldCreateExampleEvent()) { + return; + } + + $exampleEvent = $this->getExampleEvent(); + $uid = $exampleEvent->getUid(); + $this->calDavBackend->createCalendarObject( + $calendarId, + "$uid.ics", + $exampleEvent->getIcs(), + ); + } + + private function getStartDate(): \DateTimeInterface { + return $this->time->now() + ->add(new \DateInterval('P7D')) + ->setTime(10, 00); + } + + private function getEndDate(): \DateTimeInterface { + return $this->time->now() + ->add(new \DateInterval('P7D')) + ->setTime(11, 00); + } + + private function getDefaultEvent(string $uid): VCalendar { + $defaultDescription = $this->l10n->t(<<<EOF +Welcome to Nextcloud Calendar! + +This is a sample event - explore the flexibility of planning with Nextcloud Calendar by making any edits you want! + +With Nextcloud Calendar, you can: +- Create, edit, and manage events effortlessly. +- Create multiple calendars and share them with teammates, friends, or family. +- Check availability and display your busy times to others. +- Seamlessly integrate with apps and devices via CalDAV. +- Customize your experience: schedule recurring events, adjust notifications and other settings. +EOF); + + $vCalendar = new VCalendar(); + $props = [ + 'UID' => $uid, + 'DTSTAMP' => $this->time->now(), + 'SUMMARY' => $this->l10n->t('Example event - open me!'), + 'DTSTART' => $this->getStartDate(), + 'DTEND' => $this->getEndDate(), + 'DESCRIPTION' => $defaultDescription, + ]; + $vCalendar->add('VEVENT', $props); + return $vCalendar; + } + + /** + * @return string|null The ics of the custom example event or null if no custom event was uploaded. + * @throws ExampleEventException If reading the custom ics file fails. + */ + private function getCustomExampleEvent(): ?string { + try { + $folder = $this->appData->getFolder(self::FOLDER_NAME); + $icsFile = $folder->getFile(self::FILE_NAME); + } catch (NotFoundException $e) { + return null; + } + + try { + return $icsFile->getContent(); + } catch (NotFoundException|NotPermittedException $e) { + throw new ExampleEventException( + 'Failed to read custom example event', + 0, + $e, + ); + } + } + + /** + * Get the configured example event or the default one. + * + * @throws ExampleEventException If loading the custom example event fails. + */ + public function getExampleEvent(): ExampleEvent { + $uid = $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC); + $customIcs = $this->getCustomExampleEvent(); + if ($customIcs === null) { + return new ExampleEvent($this->getDefaultEvent($uid), $uid); + } + + [$vCalendar, $vEvent] = $this->parseEvent($customIcs); + $vEvent->UID = $uid; + $vEvent->DTSTART = $this->getStartDate(); + $vEvent->DTEND = $this->getEndDate(); + $vEvent->remove('ORGANIZER'); + $vEvent->remove('ATTENDEE'); + return new ExampleEvent($vCalendar, $uid); + } + + /** + * @psalm-return list{VCalendar, VEvent} The VCALENDAR document and its VEVENT child component + * @throws ExampleEventException If parsing the event fails or if it is invalid. + */ + private function parseEvent(string $ics): array { + try { + $vCalendar = \Sabre\VObject\Reader::read($ics); + if (!($vCalendar instanceof VCalendar)) { + throw new ExampleEventException('Custom event does not contain a VCALENDAR component'); + } + + /** @var VEvent|null $vEvent */ + $vEvent = $vCalendar->getBaseComponent('VEVENT'); + if ($vEvent === null) { + throw new ExampleEventException('Custom event does not contain a VEVENT component'); + } + } catch (\Exception $e) { + throw new ExampleEventException('Failed to parse custom event: ' . $e->getMessage(), 0, $e); + } + + return [$vCalendar, $vEvent]; + } + + public function saveCustomExampleEvent(string $ics): void { + // Parse and validate the event before attempting to save it to prevent run time errors + $this->parseEvent($ics); + + try { + $folder = $this->appData->getFolder(self::FOLDER_NAME); + } catch (NotFoundException $e) { + $folder = $this->appData->newFolder(self::FOLDER_NAME); + } + + try { + $existingFile = $folder->getFile(self::FILE_NAME); + $existingFile->putContent($ics); + } catch (NotFoundException $e) { + $folder->newFile(self::FILE_NAME, $ics); + } + } + + public function deleteCustomExampleEvent(): void { + try { + $folder = $this->appData->getFolder(self::FOLDER_NAME); + $file = $folder->getFile(self::FILE_NAME); + } catch (NotFoundException $e) { + return; + } + + $file->delete(); + } + + public function hasCustomExampleEvent(): bool { + try { + return $this->getCustomExampleEvent() !== null; + } catch (ExampleEventException $e) { + return false; + } + } + + public function setCreateExampleEvent(bool $enable): void { + $this->appConfig->setValueBool(Application::APP_ID, self::ENABLE_CONFIG_KEY, $enable); + } + + public function shouldCreateExampleEvent(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, self::ENABLE_CONFIG_KEY, true); + } +} diff --git a/apps/dav/lib/Settings/Admin/SystemAddressBookSettings.php b/apps/dav/lib/Settings/Admin/SystemAddressBookSettings.php new file mode 100644 index 00000000000..2f7b9f8fcc9 --- /dev/null +++ b/apps/dav/lib/Settings/Admin/SystemAddressBookSettings.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Settings\Admin; + +use OCP\IL10N; +use OCP\Settings\DeclarativeSettingsTypes; +use OCP\Settings\IDeclarativeSettingsForm; + +class SystemAddressBookSettings implements IDeclarativeSettingsForm { + + public function __construct( + private IL10N $l, + ) { + } + + public function getSchema(): array { + return [ + 'id' => 'dav-admin-system-address-book', + 'priority' => 10, + 'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN, + 'section_id' => 'groupware', + 'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL, + 'title' => $this->l->t('System Address Book'), + 'description' => $this->l->t('The system address book contains contact information for all users in your instance.'), + + 'fields' => [ + [ + 'id' => 'system_addressbook_enabled', + 'title' => $this->l->t('Enable System Address Book'), + 'type' => DeclarativeSettingsTypes::CHECKBOX, + 'default' => false, + 'options' => [], + ], + ], + ]; + } + +} diff --git a/apps/dav/lib/Settings/AvailabilitySettings.php b/apps/dav/lib/Settings/AvailabilitySettings.php index 9a163e21edb..a1ada96b255 100644 --- a/apps/dav/lib/Settings/AvailabilitySettings.php +++ b/apps/dav/lib/Settings/AvailabilitySettings.php @@ -2,40 +2,65 @@ declare(strict_types=1); -/* - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Settings; use OCA\DAV\AppInfo\Application; +use OCA\DAV\Db\AbsenceMapper; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\IConfig; use OCP\Settings\ISettings; +use OCP\User\IAvailabilityCoordinator; +use Psr\Log\LoggerInterface; class AvailabilitySettings implements ISettings { + public function __construct( + protected IConfig $config, + protected IInitialState $initialState, + protected ?string $userId, + private LoggerInterface $logger, + private IAvailabilityCoordinator $coordinator, + private AbsenceMapper $absenceMapper, + ) { + } + public function getForm(): TemplateResponse { + $this->initialState->provideInitialState( + 'user_status_automation', + $this->config->getUserValue( + $this->userId, + 'dav', + 'user_status_automation', + 'no' + ) + ); + $hideAbsenceSettings = !$this->coordinator->isEnabled(); + $this->initialState->provideInitialState('hide_absence_settings', $hideAbsenceSettings); + if (!$hideAbsenceSettings) { + try { + $absence = $this->absenceMapper->findByUserId($this->userId); + $this->initialState->provideInitialState('absence', $absence); + } catch (DoesNotExistException) { + // The user has not yet set up an absence period. + // Logging this error is not necessary. + } catch (\OCP\DB\Exception $e) { + $this->logger->error("Could not find absence data for user $this->userId: " . $e->getMessage(), [ + 'exception' => $e, + ]); + } + } + return new TemplateResponse(Application::APP_ID, 'settings-personal-availability'); } public function getSection(): string { - return 'groupware'; + return 'availability'; } public function getPriority(): int { diff --git a/apps/dav/lib/Settings/CalDAVSettings.php b/apps/dav/lib/Settings/CalDAVSettings.php index 6d60b2611e0..5e19539a899 100644 --- a/apps/dav/lib/Settings/CalDAVSettings.php +++ b/apps/dav/lib/Settings/CalDAVSettings.php @@ -1,53 +1,27 @@ <?php + /** - * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author François Freitag <mail@franek.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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Settings; use OCA\DAV\AppInfo\Application; +use OCP\App\IAppManager; use OCP\AppFramework\Http\TemplateResponse; -use OCP\IConfig; use OCP\AppFramework\Services\IInitialState; +use OCP\IConfig; use OCP\IURLGenerator; use OCP\Settings\IDelegatedSettings; class CalDAVSettings implements IDelegatedSettings { - /** @var IConfig */ - private $config; - - /** @var IInitialState */ - private $initialState; - - private IURLGenerator $urlGenerator; - private const defaults = [ 'sendInvitations' => 'yes', 'generateBirthdayCalendar' => 'yes', 'sendEventReminders' => 'yes', - 'sendEventRemindersToSharedGroupMembers' => 'yes', - 'sendEventRemindersPush' => 'no', + 'sendEventRemindersToSharedUsers' => 'yes', + 'sendEventRemindersPush' => 'yes', ]; /** @@ -56,10 +30,12 @@ class CalDAVSettings implements IDelegatedSettings { * @param IConfig $config * @param IInitialState $initialState */ - public function __construct(IConfig $config, IInitialState $initialState, IURLGenerator $urlGenerator) { - $this->config = $config; - $this->initialState = $initialState; - $this->urlGenerator = $urlGenerator; + public function __construct( + private IConfig $config, + private IInitialState $initialState, + private IURLGenerator $urlGenerator, + private IAppManager $appManager, + ) { } public function getForm(): TemplateResponse { @@ -71,10 +47,11 @@ class CalDAVSettings implements IDelegatedSettings { return new TemplateResponse(Application::APP_ID, 'settings-admin-caldav'); } - /** - * @return string - */ - public function getSection() { + public function getSection(): ?string { + if (!$this->appManager->isBackendRequired(IAppManager::BACKEND_CALDAV)) { + return null; + } + return 'groupware'; } diff --git a/apps/dav/lib/Settings/ExampleContentSettings.php b/apps/dav/lib/Settings/ExampleContentSettings.php new file mode 100644 index 00000000000..7b6f9b03a3a --- /dev/null +++ b/apps/dav/lib/Settings/ExampleContentSettings.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Settings; + +use OCA\DAV\AppInfo\Application; +use OCA\DAV\Service\ExampleContactService; +use OCA\DAV\Service\ExampleEventService; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IAppConfig; +use OCP\AppFramework\Services\IInitialState; +use OCP\Settings\ISettings; + +class ExampleContentSettings implements ISettings { + public function __construct( + private readonly IAppConfig $appConfig, + private readonly IInitialState $initialState, + private readonly IAppManager $appManager, + private readonly ExampleEventService $exampleEventService, + private readonly ExampleContactService $exampleContactService, + ) { + } + + public function getForm(): TemplateResponse { + $calendarEnabled = $this->appManager->isEnabledForUser('calendar'); + $contactsEnabled = $this->appManager->isEnabledForUser('contacts'); + $this->initialState->provideInitialState('calendarEnabled', $calendarEnabled); + $this->initialState->provideInitialState('contactsEnabled', $contactsEnabled); + + if ($calendarEnabled) { + $enableDefaultEvent = $this->exampleEventService->shouldCreateExampleEvent(); + $this->initialState->provideInitialState('create_example_event', $enableDefaultEvent); + $this->initialState->provideInitialState( + 'has_custom_example_event', + $this->exampleEventService->hasCustomExampleEvent(), + ); + } + + if ($contactsEnabled) { + $this->initialState->provideInitialState( + 'enableDefaultContact', + $this->exampleContactService->isDefaultContactEnabled(), + ); + $this->initialState->provideInitialState( + 'hasCustomDefaultContact', + $this->appConfig->getAppValueBool('hasCustomDefaultContact'), + ); + } + + return new TemplateResponse(Application::APP_ID, 'settings-example-content'); + } + + public function getSection(): ?string { + if (!$this->appManager->isEnabledForUser('contacts') + && !$this->appManager->isEnabledForUser('calendar')) { + return null; + } + + return 'groupware'; + } + + public function getPriority(): int { + return 10; + } +} diff --git a/apps/dav/lib/SetupChecks/NeedsSystemAddressBookSync.php b/apps/dav/lib/SetupChecks/NeedsSystemAddressBookSync.php new file mode 100644 index 00000000000..c3f0742a640 --- /dev/null +++ b/apps/dav/lib/SetupChecks/NeedsSystemAddressBookSync.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\SetupChecks; + +use OCP\IConfig; +use OCP\IL10N; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; + +class NeedsSystemAddressBookSync implements ISetupCheck { + public function __construct( + private IConfig $config, + private IL10N $l10n, + ) { + } + + public function getName(): string { + return $this->l10n->t('DAV system address book'); + } + + public function getCategory(): string { + return 'dav'; + } + + public function run(): SetupResult { + if ($this->config->getAppValue('dav', 'needs_system_address_book_sync', 'no') === 'no') { + return SetupResult::success($this->l10n->t('No outstanding DAV system address book sync.')); + } else { + return SetupResult::warning($this->l10n->t('The DAV system address book sync has not run yet as your instance has more than 1000 users or because an error occurred. Please run it manually by calling "occ dav:sync-system-addressbook".')); + } + } +} diff --git a/apps/dav/lib/SetupChecks/WebdavEndpoint.php b/apps/dav/lib/SetupChecks/WebdavEndpoint.php new file mode 100644 index 00000000000..c2574202fcd --- /dev/null +++ b/apps/dav/lib/SetupChecks/WebdavEndpoint.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\SetupChecks; + +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\SetupCheck\CheckServerResponseTrait; +use OCP\SetupCheck\ISetupCheck; +use OCP\SetupCheck\SetupResult; +use Psr\Log\LoggerInterface; + +class WebdavEndpoint implements ISetupCheck { + + use CheckServerResponseTrait; + + public function __construct( + protected IL10N $l10n, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IClientService $clientService, + protected LoggerInterface $logger, + ) { + } + + public function getCategory(): string { + return 'network'; + } + + public function getName(): string { + return $this->l10n->t('WebDAV endpoint'); + } + + public function run(): SetupResult { + $urls = [ + ['propfind', '/remote.php/webdav', [207, 401]], + ]; + + foreach ($urls as [$verb,$url,$validStatuses]) { + $works = null; + foreach ($this->runRequest($verb, $url, ['httpErrors' => false]) as $response) { + // Check that the response status matches + $works = in_array($response->getStatusCode(), $validStatuses); + // Skip the other requests if one works + if ($works === true) { + break; + } + } + // If 'works' is null then we could not connect to the server + if ($works === null) { + return SetupResult::info( + $this->l10n->t('Could not check that your web server is properly set up to allow file synchronization over WebDAV. Please check manually.') . "\n" . $this->serverConfigHelp(), + $this->urlGenerator->linkToDocs('admin-setup-well-known-URL'), + ); + } + // Otherwise if we fail we can abort here + if ($works === false) { + return SetupResult::error( + $this->l10n->t('Your web server is not yet properly set up to allow file synchronization, because the WebDAV interface seems to be broken.') . "\n" . $this->serverConfigHelp(), + ); + } + } + return SetupResult::success( + $this->l10n->t('Your web server is properly set up to allow file synchronization over WebDAV.') + ); + } +} diff --git a/apps/dav/lib/Storage/PublicOwnerWrapper.php b/apps/dav/lib/Storage/PublicOwnerWrapper.php index 10bcd20de05..a0f1607d971 100644 --- a/apps/dav/lib/Storage/PublicOwnerWrapper.php +++ b/apps/dav/lib/Storage/PublicOwnerWrapper.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Storage; @@ -29,27 +12,25 @@ use OC\Files\Storage\Wrapper\Wrapper; class PublicOwnerWrapper extends Wrapper { - /** @var string */ - private $owner; + private string $owner; /** - * @param array $arguments ['storage' => $storage, 'owner' => $owner] + * @param array $parameters ['storage' => $storage, 'owner' => $owner] * * $storage: The storage the permissions mask should be applied on * $owner: The owner to use in case no owner is found */ - public function __construct($arguments) { - parent::__construct($arguments); - $this->owner = $arguments['owner']; + public function __construct(array $parameters) { + parent::__construct($parameters); + $this->owner = $parameters['owner']; } - public function getOwner($path) { + public function getOwner(string $path): string|false { $owner = parent::getOwner($path); - - if ($owner === null || $owner === false) { - return $this->owner; + if ($owner !== false) { + return $owner; } - return $owner; + return $this->owner; } } diff --git a/apps/dav/lib/Storage/PublicShareWrapper.php b/apps/dav/lib/Storage/PublicShareWrapper.php new file mode 100644 index 00000000000..fb0db4dca4c --- /dev/null +++ b/apps/dav/lib/Storage/PublicShareWrapper.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Storage; + +use OC\Files\Storage\Wrapper\Wrapper; +use OCP\Files\Storage\ISharedStorage; +use OCP\Share\IShare; + +class PublicShareWrapper extends Wrapper implements ISharedStorage { + + private IShare $share; + + /** + * @param array $parameters ['storage' => $storage, 'share' => $share] + * + * $storage: The storage the permissions mask should be applied on + * $share: The share to use in case no share is found + */ + public function __construct(array $parameters) { + parent::__construct($parameters); + $this->share = $parameters['share']; + } + + public function getShare(): IShare { + $storage = parent::getWrapperStorage(); + if (method_exists($storage, 'getShare')) { + /** @var ISharedStorage $storage */ + return $storage->getShare(); + } + + return $this->share; + } +} diff --git a/apps/dav/lib/SystemTag/SystemTagList.php b/apps/dav/lib/SystemTag/SystemTagList.php new file mode 100644 index 00000000000..b55f10164d7 --- /dev/null +++ b/apps/dav/lib/SystemTag/SystemTagList.php @@ -0,0 +1,64 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\SystemTag; + +use OCP\IUser; +use OCP\SystemTag\ISystemTag; +use OCP\SystemTag\ISystemTagManager; +use Sabre\Xml\Element; +use Sabre\Xml\Reader; +use Sabre\Xml\Writer; + +/** + * TagList property + * + * This property contains multiple "tag" elements, each containing a tag name. + */ +class SystemTagList implements Element { + public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; + private array $canAssignTagMap = []; + + /** + * @param ISystemTag[] $tags + */ + public function __construct( + private array $tags, + ISystemTagManager $tagManager, + ?IUser $user, + ) { + $this->tags = $tags; + foreach ($this->tags as $tag) { + $this->canAssignTagMap[$tag->getId()] = $tagManager->canUserAssignTag($tag, $user); + } + } + + /** + * @return ISystemTag[] + */ + public function getTags(): array { + return $this->tags; + } + + public static function xmlDeserialize(Reader $reader): void { + // unsupported/unused + } + + public function xmlSerialize(Writer $writer): void { + foreach ($this->tags as $tag) { + $writer->startElement('{' . self::NS_NEXTCLOUD . '}system-tag'); + $writer->writeAttributes([ + SystemTagPlugin::CANASSIGN_PROPERTYNAME => $this->canAssignTagMap[$tag->getId()] ? 'true' : 'false', + SystemTagPlugin::ID_PROPERTYNAME => $tag->getId(), + SystemTagPlugin::USERASSIGNABLE_PROPERTYNAME => $tag->isUserAssignable() ? 'true' : 'false', + SystemTagPlugin::USERVISIBLE_PROPERTYNAME => $tag->isUserVisible() ? 'true' : 'false', + SystemTagPlugin::COLOR_PROPERTYNAME => $tag->getColor() ?? '', + ]); + $writer->write($tag->getName()); + $writer->endElement(); + } + } +} diff --git a/apps/dav/lib/SystemTag/SystemTagMappingNode.php b/apps/dav/lib/SystemTag/SystemTagMappingNode.php index 344ff1dbc70..12d604a7d6e 100644 --- a/apps/dav/lib/SystemTag/SystemTagMappingNode.php +++ b/apps/dav/lib/SystemTag/SystemTagMappingNode.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\SystemTag; @@ -37,62 +21,15 @@ use Sabre\DAV\Exception\NotFound; * Mapping node for system tag to object id */ class SystemTagMappingNode implements \Sabre\DAV\INode { - /** - * @var ISystemTag - */ - protected $tag; - - /** - * @var string - */ - private $objectId; - - /** - * @var string - */ - private $objectType; - - /** - * User - * - * @var IUser - */ - protected $user; - - /** - * @var ISystemTagManager - */ - protected $tagManager; - - /** - * @var ISystemTagObjectMapper - */ - private $tagMapper; - - /** - * Sets up the node, expects a full path name - * - * @param ISystemTag $tag system tag - * @param string $objectId - * @param string $objectType - * @param IUser $user user - * @param ISystemTagManager $tagManager - * @param ISystemTagObjectMapper $tagMapper - */ public function __construct( - ISystemTag $tag, - $objectId, - $objectType, - IUser $user, - ISystemTagManager $tagManager, - ISystemTagObjectMapper $tagMapper + private ISystemTag $tag, + private string $objectId, + private string $objectType, + private IUser $user, + private ISystemTagManager $tagManager, + private ISystemTagObjectMapper $tagMapper, + private \Closure $childWriteAccessFunction, ) { - $this->tag = $tag; - $this->objectId = $objectId; - $this->objectType = $objectType; - $this->user = $user; - $this->tagManager = $tagManager; - $this->tagMapper = $tagMapper; } /** @@ -137,6 +74,8 @@ class SystemTagMappingNode implements \Sabre\DAV\INode { * @param string $name The new name * * @throws MethodNotAllowed not allowed to rename node + * + * @return never */ public function setName($name) { throw new MethodNotAllowed(); @@ -145,6 +84,7 @@ class SystemTagMappingNode implements \Sabre\DAV\INode { /** * Returns null, not supported * + * @return null */ public function getLastModified() { return null; @@ -152,6 +92,8 @@ class SystemTagMappingNode implements \Sabre\DAV\INode { /** * Delete tag to object association + * + * @return void */ public function delete() { try { @@ -161,6 +103,10 @@ class SystemTagMappingNode implements \Sabre\DAV\INode { if (!$this->tagManager->canUserAssignTag($this->tag, $this->user)) { throw new Forbidden('No permission to unassign tag ' . $this->tag->getId()); } + $writeAccessFunction = $this->childWriteAccessFunction; + if (!$writeAccessFunction($this->objectId)) { + throw new Forbidden('No permission to unassign tag to ' . $this->objectId); + } $this->tagMapper->unassignTags($this->objectId, $this->objectType, $this->tag->getId()); } catch (TagNotFoundException $e) { // can happen if concurrent deletion occurred diff --git a/apps/dav/lib/SystemTag/SystemTagNode.php b/apps/dav/lib/SystemTag/SystemTagNode.php index a31deb59a93..2341d4823ba 100644 --- a/apps/dav/lib/SystemTag/SystemTagNode.php +++ b/apps/dav/lib/SystemTag/SystemTagNode.php @@ -1,35 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\SystemTag; use OCP\IUser; use OCP\SystemTag\ISystemTag; use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; use OCP\SystemTag\TagAlreadyExistsException; - use OCP\SystemTag\TagNotFoundException; +use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Conflict; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\MethodNotAllowed; @@ -38,31 +22,10 @@ use Sabre\DAV\Exception\NotFound; /** * DAV node representing a system tag, with the name being the tag id. */ -class SystemTagNode implements \Sabre\DAV\INode { - - /** - * @var ISystemTag - */ - protected $tag; +class SystemTagNode implements \Sabre\DAV\ICollection { - /** - * @var ISystemTagManager - */ - protected $tagManager; - - /** - * User - * - * @var IUser - */ - protected $user; - - /** - * Whether to allow permissions for admins - * - * @var bool - */ - protected $isAdmin; + protected int $numberOfFiles = -1; + protected int $referenceFileId = -1; /** * Sets up the node, expects a full path name @@ -72,11 +35,19 @@ class SystemTagNode implements \Sabre\DAV\INode { * @param bool $isAdmin whether to allow operations for admins * @param ISystemTagManager $tagManager tag manager */ - public function __construct(ISystemTag $tag, IUser $user, $isAdmin, ISystemTagManager $tagManager) { - $this->tag = $tag; - $this->user = $user; - $this->isAdmin = $isAdmin; - $this->tagManager = $tagManager; + public function __construct( + protected ISystemTag $tag, + /** + * User + */ + protected IUser $user, + /** + * Whether to allow permissions for admins + */ + protected bool $isAdmin, + protected ISystemTagManager $tagManager, + protected ISystemTagObjectMapper $tagMapper, + ) { } /** @@ -103,6 +74,8 @@ class SystemTagNode implements \Sabre\DAV\INode { * @param string $name The new name * * @throws MethodNotAllowed not allowed to rename node + * + * @return never */ public function setName($name) { throw new MethodNotAllowed(); @@ -114,11 +87,13 @@ class SystemTagNode implements \Sabre\DAV\INode { * @param string $name new tag name * @param bool $userVisible user visible * @param bool $userAssignable user assignable + * @param string $color color + * * @throws NotFound whenever the given tag id does not exist * @throws Forbidden whenever there is no permission to update said tag * @throws Conflict whenever a tag already exists with the given attributes */ - public function update($name, $userVisible, $userAssignable) { + public function update($name, $userVisible, $userAssignable, $color): void { try { if (!$this->tagManager->canUserSeeTag($this->tag, $this->user)) { throw new NotFound('Tag with id ' . $this->tag->getId() . ' does not exist'); @@ -137,13 +112,18 @@ class SystemTagNode implements \Sabre\DAV\INode { } } - $this->tagManager->updateTag($this->tag->getId(), $name, $userVisible, $userAssignable); + // Make sure color is a proper hex + if ($color !== null && (strlen($color) !== 6 || !ctype_xdigit($color))) { + throw new BadRequest('Color must be a 6-digit hexadecimal value'); + } + + $this->tagManager->updateTag($this->tag->getId(), $name, $userVisible, $userAssignable, $color); } catch (TagNotFoundException $e) { throw new NotFound('Tag with id ' . $this->tag->getId() . ' does not exist'); } catch (TagAlreadyExistsException $e) { throw new Conflict( - 'Tag with the properties "' . $name . '", ' . - $userVisible . ', ' . $userAssignable . ' already exists' + 'Tag with the properties "' . $name . '", ' + . $userVisible . ', ' . $userAssignable . ' already exists' ); } } @@ -151,11 +131,15 @@ class SystemTagNode implements \Sabre\DAV\INode { /** * Returns null, not supported * + * @return null */ public function getLastModified() { return null; } + /** + * @return void + */ public function delete() { try { if (!$this->isAdmin) { @@ -172,4 +156,47 @@ class SystemTagNode implements \Sabre\DAV\INode { throw new NotFound('Tag with id ' . $this->tag->getId() . ' not found', 0, $e); } } + + public function getNumberOfFiles(): int { + return $this->numberOfFiles; + } + + public function setNumberOfFiles(int $numberOfFiles): void { + $this->numberOfFiles = $numberOfFiles; + } + + public function getReferenceFileId(): int { + return $this->referenceFileId; + } + + public function setReferenceFileId(int $referenceFileId): void { + $this->referenceFileId = $referenceFileId; + } + + public function createFile($name, $data = null) { + throw new MethodNotAllowed(); + } + + public function createDirectory($name) { + throw new MethodNotAllowed(); + } + + public function getChild($name) { + return new SystemTagObjectType($this->tag, $name, $this->tagManager, $this->tagMapper); + } + + public function childExists($name) { + $objectTypes = $this->tagMapper->getAvailableObjectTypes(); + return in_array($name, $objectTypes); + } + + public function getChildren() { + $objectTypes = $this->tagMapper->getAvailableObjectTypes(); + return array_map( + function ($objectType) { + return new SystemTagObjectType($this->tag, $objectType, $this->tagManager, $this->tagMapper); + }, + $objectTypes + ); + } } diff --git a/apps/dav/lib/SystemTag/SystemTagObjectType.php b/apps/dav/lib/SystemTag/SystemTagObjectType.php new file mode 100644 index 00000000000..0d348cd95f4 --- /dev/null +++ b/apps/dav/lib/SystemTag/SystemTagObjectType.php @@ -0,0 +1,82 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\SystemTag; + +use OCP\SystemTag\ISystemTag; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; +use Sabre\DAV\Exception\MethodNotAllowed; + +/** + * SystemTagObjectType property + * This property represent a type of object which tags are assigned to. + */ +class SystemTagObjectType implements \Sabre\DAV\IFile { + public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; + + /** @var string[] */ + private array $objectsIds = []; + + public function __construct( + private ISystemTag $tag, + private string $type, + private ISystemTagManager $tagManager, + private ISystemTagObjectMapper $tagMapper, + ) { + } + + /** + * Get the list of object ids that have this tag assigned. + */ + public function getObjectsIds(): array { + if (empty($this->objectsIds)) { + $this->objectsIds = $this->tagMapper->getObjectIdsForTags($this->tag->getId(), $this->type); + } + + return $this->objectsIds; + } + + /** + * Returns the system tag represented by this node + * + * @return ISystemTag system tag + */ + public function getSystemTag() { + return $this->tag; + } + + public function getName() { + return $this->type; + } + + public function getLastModified() { + return null; + } + + public function getETag() { + return '"' . $this->tag->getETag() . '"'; + } + + public function setName($name) { + throw new MethodNotAllowed(); + } + public function put($data) { + throw new MethodNotAllowed(); + } + public function get() { + throw new MethodNotAllowed(); + } + public function delete() { + throw new MethodNotAllowed(); + } + public function getContentType() { + throw new MethodNotAllowed(); + } + public function getSize() { + throw new MethodNotAllowed(); + } +} diff --git a/apps/dav/lib/SystemTag/SystemTagPlugin.php b/apps/dav/lib/SystemTag/SystemTagPlugin.php index b6bd7d3b7cd..4d4499c7559 100644 --- a/apps/dav/lib/SystemTag/SystemTagPlugin.php +++ b/apps/dav/lib/SystemTag/SystemTagPlugin.php @@ -1,35 +1,28 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\SystemTag; +use OCA\DAV\Connector\Sabre\Directory; +use OCA\DAV\Connector\Sabre\FilesPlugin; +use OCA\DAV\Connector\Sabre\Node; +use OCP\AppFramework\Http; +use OCP\Constants; +use OCP\Files\IRootFolder; use OCP\IGroupManager; +use OCP\IUser; use OCP\IUserSession; use OCP\SystemTag\ISystemTag; use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; use OCP\SystemTag\TagAlreadyExistsException; +use OCP\SystemTag\TagCreationForbiddenException; +use OCP\SystemTag\TagUpdateForbiddenException; +use OCP\Util; use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Conflict; use Sabre\DAV\Exception\Forbidden; @@ -50,44 +43,36 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { // namespace public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; public const ID_PROPERTYNAME = '{http://owncloud.org/ns}id'; public const DISPLAYNAME_PROPERTYNAME = '{http://owncloud.org/ns}display-name'; public const USERVISIBLE_PROPERTYNAME = '{http://owncloud.org/ns}user-visible'; public const USERASSIGNABLE_PROPERTYNAME = '{http://owncloud.org/ns}user-assignable'; public const GROUPS_PROPERTYNAME = '{http://owncloud.org/ns}groups'; public const CANASSIGN_PROPERTYNAME = '{http://owncloud.org/ns}can-assign'; + public const SYSTEM_TAGS_PROPERTYNAME = '{http://nextcloud.org/ns}system-tags'; + public const NUM_FILES_PROPERTYNAME = '{http://nextcloud.org/ns}files-assigned'; + public const REFERENCE_FILEID_PROPERTYNAME = '{http://nextcloud.org/ns}reference-fileid'; + public const OBJECTIDS_PROPERTYNAME = '{http://nextcloud.org/ns}object-ids'; + public const COLOR_PROPERTYNAME = '{http://nextcloud.org/ns}color'; /** * @var \Sabre\DAV\Server $server */ private $server; - /** - * @var ISystemTagManager - */ - protected $tagManager; - - /** - * @var IUserSession - */ - protected $userSession; - - /** - * @var IGroupManager - */ - protected $groupManager; - - /** - * @param ISystemTagManager $tagManager tag manager - * @param IGroupManager $groupManager - * @param IUserSession $userSession - */ - public function __construct(ISystemTagManager $tagManager, - IGroupManager $groupManager, - IUserSession $userSession) { - $this->tagManager = $tagManager; - $this->userSession = $userSession; - $this->groupManager = $groupManager; + /** @var array<int, string[]> */ + private array $cachedTagMappings = []; + /** @var array<string, ISystemTag> */ + private array $cachedTags = []; + + public function __construct( + protected ISystemTagManager $tagManager, + protected IGroupManager $groupManager, + protected IUserSession $userSession, + protected IRootFolder $rootFolder, + protected ISystemTagObjectMapper $tagMapper, + ) { } /** @@ -103,6 +88,9 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { */ public function initialize(\Sabre\DAV\Server $server) { $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; + $server->xml->namespaceMap[self::NS_NEXTCLOUD] = 'nc'; + + $server->xml->elementMap[self::OBJECTIDS_PROPERTYNAME] = SystemTagsObjectList::class; $server->protectedProperties[] = self::ID_PROPERTYNAME; @@ -145,7 +133,7 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { $response->setHeader('Content-Location', $url . $tag->getId()); // created - $response->setStatus(201); + $response->setStatus(Http::STATUS_CREATED); return false; } } @@ -163,7 +151,7 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { */ private function createTag($data, $contentType = 'application/json') { if (explode(';', $contentType)[0] === 'application/json') { - $data = json_decode($data, true); + $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); } else { throw new UnsupportedMediaType(); } @@ -206,6 +194,8 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { return $tag; } catch (TagAlreadyExistsException $e) { throw new Conflict('Tag already exists', 0, $e); + } catch (TagCreationForbiddenException $e) { + throw new Forbidden('You don’t have permissions to create tags', 0, $e); } } @@ -215,15 +205,31 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { * * @param PropFind $propFind * @param \Sabre\DAV\INode $node + * + * @return void */ public function handleGetProperties( PropFind $propFind, - \Sabre\DAV\INode $node + \Sabre\DAV\INode $node, ) { - if (!($node instanceof SystemTagNode) && !($node instanceof SystemTagMappingNode)) { + if ($node instanceof Node) { + $this->propfindForFile($propFind, $node); + return; + } + + if (!$node instanceof SystemTagNode && !$node instanceof SystemTagMappingNode && !$node instanceof SystemTagObjectType) { return; } + // child nodes from systemtags-assigned should point to normal tag endpoint + if (preg_match('/^systemtags-assigned\/[0-9]+/', $propFind->getPath())) { + $propFind->setPath(str_replace('systemtags-assigned/', 'systemtags/', $propFind->getPath())); + } + + $propFind->handle(FilesPlugin::GETETAG_PROPERTYNAME, function () use ($node): string { + return '"' . ($node->getSystemTag()->getETag() ?? '') . '"'; + }); + $propFind->handle(self::ID_PROPERTYNAME, function () use ($node) { return $node->getSystemTag()->getId(); }); @@ -246,6 +252,10 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { return $this->tagManager->canUserAssignTag($node->getSystemTag(), $this->userSession->getUser()) ? 'true' : 'false'; }); + $propFind->handle(self::COLOR_PROPERTYNAME, function () use ($node) { + return $node->getSystemTag()->getColor() ?? ''; + }); + $propFind->handle(self::GROUPS_PROPERTYNAME, function () use ($node) { if (!$this->groupManager->isAdmin($this->userSession->getUser()->getUID())) { // property only available for admins @@ -258,6 +268,106 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { } return implode('|', $groups); }); + + if ($node instanceof SystemTagNode) { + $propFind->handle(self::NUM_FILES_PROPERTYNAME, function () use ($node): int { + return $node->getNumberOfFiles(); + }); + + $propFind->handle(self::REFERENCE_FILEID_PROPERTYNAME, function () use ($node): int { + return $node->getReferenceFileId(); + }); + + $propFind->handle(self::OBJECTIDS_PROPERTYNAME, function () use ($node): SystemTagsObjectList { + $objectTypes = $this->tagMapper->getAvailableObjectTypes(); + $objects = []; + foreach ($objectTypes as $type) { + $systemTagObjectType = new SystemTagObjectType($node->getSystemTag(), $type, $this->tagManager, $this->tagMapper); + $objects = array_merge($objects, array_fill_keys($systemTagObjectType->getObjectsIds(), $type)); + } + return new SystemTagsObjectList($objects); + }); + } + + if ($node instanceof SystemTagObjectType) { + $propFind->handle(self::OBJECTIDS_PROPERTYNAME, function () use ($node): SystemTagsObjectList { + return new SystemTagsObjectList(array_fill_keys($node->getObjectsIds(), $node->getName())); + }); + } + } + + private function propfindForFile(PropFind $propFind, Node $node): void { + if ($node instanceof Directory + && $propFind->getDepth() !== 0 + && !is_null($propFind->getStatus(self::SYSTEM_TAGS_PROPERTYNAME))) { + $fileIds = [$node->getId()]; + + // note: pre-fetching only supported for depth <= 1 + $folderContent = $node->getChildren(); + foreach ($folderContent as $info) { + if ($info instanceof Node) { + $fileIds[] = $info->getId(); + } + } + + $tags = $this->tagMapper->getTagIdsForObjects($fileIds, 'files'); + + $this->cachedTagMappings = $this->cachedTagMappings + $tags; + $emptyFileIds = array_diff($fileIds, array_keys($tags)); + + // also cache the ones that were not found + foreach ($emptyFileIds as $fileId) { + $this->cachedTagMappings[$fileId] = []; + } + } + + $propFind->handle(self::SYSTEM_TAGS_PROPERTYNAME, function () use ($node) { + $user = $this->userSession->getUser(); + + $tags = $this->getTagsForFile($node->getId(), $user); + usort($tags, function (ISystemTag $tagA, ISystemTag $tagB): int { + return Util::naturalSortCompare($tagA->getName(), $tagB->getName()); + }); + return new SystemTagList($tags, $this->tagManager, $user); + }); + } + + /** + * @param int $fileId + * @return ISystemTag[] + */ + private function getTagsForFile(int $fileId, ?IUser $user): array { + if (isset($this->cachedTagMappings[$fileId])) { + $tagIds = $this->cachedTagMappings[$fileId]; + } else { + $tags = $this->tagMapper->getTagIdsForObjects([$fileId], 'files'); + $fileTags = current($tags); + if ($fileTags) { + $tagIds = $fileTags; + } else { + $tagIds = []; + } + } + + $tags = array_filter(array_map(function (string $tagId) { + return $this->cachedTags[$tagId] ?? null; + }, $tagIds)); + + $uncachedTagIds = array_filter($tagIds, function (string $tagId): bool { + return !isset($this->cachedTags[$tagId]); + }); + + if (count($uncachedTagIds)) { + $retrievedTags = $this->tagManager->getTagsByIds($uncachedTagIds); + foreach ($retrievedTags as $tag) { + $this->cachedTags[$tag->getId()] = $tag; + } + $tags += $retrievedTags; + } + + return array_filter($tags, function (ISystemTag $tag) use ($user) { + return $this->tagManager->canUserSeeTag($tag, $user); + }); } /** @@ -270,20 +380,86 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { */ public function handleUpdateProperties($path, PropPatch $propPatch) { $node = $this->server->tree->getNodeForPath($path); - if (!($node instanceof SystemTagNode)) { + if (!$node instanceof SystemTagNode && !$node instanceof SystemTagObjectType) { return; } + $propPatch->handle([self::OBJECTIDS_PROPERTYNAME], function ($props) use ($node) { + if (!$node instanceof SystemTagObjectType) { + return false; + } + + if (isset($props[self::OBJECTIDS_PROPERTYNAME])) { + $user = $this->userSession->getUser(); + if (!$user) { + throw new Forbidden('You don’t have permissions to update tags'); + } + + $propValue = $props[self::OBJECTIDS_PROPERTYNAME]; + if (!$propValue instanceof SystemTagsObjectList || count($propValue->getObjects()) === 0) { + throw new BadRequest('Invalid object-ids property'); + } + + $objects = $propValue->getObjects(); + $objectTypes = array_unique(array_values($objects)); + + if (count($objectTypes) !== 1 || $objectTypes[0] !== $node->getName()) { + throw new BadRequest('Invalid object-ids property. All object types must be of the same type: ' . $node->getName()); + } + + // Only files are supported at the moment + // Also see SystemTagsRelationsCollection file + if ($objectTypes[0] !== 'files') { + throw new BadRequest('Invalid object-ids property type. Only files are supported'); + } + + // Get all current tagged objects + $taggedObjects = $this->tagMapper->getObjectIdsForTags([$node->getSystemTag()->getId()], 'files'); + $toAddObjects = array_map(fn ($value) => (string)$value, array_keys($objects)); + + // Compute the tags to add and remove + $addedObjects = array_values(array_diff($toAddObjects, $taggedObjects)); + $removedObjects = array_values(array_diff($taggedObjects, $toAddObjects)); + + // Check permissions for each object to be freshly tagged or untagged + if (!$this->canUpdateTagForFileIds(array_merge($addedObjects, $removedObjects))) { + throw new Forbidden('You don’t have permissions to update tags'); + } + + $this->tagMapper->setObjectIdsForTag($node->getSystemTag()->getId(), $node->getName(), array_keys($objects)); + } + + if ($props[self::OBJECTIDS_PROPERTYNAME] === null) { + // Check the user have permissions to remove the tag from all currently tagged objects + $taggedObjects = $this->tagMapper->getObjectIdsForTags([$node->getSystemTag()->getId()], 'files'); + if (!$this->canUpdateTagForFileIds($taggedObjects)) { + throw new Forbidden('You don’t have permissions to update tags'); + } + + $this->tagMapper->setObjectIdsForTag($node->getSystemTag()->getId(), $node->getName(), []); + } + + return true; + }); + $propPatch->handle([ self::DISPLAYNAME_PROPERTYNAME, self::USERVISIBLE_PROPERTYNAME, self::USERASSIGNABLE_PROPERTYNAME, self::GROUPS_PROPERTYNAME, + self::NUM_FILES_PROPERTYNAME, + self::REFERENCE_FILEID_PROPERTYNAME, + self::COLOR_PROPERTYNAME, ], function ($props) use ($node) { + if (!$node instanceof SystemTagNode) { + return false; + } + $tag = $node->getSystemTag(); $name = $tag->getName(); $userVisible = $tag->isUserVisible(); $userAssignable = $tag->isUserAssignable(); + $color = $tag->getColor(); $updateTag = false; @@ -304,6 +480,15 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { $updateTag = true; } + if (isset($props[self::COLOR_PROPERTYNAME])) { + $propValue = $props[self::COLOR_PROPERTYNAME]; + if ($propValue === '' || $propValue === 'null') { + $propValue = null; + } + $color = $propValue; + $updateTag = true; + } + if (isset($props[self::GROUPS_PROPERTYNAME])) { if (!$this->groupManager->isAdmin($this->userSession->getUser()->getUID())) { // property only available for admins @@ -315,11 +500,40 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { $this->tagManager->setTagGroups($tag, $groupIds); } + if (isset($props[self::NUM_FILES_PROPERTYNAME]) || isset($props[self::REFERENCE_FILEID_PROPERTYNAME])) { + // read-only properties + throw new Forbidden(); + } + if ($updateTag) { - $node->update($name, $userVisible, $userAssignable); + try { + $node->update($name, $userVisible, $userAssignable, $color); + } catch (TagUpdateForbiddenException $e) { + throw new Forbidden('You don’t have permissions to update tags', 0, $e); + } } return true; }); } + + /** + * Check if the user can update the tag for the given file ids + * + * @param list<string> $fileIds + * @return bool + */ + private function canUpdateTagForFileIds(array $fileIds): bool { + $user = $this->userSession->getUser(); + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + foreach ($fileIds as $fileId) { + $nodes = $userFolder->getById((int)$fileId); + foreach ($nodes as $node) { + if (($node->getPermissions() & Constants::PERMISSION_UPDATE) === Constants::PERMISSION_UPDATE) { + return true; + } + } + } + return false; + } } diff --git a/apps/dav/lib/SystemTag/SystemTagsByIdCollection.php b/apps/dav/lib/SystemTag/SystemTagsByIdCollection.php index 1256c58921e..b854db7b94d 100644 --- a/apps/dav/lib/SystemTag/SystemTagsByIdCollection.php +++ b/apps/dav/lib/SystemTag/SystemTagsByIdCollection.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\SystemTag; @@ -28,6 +11,7 @@ use OCP\IGroupManager; use OCP\IUserSession; use OCP\SystemTag\ISystemTag; use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; use OCP\SystemTag\TagNotFoundException; use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Forbidden; @@ -37,21 +21,6 @@ use Sabre\DAV\ICollection; class SystemTagsByIdCollection implements ICollection { /** - * @var ISystemTagManager - */ - private $tagManager; - - /** - * @var IGroupManager - */ - private $groupManager; - - /** - * @var IUserSession - */ - private $userSession; - - /** * SystemTagsByIdCollection constructor. * * @param ISystemTagManager $tagManager @@ -59,13 +28,11 @@ class SystemTagsByIdCollection implements ICollection { * @param IGroupManager $groupManager */ public function __construct( - ISystemTagManager $tagManager, - IUserSession $userSession, - IGroupManager $groupManager + private ISystemTagManager $tagManager, + private IUserSession $userSession, + private IGroupManager $groupManager, + protected ISystemTagObjectMapper $tagMapper, ) { - $this->tagManager = $tagManager; - $this->userSession = $userSession; - $this->groupManager = $groupManager; } /** @@ -84,7 +51,10 @@ class SystemTagsByIdCollection implements ICollection { /** * @param string $name * @param resource|string $data Initial payload + * * @throws Forbidden + * + * @return never */ public function createFile($name, $data = null) { throw new Forbidden('Cannot create tags by id'); @@ -92,6 +62,8 @@ class SystemTagsByIdCollection implements ICollection { /** * @param string $name + * + * @return never */ public function createDirectory($name) { throw new Forbidden('Permission denied to create collections'); @@ -99,6 +71,8 @@ class SystemTagsByIdCollection implements ICollection { /** * @param string $name + * + * @return SystemTagNode */ public function getChild($name) { try { @@ -115,6 +89,11 @@ class SystemTagsByIdCollection implements ICollection { } } + /** + * @return SystemTagNode[] + * + * @psalm-return array<SystemTagNode> + */ public function getChildren() { $visibilityFilter = true; if ($this->isAdmin()) { @@ -145,14 +124,25 @@ class SystemTagsByIdCollection implements ICollection { } } + /** + * @return never + */ public function delete() { throw new Forbidden('Permission denied to delete this collection'); } + /** + * @return string + * + * @psalm-return 'systemtags' + */ public function getName() { return 'systemtags'; } + /** + * @return never + */ public function setName($name) { throw new Forbidden('Permission denied to rename this collection'); } @@ -160,7 +150,7 @@ class SystemTagsByIdCollection implements ICollection { /** * Returns the last modification time, as a unix timestamp * - * @return int + * @return null */ public function getLastModified() { return null; @@ -174,6 +164,6 @@ class SystemTagsByIdCollection implements ICollection { * @return SystemTagNode */ private function makeNode(ISystemTag $tag) { - return new SystemTagNode($tag, $this->userSession->getUser(), $this->isAdmin(), $this->tagManager); + return new SystemTagNode($tag, $this->userSession->getUser(), $this->isAdmin(), $this->tagManager, $this->tagMapper); } } diff --git a/apps/dav/lib/SystemTag/SystemTagsInUseCollection.php b/apps/dav/lib/SystemTag/SystemTagsInUseCollection.php new file mode 100644 index 00000000000..f11482b04ee --- /dev/null +++ b/apps/dav/lib/SystemTag/SystemTagsInUseCollection.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\SystemTag; + +use OC\SystemTag\SystemTag; +use OC\SystemTag\SystemTagsInFilesDetector; +use OC\User\NoUserException; +use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; +use OCP\IUserSession; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\SimpleCollection; + +class SystemTagsInUseCollection extends SimpleCollection { + protected SystemTagsInFilesDetector $systemTagsInFilesDetector; + + /** @noinspection PhpMissingParentConstructorInspection */ + public function __construct( + protected IUserSession $userSession, + protected IRootFolder $rootFolder, + protected ISystemTagManager $systemTagManager, + protected ISystemTagObjectMapper $tagMapper, + SystemTagsInFilesDetector $systemTagsInFilesDetector, + protected string $mediaType = '', + ) { + $this->systemTagsInFilesDetector = $systemTagsInFilesDetector; + $this->name = 'systemtags-assigned'; + if ($this->mediaType != '') { + $this->name .= '/' . $this->mediaType; + } + } + + public function setName($name): void { + throw new Forbidden('Permission denied to rename this collection'); + } + + public function getChild($name): self { + if ($this->mediaType !== '') { + throw new NotFound('Invalid media type'); + } + return new self($this->userSession, $this->rootFolder, $this->systemTagManager, $this->tagMapper, $this->systemTagsInFilesDetector, $name); + } + + /** + * @return SystemTagNode[] + * @throws NotPermittedException + * @throws Forbidden + */ + public function getChildren(): array { + $user = $this->userSession->getUser(); + $userFolder = null; + try { + if ($user) { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + } + } catch (NoUserException) { + // will throw a Sabre exception in the next step. + } + if ($user === null || $userFolder === null) { + throw new Forbidden('Permission denied to read this collection'); + } + + $result = $this->systemTagsInFilesDetector->detectAssignedSystemTagsIn($userFolder, $this->mediaType); + $children = []; + foreach ($result as $tagData) { + $tag = new SystemTag((string)$tagData['id'], $tagData['name'], (bool)$tagData['visibility'], (bool)$tagData['editable'], $tagData['etag'], $tagData['color']); + // read only, so we can submit the isAdmin parameter as false generally + $node = new SystemTagNode($tag, $user, false, $this->systemTagManager, $this->tagMapper); + $node->setNumberOfFiles((int)$tagData['number_files']); + $node->setReferenceFileId((int)$tagData['ref_file_id']); + $children[] = $node; + } + return $children; + } +} diff --git a/apps/dav/lib/SystemTag/SystemTagsObjectList.php b/apps/dav/lib/SystemTag/SystemTagsObjectList.php new file mode 100644 index 00000000000..64e8b1bbebb --- /dev/null +++ b/apps/dav/lib/SystemTag/SystemTagsObjectList.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\SystemTag; + +use Sabre\Xml\Reader; +use Sabre\Xml\Writer; +use Sabre\Xml\XmlDeserializable; +use Sabre\Xml\XmlSerializable; + +/** + * This property contains multiple "object-id" elements. + */ +class SystemTagsObjectList implements XmlSerializable, XmlDeserializable { + + public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; + public const OBJECTID_ROOT_PROPERTYNAME = '{http://nextcloud.org/ns}object-id'; + public const OBJECTID_PROPERTYNAME = '{http://nextcloud.org/ns}id'; + public const OBJECTTYPE_PROPERTYNAME = '{http://nextcloud.org/ns}type'; + + /** + * @param array<string, string> $objects An array of object ids and their types + */ + public function __construct( + private array $objects, + ) { + } + + /** + * Get the object ids and their types. + * + * @return array<string, string> + */ + public function getObjects(): array { + return $this->objects; + } + + public static function xmlDeserialize(Reader $reader) { + $tree = $reader->parseInnerTree(); + if ($tree === null) { + return null; + } + + $objects = []; + foreach ($tree as $elem) { + if ($elem['name'] === self::OBJECTID_ROOT_PROPERTYNAME) { + $value = $elem['value']; + $id = ''; + $type = ''; + foreach ($value as $subElem) { + if ($subElem['name'] === self::OBJECTID_PROPERTYNAME) { + $id = $subElem['value']; + } elseif ($subElem['name'] === self::OBJECTTYPE_PROPERTYNAME) { + $type = $subElem['value']; + } + } + if ($id !== '' && $type !== '') { + $objects[(string)$id] = (string)$type; + } + } + } + + return new self($objects); + } + + /** + * The xmlSerialize method is called during xml writing. + * + * @param Writer $writer + * @return void + */ + public function xmlSerialize(Writer $writer) { + foreach ($this->objects as $objectsId => $type) { + $writer->startElement(SystemTagPlugin::OBJECTIDS_PROPERTYNAME); + $writer->writeElement(self::OBJECTID_PROPERTYNAME, $objectsId); + $writer->writeElement(self::OBJECTTYPE_PROPERTYNAME, $type); + $writer->endElement(); + } + } +} diff --git a/apps/dav/lib/SystemTag/SystemTagsObjectMappingCollection.php b/apps/dav/lib/SystemTag/SystemTagsObjectMappingCollection.php index 8bb34182b0c..da58f9bf308 100644 --- a/apps/dav/lib/SystemTag/SystemTagsObjectMappingCollection.php +++ b/apps/dav/lib/SystemTag/SystemTagsObjectMappingCollection.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\SystemTag; @@ -40,58 +22,19 @@ use Sabre\DAV\ICollection; * Collection containing tags by object id */ class SystemTagsObjectMappingCollection implements ICollection { - - /** - * @var string - */ - private $objectId; - - /** - * @var string - */ - private $objectType; - - /** - * @var ISystemTagManager - */ - private $tagManager; - - /** - * @var ISystemTagObjectMapper - */ - private $tagMapper; - - /** - * User - * - * @var IUser - */ - private $user; - - - /** - * Constructor - * - * @param string $objectId object id - * @param string $objectType object type - * @param IUser $user user - * @param ISystemTagManager $tagManager tag manager - * @param ISystemTagObjectMapper $tagMapper tag mapper - */ public function __construct( - $objectId, - $objectType, - IUser $user, - ISystemTagManager $tagManager, - ISystemTagObjectMapper $tagMapper + private string $objectId, + private string $objectType, + private IUser $user, + private ISystemTagManager $tagManager, + private ISystemTagObjectMapper $tagMapper, + protected \Closure $childWriteAccessFunction, ) { - $this->tagManager = $tagManager; - $this->tagMapper = $tagMapper; - $this->objectId = $objectId; - $this->objectType = $objectType; - $this->user = $user; } + /** + * @return void + */ public function createFile($name, $data = null) { $tagId = $name; try { @@ -103,17 +46,26 @@ class SystemTagsObjectMappingCollection implements ICollection { if (!$this->tagManager->canUserAssignTag($tag, $this->user)) { throw new Forbidden('No permission to assign tag ' . $tagId); } - + $writeAccessFunction = $this->childWriteAccessFunction; + if (!$writeAccessFunction($this->objectId)) { + throw new Forbidden('No permission to assign tag to ' . $this->objectId); + } $this->tagMapper->assignTags($this->objectId, $this->objectType, $tagId); } catch (TagNotFoundException $e) { throw new PreconditionFailed('Tag with id ' . $tagId . ' does not exist, cannot assign'); } } + /** + * @return never + */ public function createDirectory($name) { throw new Forbidden('Permission denied to create collections'); } + /** + * @return SystemTagMappingNode + */ public function getChild($tagName) { try { if ($this->tagMapper->haveTag([$this->objectId], $this->objectType, $tagName, true)) { @@ -131,6 +83,11 @@ class SystemTagsObjectMappingCollection implements ICollection { } } + /** + * @return SystemTagMappingNode[] + * + * @psalm-return list<SystemTagMappingNode> + */ public function getChildren() { $tagIds = current($this->tagMapper->getTagIdsForObjects([$this->objectId], $this->objectType)); if (empty($tagIds)) { @@ -168,6 +125,9 @@ class SystemTagsObjectMappingCollection implements ICollection { } } + /** + * @return never + */ public function delete() { throw new Forbidden('Permission denied to delete this collection'); } @@ -176,6 +136,9 @@ class SystemTagsObjectMappingCollection implements ICollection { return $this->objectId; } + /** + * @return never + */ public function setName($name) { throw new Forbidden('Permission denied to rename this collection'); } @@ -183,7 +146,7 @@ class SystemTagsObjectMappingCollection implements ICollection { /** * Returns the last modification time, as a unix timestamp * - * @return int + * @return null */ public function getLastModified() { return null; @@ -204,7 +167,8 @@ class SystemTagsObjectMappingCollection implements ICollection { $this->objectType, $this->user, $this->tagManager, - $this->tagMapper + $this->tagMapper, + $this->childWriteAccessFunction, ); } } diff --git a/apps/dav/lib/SystemTag/SystemTagsObjectTypeCollection.php b/apps/dav/lib/SystemTag/SystemTagsObjectTypeCollection.php index 1ca45c32ce4..9bd66ca0d61 100644 --- a/apps/dav/lib/SystemTag/SystemTagsObjectTypeCollection.php +++ b/apps/dav/lib/SystemTag/SystemTagsObjectTypeCollection.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\SystemTag; @@ -38,67 +20,23 @@ use Sabre\DAV\ICollection; * Collection containing object ids by object type */ class SystemTagsObjectTypeCollection implements ICollection { - - /** - * @var string - */ - private $objectType; - - /** - * @var ISystemTagManager - */ - private $tagManager; - - /** - * @var ISystemTagObjectMapper - */ - private $tagMapper; - - /** - * @var IGroupManager - */ - private $groupManager; - - /** - * @var IUserSession - */ - private $userSession; - - /** - * @var \Closure - **/ - protected $childExistsFunction; - - /** - * Constructor - * - * @param string $objectType object type - * @param ISystemTagManager $tagManager - * @param ISystemTagObjectMapper $tagMapper - * @param IUserSession $userSession - * @param IGroupManager $groupManager - * @param \Closure $childExistsFunction - */ public function __construct( - $objectType, - ISystemTagManager $tagManager, - ISystemTagObjectMapper $tagMapper, - IUserSession $userSession, - IGroupManager $groupManager, - \Closure $childExistsFunction + private string $objectType, + private ISystemTagManager $tagManager, + private ISystemTagObjectMapper $tagMapper, + private IUserSession $userSession, + private IGroupManager $groupManager, + protected \Closure $childExistsFunction, + protected \Closure $childWriteAccessFunction, ) { - $this->tagManager = $tagManager; - $this->tagMapper = $tagMapper; - $this->objectType = $objectType; - $this->userSession = $userSession; - $this->groupManager = $groupManager; - $this->childExistsFunction = $childExistsFunction; } /** * @param string $name * @param resource|string $data Initial payload - * @return null|string + * + * @return never + * * @throws Forbidden */ public function createFile($name, $data = null) { @@ -107,7 +45,10 @@ class SystemTagsObjectTypeCollection implements ICollection { /** * @param string $name + * * @throws Forbidden + * + * @return never */ public function createDirectory($name) { throw new Forbidden('Permission denied to create collections'); @@ -129,10 +70,14 @@ class SystemTagsObjectTypeCollection implements ICollection { $this->objectType, $this->userSession->getUser(), $this->tagManager, - $this->tagMapper + $this->tagMapper, + $this->childWriteAccessFunction, ); } + /** + * @return never + */ public function getChildren() { // do not list object ids throw new MethodNotAllowed(); @@ -148,6 +93,9 @@ class SystemTagsObjectTypeCollection implements ICollection { return call_user_func($this->childExistsFunction, $name); } + /** + * @return never + */ public function delete() { throw new Forbidden('Permission denied to delete this collection'); } @@ -158,7 +106,10 @@ class SystemTagsObjectTypeCollection implements ICollection { /** * @param string $name + * * @throws Forbidden + * + * @return never */ public function setName($name) { throw new Forbidden('Permission denied to rename this collection'); @@ -167,7 +118,7 @@ class SystemTagsObjectTypeCollection implements ICollection { /** * Returns the last modification time, as a unix timestamp * - * @return int + * @return null */ public function getLastModified() { return null; diff --git a/apps/dav/lib/SystemTag/SystemTagsRelationsCollection.php b/apps/dav/lib/SystemTag/SystemTagsRelationsCollection.php index 4c179d5f0f6..0839a5bc995 100644 --- a/apps/dav/lib/SystemTag/SystemTagsRelationsCollection.php +++ b/apps/dav/lib/SystemTag/SystemTagsRelationsCollection.php @@ -1,31 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\SystemTag; +use OCP\Constants; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\IRootFolder; use OCP\IGroupManager; use OCP\IUserSession; use OCP\SystemTag\ISystemTagManager; @@ -33,42 +17,54 @@ use OCP\SystemTag\ISystemTagObjectMapper; use OCP\SystemTag\SystemTagsEntityEvent; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\SimpleCollection; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; class SystemTagsRelationsCollection extends SimpleCollection { - - /** - * SystemTagsRelationsCollection constructor. - * - * @param ISystemTagManager $tagManager - * @param ISystemTagObjectMapper $tagMapper - * @param IUserSession $userSession - * @param IGroupManager $groupManager - * @param EventDispatcherInterface $dispatcher - */ public function __construct( ISystemTagManager $tagManager, ISystemTagObjectMapper $tagMapper, IUserSession $userSession, IGroupManager $groupManager, - EventDispatcherInterface $dispatcher + IEventDispatcher $dispatcher, + IRootFolder $rootFolder, ) { $children = [ + // Only files are supported at the moment + // Also see SystemTagPlugin::OBJECTIDS_PROPERTYNAME supported types new SystemTagsObjectTypeCollection( 'files', $tagManager, $tagMapper, $userSession, $groupManager, - function ($name) { - $nodes = \OC::$server->getUserFolder()->getById((int)$name); - return !empty($nodes); - } + function (string $name) use ($rootFolder, $userSession): bool { + $user = $userSession->getUser(); + if ($user) { + $node = $rootFolder->getUserFolder($user->getUID())->getFirstNodeById((int)$name); + return $node !== null; + } else { + return false; + } + }, + function (string $name) use ($rootFolder, $userSession): bool { + $user = $userSession->getUser(); + if ($user) { + $nodes = $rootFolder->getUserFolder($user->getUID())->getById((int)$name); + foreach ($nodes as $node) { + if (($node->getPermissions() & Constants::PERMISSION_UPDATE) === Constants::PERMISSION_UPDATE) { + return true; + } + } + return false; + } else { + return false; + } + }, ), ]; - $event = new SystemTagsEntityEvent(SystemTagsEntityEvent::EVENT_ENTITY); + $event = new SystemTagsEntityEvent(); $dispatcher->dispatch(SystemTagsEntityEvent::EVENT_ENTITY, $event); + $dispatcher->dispatchTyped($event); foreach ($event->getEntityCollections() as $entity => $entityExistsFunction) { $children[] = new SystemTagsObjectTypeCollection( @@ -77,7 +73,8 @@ class SystemTagsRelationsCollection extends SimpleCollection { $tagMapper, $userSession, $groupManager, - $entityExistsFunction + $entityExistsFunction, + fn ($name) => true, ); } diff --git a/apps/dav/lib/Traits/PrincipalProxyTrait.php b/apps/dav/lib/Traits/PrincipalProxyTrait.php index 6e764cac8c0..feec485fe5c 100644 --- a/apps/dav/lib/Traits/PrincipalProxyTrait.php +++ b/apps/dav/lib/Traits/PrincipalProxyTrait.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright 2019, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Traits; diff --git a/apps/dav/lib/Upload/AssemblyStream.php b/apps/dav/lib/Upload/AssemblyStream.php index ef6d39974c0..642a8604b17 100644 --- a/apps/dav/lib/Upload/AssemblyStream.php +++ b/apps/dav/lib/Upload/AssemblyStream.php @@ -1,30 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author J0WI <J0WI@users.noreply.github.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Markus Goetz <markus@woboq.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Upload; @@ -96,6 +75,10 @@ class AssemblyStream implements \Icewind\Streams\File { $offset = $this->size + $offset; } + if ($offset === $this->pos) { + return true; + } + if ($offset > $this->size) { return false; } @@ -116,7 +99,7 @@ class AssemblyStream implements \Icewind\Streams\File { $stream = $this->getStream($this->nodes[$nodeIndex]); $nodeOffset = $offset - $nodeStart; - if (fseek($stream, $nodeOffset) === -1) { + if ($nodeOffset > 0 && fseek($stream, $nodeOffset) === -1) { return false; } $this->currentNode = $nodeIndex; @@ -147,9 +130,14 @@ class AssemblyStream implements \Icewind\Streams\File { } } - do { + $collectedData = ''; + // read data until we either got all the data requested or there is no more stream left + while ($count > 0 && !is_null($this->currentStream)) { $data = fread($this->currentStream, $count); $read = strlen($data); + + $count -= $read; + $collectedData .= $data; $this->currentNodeRead += $read; if (feof($this->currentStream)) { @@ -166,14 +154,11 @@ class AssemblyStream implements \Icewind\Streams\File { $this->currentStream = null; } } - // if no data read, try again with the next node because - // returning empty data can make the caller think there is no more - // data left to read - } while ($read === 0 && !is_null($this->currentStream)); + } // update position - $this->pos += $read; - return $data; + $this->pos += strlen($collectedData); + return $collectedData; } /** diff --git a/apps/dav/lib/Upload/ChunkingPlugin.php b/apps/dav/lib/Upload/ChunkingPlugin.php index 5a443ee7712..8cc8f7d6c61 100644 --- a/apps/dav/lib/Upload/ChunkingPlugin.php +++ b/apps/dav/lib/Upload/ChunkingPlugin.php @@ -1,32 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2017, ownCloud GmbH - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.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: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Upload; use OCA\DAV\Connector\Sabre\Directory; use OCA\DAV\Connector\Sabre\Exception\Forbidden; +use OCP\AppFramework\Http; use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\INode; @@ -107,7 +90,7 @@ class ChunkingPlugin extends ServerPlugin { $response = $this->server->httpResponse; $response->setHeader('Content-Length', '0'); - $response->setStatus($fileExists ? 204 : 201); + $response->setStatus($fileExists ? Http::STATUS_NO_CONTENT : Http::STATUS_CREATED); return false; } diff --git a/apps/dav/lib/Upload/ChunkingV2Plugin.php b/apps/dav/lib/Upload/ChunkingV2Plugin.php new file mode 100644 index 00000000000..07452dc0593 --- /dev/null +++ b/apps/dav/lib/Upload/ChunkingV2Plugin.php @@ -0,0 +1,385 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Upload; + +use Exception; +use InvalidArgumentException; +use OC\Files\Filesystem; +use OC\Files\ObjectStore\ObjectStoreStorage; +use OC\Files\View; +use OC\Memcache\Memcached; +use OC\Memcache\Redis; +use OC_Hook; +use OCA\DAV\Connector\Sabre\Directory; +use OCA\DAV\Connector\Sabre\File; +use OCP\AppFramework\Http; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\IRootFolder; +use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload; +use OCP\Files\Storage\IChunkedFileWrite; +use OCP\Files\StorageInvalidException; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\Lock\ILockingProvider; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\InsufficientStorage; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Exception\PreconditionFailed; +use Sabre\DAV\ICollection; +use Sabre\DAV\INode; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Sabre\Uri; + +class ChunkingV2Plugin extends ServerPlugin { + /** @var Server */ + private $server; + /** @var UploadFolder */ + private $uploadFolder; + /** @var ICache */ + private $cache; + + private ?string $uploadId = null; + private ?string $uploadPath = null; + + private const TEMP_TARGET = '.target'; + + public const CACHE_KEY = 'chunking-v2'; + public const UPLOAD_TARGET_PATH = 'upload-target-path'; + public const UPLOAD_TARGET_ID = 'upload-target-id'; + public const UPLOAD_ID = 'upload-id'; + + private const DESTINATION_HEADER = 'Destination'; + + public function __construct(ICacheFactory $cacheFactory) { + $this->cache = $cacheFactory->createDistributed(self::CACHE_KEY); + } + + /** + * @inheritdoc + */ + public function initialize(Server $server) { + $server->on('afterMethod:MKCOL', [$this, 'afterMkcol']); + $server->on('beforeMethod:PUT', [$this, 'beforePut']); + $server->on('beforeMethod:DELETE', [$this, 'beforeDelete']); + $server->on('beforeMove', [$this, 'beforeMove'], 90); + + $this->server = $server; + } + + /** + * @param string $path + * @param bool $createIfNotExists + * @return FutureFile|UploadFile|ICollection|INode + */ + private function getUploadFile(string $path, bool $createIfNotExists = false) { + try { + $actualFile = $this->server->tree->getNodeForPath($path); + // Only directly upload to the target file if it is on the same storage + // There may be further potential to optimize here by also uploading + // to other storages directly. This would require to also carefully pick + // the storage/path used in getStorage() + if ($actualFile instanceof File && $this->uploadFolder->getStorage()->getId() === $actualFile->getNode()->getStorage()->getId()) { + return $actualFile; + } + } catch (NotFound $e) { + // If there is no target file we upload to the upload folder first + } + + // Use file in the upload directory that will be copied or moved afterwards + if ($createIfNotExists) { + $this->uploadFolder->createFile(self::TEMP_TARGET); + } + + /** @var UploadFile $uploadFile */ + $uploadFile = $this->uploadFolder->getChild(self::TEMP_TARGET); + return $uploadFile->getFile(); + } + + public function afterMkcol(RequestInterface $request, ResponseInterface $response): bool { + try { + $this->prepareUpload($request->getPath()); + $this->checkPrerequisites(false); + } catch (BadRequest|StorageInvalidException|NotFound $e) { + return true; + } + + $this->uploadPath = $this->server->calculateUri($this->server->httpRequest->getHeader(self::DESTINATION_HEADER)); + $targetFile = $this->getUploadFile($this->uploadPath, true); + [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath); + + $this->uploadId = $storage->startChunkedWrite($storagePath); + + $this->cache->set($this->uploadFolder->getName(), [ + self::UPLOAD_ID => $this->uploadId, + self::UPLOAD_TARGET_PATH => $this->uploadPath, + self::UPLOAD_TARGET_ID => $targetFile->getId(), + ], 86400); + + $response->setStatus(Http::STATUS_CREATED); + return true; + } + + public function beforePut(RequestInterface $request, ResponseInterface $response): bool { + try { + $this->prepareUpload(dirname($request->getPath())); + $this->checkPrerequisites(); + } catch (StorageInvalidException|BadRequest|NotFound $e) { + return true; + } + + [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath); + + $chunkName = basename($request->getPath()); + $partId = is_numeric($chunkName) ? (int)$chunkName : -1; + if (!($partId >= 1 && $partId <= 10000)) { + throw new BadRequest('Invalid chunk name, must be numeric between 1 and 10000'); + } + + $uploadFile = $this->getUploadFile($this->uploadPath); + $tempTargetFile = null; + + $additionalSize = (int)$request->getHeader('Content-Length'); + if ($this->uploadFolder->childExists(self::TEMP_TARGET) && $this->uploadPath) { + /** @var UploadFile $tempTargetFile */ + $tempTargetFile = $this->uploadFolder->getChild(self::TEMP_TARGET); + [$destinationDir, $destinationName] = Uri\split($this->uploadPath); + /** @var Directory $destinationParent */ + $destinationParent = $this->server->tree->getNodeForPath($destinationDir); + $free = $destinationParent->getNode()->getFreeSpace(); + $newSize = $tempTargetFile->getSize() + $additionalSize; + if ($free >= 0 && ($tempTargetFile->getSize() > $free || $newSize > $free)) { + throw new InsufficientStorage("Insufficient space in $this->uploadPath"); + } + } + + $stream = $request->getBodyAsStream(); + $storage->putChunkedWritePart($storagePath, $this->uploadId, (string)$partId, $stream, $additionalSize); + + $storage->getCache()->update($uploadFile->getId(), ['size' => $uploadFile->getSize() + $additionalSize]); + if ($tempTargetFile) { + $storage->getPropagator()->propagateChange($tempTargetFile->getInternalPath(), time(), $additionalSize); + } + + $response->setStatus(201); + return false; + } + + public function beforeMove($sourcePath, $destination): bool { + try { + $this->prepareUpload(dirname($sourcePath)); + $this->checkPrerequisites(); + } catch (StorageInvalidException|BadRequest|NotFound|PreconditionFailed $e) { + return true; + } + [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath); + + $targetFile = $this->getUploadFile($this->uploadPath); + + [$destinationDir, $destinationName] = Uri\split($destination); + /** @var Directory $destinationParent */ + $destinationParent = $this->server->tree->getNodeForPath($destinationDir); + $destinationExists = $destinationParent->childExists($destinationName); + + + // allow sync clients to send the modification and creation time along in a header + $updateFileInfo = []; + if ($this->server->httpRequest->getHeader('X-OC-MTime') !== null) { + $updateFileInfo['mtime'] = $this->sanitizeMtime($this->server->httpRequest->getHeader('X-OC-MTime')); + $this->server->httpResponse->setHeader('X-OC-MTime', 'accepted'); + } + if ($this->server->httpRequest->getHeader('X-OC-CTime') !== null) { + $updateFileInfo['creation_time'] = $this->sanitizeMtime($this->server->httpRequest->getHeader('X-OC-CTime')); + $this->server->httpResponse->setHeader('X-OC-CTime', 'accepted'); + } + $updateFileInfo['mimetype'] = \OCP\Server::get(IMimeTypeDetector::class)->detectPath($destinationName); + + if ($storage->instanceOfStorage(ObjectStoreStorage::class) && $storage->getObjectStore() instanceof IObjectStoreMultiPartUpload) { + /** @var ObjectStoreStorage $storage */ + /** @var IObjectStoreMultiPartUpload $objectStore */ + $objectStore = $storage->getObjectStore(); + $parts = $objectStore->getMultipartUploads($storage->getURN($targetFile->getId()), $this->uploadId); + $size = 0; + foreach ($parts as $part) { + $size += $part['Size']; + } + $free = $destinationParent->getNode()->getFreeSpace(); + if ($free >= 0 && ($size > $free)) { + throw new InsufficientStorage("Insufficient space in $this->uploadPath"); + } + } + + $destinationInView = $destinationParent->getFileInfo()->getPath() . '/' . $destinationName; + $this->completeChunkedWrite($destinationInView); + + $rootView = new View(); + $rootView->putFileInfo($destinationInView, $updateFileInfo); + + $sourceNode = $this->server->tree->getNodeForPath($sourcePath); + if ($sourceNode instanceof FutureFile) { + $this->uploadFolder->delete(); + } + + $this->server->emit('afterMove', [$sourcePath, $destination]); + $this->server->emit('afterUnbind', [$sourcePath]); + $this->server->emit('afterBind', [$destination]); + + $response = $this->server->httpResponse; + $response->setHeader('Content-Type', 'application/xml; charset=utf-8'); + $response->setHeader('Content-Length', '0'); + $response->setStatus($destinationExists ? Http::STATUS_NO_CONTENT : Http::STATUS_CREATED); + return false; + } + + public function beforeDelete(RequestInterface $request, ResponseInterface $response) { + try { + $this->prepareUpload(dirname($request->getPath())); + $this->checkPrerequisites(); + } catch (StorageInvalidException|BadRequest|NotFound $e) { + return true; + } + + [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath); + $storage->cancelChunkedWrite($storagePath, $this->uploadId); + return true; + } + + /** + * @throws BadRequest + * @throws PreconditionFailed + * @throws StorageInvalidException + */ + private function checkPrerequisites(bool $checkUploadMetadata = true): void { + $distributedCacheConfig = \OCP\Server::get(IConfig::class)->getSystemValue('memcache.distributed', null); + + if ($distributedCacheConfig === null || (!$this->cache instanceof Redis && !$this->cache instanceof Memcached)) { + throw new BadRequest('Skipping chunking v2 since no proper distributed cache is available'); + } + if (!$this->uploadFolder instanceof UploadFolder || empty($this->server->httpRequest->getHeader(self::DESTINATION_HEADER))) { + throw new BadRequest('Skipping chunked file writing as the destination header was not passed'); + } + if (!$this->uploadFolder->getStorage()->instanceOfStorage(IChunkedFileWrite::class)) { + throw new StorageInvalidException('Storage does not support chunked file writing'); + } + if ($this->uploadFolder->getStorage()->instanceOfStorage(ObjectStoreStorage::class) && !$this->uploadFolder->getStorage()->getObjectStore() instanceof IObjectStoreMultiPartUpload) { + throw new StorageInvalidException('Storage does not support multi part uploads'); + } + + if ($checkUploadMetadata) { + if ($this->uploadId === null || $this->uploadPath === null) { + throw new PreconditionFailed('Missing metadata for chunked upload. The distributed cache does not hold the information of previous requests.'); + } + } + } + + /** + * @return array [IStorage, string] + */ + private function getUploadStorage(string $targetPath): array { + $storage = $this->uploadFolder->getStorage(); + $targetFile = $this->getUploadFile($targetPath); + return [$storage, $targetFile->getInternalPath()]; + } + + protected function sanitizeMtime(string $mtimeFromRequest): int { + if (!is_numeric($mtimeFromRequest)) { + throw new InvalidArgumentException('X-OC-MTime header must be an integer (unix timestamp).'); + } + + return (int)$mtimeFromRequest; + } + + /** + * @throws NotFound + */ + public function prepareUpload($path): void { + $this->uploadFolder = $this->server->tree->getNodeForPath($path); + $uploadMetadata = $this->cache->get($this->uploadFolder->getName()); + $this->uploadId = $uploadMetadata[self::UPLOAD_ID] ?? null; + $this->uploadPath = $uploadMetadata[self::UPLOAD_TARGET_PATH] ?? null; + } + + private function completeChunkedWrite(string $targetAbsolutePath): void { + $uploadFile = $this->getUploadFile($this->uploadPath)->getNode(); + [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath); + + $rootFolder = \OCP\Server::get(IRootFolder::class); + $exists = $rootFolder->nodeExists($targetAbsolutePath); + + $uploadFile->lock(ILockingProvider::LOCK_SHARED); + $this->emitPreHooks($targetAbsolutePath, $exists); + try { + $uploadFile->changeLock(ILockingProvider::LOCK_EXCLUSIVE); + $storage->completeChunkedWrite($storagePath, $this->uploadId); + $uploadFile->changeLock(ILockingProvider::LOCK_SHARED); + } catch (Exception $e) { + $uploadFile->unlock(ILockingProvider::LOCK_EXCLUSIVE); + throw $e; + } + + // If the file was not uploaded to the user storage directly we need to copy/move it + try { + $uploadFileAbsolutePath = $uploadFile->getFileInfo()->getPath(); + if ($uploadFileAbsolutePath !== $targetAbsolutePath) { + $uploadFile = $rootFolder->get($uploadFile->getFileInfo()->getPath()); + if ($exists) { + $uploadFile->copy($targetAbsolutePath); + } else { + $uploadFile->move($targetAbsolutePath); + } + } + $this->emitPostHooks($targetAbsolutePath, $exists); + } catch (Exception $e) { + $uploadFile->unlock(ILockingProvider::LOCK_SHARED); + throw $e; + } + } + + private function emitPreHooks(string $target, bool $exists): void { + $hookPath = $this->getHookPath($target); + if (!$exists) { + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_create, [ + Filesystem::signal_param_path => $hookPath, + ]); + } else { + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_update, [ + Filesystem::signal_param_path => $hookPath, + ]); + } + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_write, [ + Filesystem::signal_param_path => $hookPath, + ]); + } + + private function emitPostHooks(string $target, bool $exists): void { + $hookPath = $this->getHookPath($target); + if (!$exists) { + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_create, [ + Filesystem::signal_param_path => $hookPath, + ]); + } else { + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_update, [ + Filesystem::signal_param_path => $hookPath, + ]); + } + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_write, [ + Filesystem::signal_param_path => $hookPath, + ]); + } + + private function getHookPath(string $path): ?string { + if (!Filesystem::getView()) { + return $path; + } + return Filesystem::getView()->getRelativePath($path); + } +} diff --git a/apps/dav/lib/Upload/CleanupService.php b/apps/dav/lib/Upload/CleanupService.php index 2b6fc965c01..ffa6bad533c 100644 --- a/apps/dav/lib/Upload/CleanupService.php +++ b/apps/dav/lib/Upload/CleanupService.php @@ -3,48 +3,25 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Upload; use OCA\DAV\BackgroundJob\UploadCleanup; use OCP\BackgroundJob\IJobList; -use OCP\IUserSession; class CleanupService { - /** @var IUserSession */ - private $userSession; - /** @var IJobList */ - private $jobList; - - public function __construct(IUserSession $userSession, IJobList $jobList) { - $this->userSession = $userSession; - $this->jobList = $jobList; + public function __construct( + private IJobList $jobList, + ) { } - public function addJob(string $folder) { - $this->jobList->add(UploadCleanup::class, ['uid' => $this->userSession->getUser()->getUID(), 'folder' => $folder]); + public function addJob(string $uid, string $folder) { + $this->jobList->add(UploadCleanup::class, ['uid' => $uid, 'folder' => $folder]); } - public function removeJob(string $folder) { - $this->jobList->remove(UploadCleanup::class, ['uid' => $this->userSession->getUser()->getUID(), 'folder' => $folder]); + public function removeJob(string $uid, string $folder) { + $this->jobList->remove(UploadCleanup::class, ['uid' => $uid, 'folder' => $folder]); } } diff --git a/apps/dav/lib/Upload/FutureFile.php b/apps/dav/lib/Upload/FutureFile.php index eba550a62da..ba37c56978d 100644 --- a/apps/dav/lib/Upload/FutureFile.php +++ b/apps/dav/lib/Upload/FutureFile.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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\Upload; @@ -36,19 +20,14 @@ use Sabre\DAV\IFile; * @package OCA\DAV\Upload */ class FutureFile implements \Sabre\DAV\IFile { - - /** @var Directory */ - private $root; - /** @var string */ - private $name; - /** * @param Directory $root * @param string $name */ - public function __construct(Directory $root, $name) { - $this->root = $root; - $this->name = $name; + public function __construct( + private Directory $root, + private $name, + ) { } /** @@ -66,6 +45,10 @@ class FutureFile implements \Sabre\DAV\IFile { return AssemblyStream::wrap($nodes); } + public function getPath() { + return $this->root->getFileInfo()->getInternalPath() . '/.file'; + } + /** * @inheritdoc */ diff --git a/apps/dav/lib/Upload/PartFile.php b/apps/dav/lib/Upload/PartFile.php new file mode 100644 index 00000000000..11900997a90 --- /dev/null +++ b/apps/dav/lib/Upload/PartFile.php @@ -0,0 +1,91 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Upload; + +use OCA\DAV\Connector\Sabre\Directory; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\IFile; + +/** + * This class represents an Upload part which is not present on the storage itself + * but handled directly by external storage services like S3 with Multipart Upload + */ +class PartFile implements IFile { + public function __construct( + private Directory $root, + private array $partInfo, + ) { + } + + /** + * @inheritdoc + */ + public function put($data) { + throw new Forbidden('Permission denied to put into this file'); + } + + /** + * @inheritdoc + */ + public function get() { + throw new Forbidden('Permission denied to get this file'); + } + + public function getPath() { + return $this->root->getFileInfo()->getInternalPath() . '/' . $this->partInfo['PartNumber']; + } + + /** + * @inheritdoc + */ + public function getContentType() { + return 'application/octet-stream'; + } + + /** + * @inheritdoc + */ + public function getETag() { + return $this->partInfo['ETag']; + } + + /** + * @inheritdoc + */ + public function getSize() { + return $this->partInfo['Size']; + } + + /** + * @inheritdoc + */ + public function delete() { + $this->root->delete(); + } + + /** + * @inheritdoc + */ + public function getName() { + return $this->partInfo['PartNumber']; + } + + /** + * @inheritdoc + */ + public function setName($name) { + throw new Forbidden('Permission denied to rename this file'); + } + + /** + * @inheritdoc + */ + public function getLastModified() { + return $this->partInfo['LastModified']; + } +} diff --git a/apps/dav/lib/Upload/RootCollection.php b/apps/dav/lib/Upload/RootCollection.php index e3ae22af5e9..cd7ab7f5e0a 100644 --- a/apps/dav/lib/Upload/RootCollection.php +++ b/apps/dav/lib/Upload/RootCollection.php @@ -3,49 +3,42 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Upload; +use OCP\Files\IRootFolder; +use OCP\IUserSession; +use OCP\Share\IManager; use Sabre\DAVACL\AbstractPrincipalCollection; use Sabre\DAVACL\PrincipalBackend; class RootCollection extends AbstractPrincipalCollection { - /** @var CleanupService */ - private $cleanupService; - - public function __construct(PrincipalBackend\BackendInterface $principalBackend, - string $principalPrefix, - CleanupService $cleanupService) { + public function __construct( + PrincipalBackend\BackendInterface $principalBackend, + string $principalPrefix, + private CleanupService $cleanupService, + private IRootFolder $rootFolder, + private IUserSession $userSession, + private IManager $shareManager, + ) { parent::__construct($principalBackend, $principalPrefix); - $this->cleanupService = $cleanupService; } /** * @inheritdoc */ public function getChildForPrincipal(array $principalInfo): UploadHome { - return new UploadHome($principalInfo, $this->cleanupService); + return new UploadHome( + $principalInfo, + $this->cleanupService, + $this->rootFolder, + $this->userSession, + $this->shareManager, + ); } /** diff --git a/apps/dav/lib/Upload/UploadAutoMkcolPlugin.php b/apps/dav/lib/Upload/UploadAutoMkcolPlugin.php new file mode 100644 index 00000000000..a7030ba1133 --- /dev/null +++ b/apps/dav/lib/Upload/UploadAutoMkcolPlugin.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Upload; + +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use function Sabre\Uri\split as uriSplit; + +/** + * Class that allows automatically creating non-existing collections on file + * upload. + * + * Since this functionality is not WebDAV compliant, it needs a special + * header to be activated. + */ +class UploadAutoMkcolPlugin extends ServerPlugin { + + private Server $server; + + public function initialize(Server $server): void { + $server->on('beforeMethod:PUT', [$this, 'beforeMethod']); + $this->server = $server; + } + + /** + * @throws NotFound a node expected to exist cannot be found + */ + public function beforeMethod(RequestInterface $request, ResponseInterface $response): bool { + if ($request->getHeader('X-NC-WebDAV-Auto-Mkcol') !== '1') { + return true; + } + + [$path,] = uriSplit($request->getPath()); + + if ($this->server->tree->nodeExists($path)) { + return true; + } + + $parts = explode('/', trim($path, '/')); + $rootPath = array_shift($parts); + $node = $this->server->tree->getNodeForPath('/' . $rootPath); + + if (!($node instanceof ICollection)) { + // the root node is not a collection, let SabreDAV handle it + return true; + } + + foreach ($parts as $part) { + if (!$node->childExists($part)) { + $node->createDirectory($part); + } + + $node = $node->getChild($part); + } + + return true; + } +} diff --git a/apps/dav/lib/Upload/UploadFile.php b/apps/dav/lib/Upload/UploadFile.php index 49a2fadecf6..7301e855cfe 100644 --- a/apps/dav/lib/Upload/UploadFile.php +++ b/apps/dav/lib/Upload/UploadFile.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Upload; @@ -29,12 +12,9 @@ use OCA\DAV\Connector\Sabre\File; use Sabre\DAV\IFile; class UploadFile implements IFile { - - /** @var File */ - private $file; - - public function __construct(File $file) { - $this->file = $file; + public function __construct( + private File $file, + ) { } public function put($data) { @@ -45,6 +25,10 @@ class UploadFile implements IFile { return $this->file->get(); } + public function getId() { + return $this->file->getId(); + } + public function getContentType() { return $this->file->getContentType(); } @@ -53,6 +37,10 @@ class UploadFile implements IFile { return $this->file->getETag(); } + /** + * @psalm-suppress ImplementedReturnTypeMismatch \Sabre\DAV\IFile::getSize signature does not support 32bit + * @return int|float + */ public function getSize() { return $this->file->getSize(); } @@ -72,4 +60,16 @@ class UploadFile implements IFile { public function getLastModified() { return $this->file->getLastModified(); } + + public function getInternalPath(): string { + return $this->file->getInternalPath(); + } + + public function getFile(): File { + return $this->file; + } + + public function getNode() { + return $this->file->getNode(); + } } diff --git a/apps/dav/lib/Upload/UploadFolder.php b/apps/dav/lib/Upload/UploadFolder.php index bb7c494cee3..8890d472f87 100644 --- a/apps/dav/lib/Upload/UploadFolder.php +++ b/apps/dav/lib/Upload/UploadFolder.php @@ -1,48 +1,41 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Upload; +use OC\Files\ObjectStore\ObjectStoreStorage; use OCA\DAV\Connector\Sabre\Directory; +use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload; +use OCP\Files\Storage\IStorage; +use OCP\ICacheFactory; +use OCP\Server; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\ICollection; class UploadFolder implements ICollection { - - /** @var Directory */ - private $node; - /** @var CleanupService */ - private $cleanupService; - - public function __construct(Directory $node, CleanupService $cleanupService) { - $this->node = $node; - $this->cleanupService = $cleanupService; + public function __construct( + private Directory $node, + private CleanupService $cleanupService, + private IStorage $storage, + private string $uid, + ) { } public function createFile($name, $data = null) { // TODO: verify name - should be a simple number - $this->node->createFile($name, $data); + try { + $this->node->createFile($name, $data); + } catch (\Exception $e) { + if ($this->node->childExists($name)) { + $child = $this->node->getChild($name); + $child->delete(); + } + throw $e; + } } public function createDirectory($name) { @@ -66,6 +59,23 @@ class UploadFolder implements ICollection { $children[] = new UploadFile($child); } + if ($this->storage->instanceOfStorage(ObjectStoreStorage::class)) { + /** @var ObjectStoreStorage $storage */ + $objectStore = $this->storage->getObjectStore(); + if ($objectStore instanceof IObjectStoreMultiPartUpload) { + $cache = Server::get(ICacheFactory::class)->createDistributed(ChunkingV2Plugin::CACHE_KEY); + $uploadSession = $cache->get($this->getName()); + if ($uploadSession) { + $uploadId = $uploadSession[ChunkingV2Plugin::UPLOAD_ID]; + $id = $uploadSession[ChunkingV2Plugin::UPLOAD_TARGET_ID]; + $parts = $objectStore->getMultipartUploads($this->storage->getURN($id), $uploadId); + foreach ($parts as $part) { + $children[] = new PartFile($this->node, $part); + } + } + } + } + return $children; } @@ -80,7 +90,7 @@ class UploadFolder implements ICollection { $this->node->delete(); // Background cleanup job is not needed anymore - $this->cleanupService->removeJob($this->getName()); + $this->cleanupService->removeJob($this->uid, $this->getName()); } public function getName() { @@ -94,4 +104,8 @@ class UploadFolder implements ICollection { public function getLastModified() { return $this->node->getLastModified(); } + + public function getStorage() { + return $this->storage; + } } diff --git a/apps/dav/lib/Upload/UploadHome.php b/apps/dav/lib/Upload/UploadHome.php index 35d47b6a82a..4042f1c4101 100644 --- a/apps/dav/lib/Upload/UploadHome.php +++ b/apps/dav/lib/Upload/UploadHome.php @@ -1,46 +1,43 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Upload; -use OC\Files\Filesystem; use OC\Files\View; use OCA\DAV\Connector\Sabre\Directory; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\IUserSession; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\ICollection; class UploadHome implements ICollection { + private string $uid; + private ?Folder $uploadFolder = null; - /** @var array */ - private $principalInfo; - /** @var CleanupService */ - private $cleanupService; + public function __construct( + private readonly array $principalInfo, + private readonly CleanupService $cleanupService, + private readonly IRootFolder $rootFolder, + private readonly IUserSession $userSession, + private readonly \OCP\Share\IManager $shareManager, + ) { + [$prefix, $name] = \Sabre\Uri\split($principalInfo['uri']); + if ($prefix === 'principals/shares') { + $this->uid = $this->shareManager->getShareByToken($name)->getShareOwner(); + } else { + $user = $this->userSession->getUser(); + if (!$user) { + throw new Forbidden('Not logged in'); + } - public function __construct(array $principalInfo, CleanupService $cleanupService) { - $this->principalInfo = $principalInfo; - $this->cleanupService = $cleanupService; + $this->uid = $user->getUID(); + } } public function createFile($name, $data = null) { @@ -51,16 +48,26 @@ class UploadHome implements ICollection { $this->impl()->createDirectory($name); // Add a cleanup job - $this->cleanupService->addJob($name); + $this->cleanupService->addJob($this->uid, $name); } public function getChild($name): UploadFolder { - return new UploadFolder($this->impl()->getChild($name), $this->cleanupService); + return new UploadFolder( + $this->impl()->getChild($name), + $this->cleanupService, + $this->getStorage(), + $this->uid, + ); } public function getChildren(): array { return array_map(function ($node) { - return new UploadFolder($node, $this->cleanupService); + return new UploadFolder( + $node, + $this->cleanupService, + $this->getStorage(), + $this->uid, + ); }, $this->impl()->getChildren()); } @@ -85,18 +92,29 @@ class UploadHome implements ICollection { return $this->impl()->getLastModified(); } - /** - * @return Directory - */ - private function impl() { - $rootView = new View(); - $user = \OC::$server->getUserSession()->getUser(); - Filesystem::initMountPoints($user->getUID()); - if (!$rootView->file_exists('/' . $user->getUID() . '/uploads')) { - $rootView->mkdir('/' . $user->getUID() . '/uploads'); + private function getUploadFolder(): Folder { + if ($this->uploadFolder === null) { + $path = '/' . $this->uid . '/uploads'; + try { + $folder = $this->rootFolder->get($path); + if (!$folder instanceof Folder) { + throw new \Exception('Upload folder is a file'); + } + $this->uploadFolder = $folder; + } catch (NotFoundException $e) { + $this->uploadFolder = $this->rootFolder->newFolder($path); + } } - $view = new View('/' . $user->getUID() . '/uploads'); - $rootInfo = $view->getFileInfo(''); - return new Directory($view, $rootInfo); + return $this->uploadFolder; + } + + private function impl(): Directory { + $folder = $this->getUploadFolder(); + $view = new View($folder->getPath()); + return new Directory($view, $folder); + } + + private function getStorage() { + return $this->getUploadFolder()->getStorage(); } } diff --git a/apps/dav/lib/UserMigration/CalendarMigrator.php b/apps/dav/lib/UserMigration/CalendarMigrator.php index 015ce6faa86..73e9c375490 100644 --- a/apps/dav/lib/UserMigration/CalendarMigrator.php +++ b/apps/dav/lib/UserMigration/CalendarMigrator.php @@ -3,30 +3,12 @@ declare(strict_types=1); /** - * @copyright 2022 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\UserMigration; -use function Safe\substr; use OCA\DAV\AppInfo\Application; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin; @@ -42,6 +24,7 @@ use OCP\IUser; use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; use OCP\UserMigration\IMigrator; +use OCP\UserMigration\ISizeEstimationMigrator; use OCP\UserMigration\TMigratorBasicVersionHandling; use Sabre\VObject\Component as VObjectComponent; use Sabre\VObject\Component\VCalendar; @@ -49,25 +32,15 @@ use Sabre\VObject\Component\VTimeZone; use Sabre\VObject\Property\ICalendar\DateTime; use Sabre\VObject\Reader as VObjectReader; use Sabre\VObject\UUIDUtil; -use Safe\Exceptions\StringsException; +use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use Throwable; +use function substr; -class CalendarMigrator implements IMigrator { +class CalendarMigrator implements IMigrator, ISizeEstimationMigrator { use TMigratorBasicVersionHandling; - private CalDavBackend $calDavBackend; - - private ICalendarManager $calendarManager; - - // ICSExportPlugin is injected as the mergeObjects() method is required and is not to be used as a SabreDAV server plugin - private ICSExportPlugin $icsExportPlugin; - - private Defaults $defaults; - - private IL10N $l10n; - private SabreDavServer $sabreDavServer; private const USERS_URI_ROOT = 'principals/users/'; @@ -79,18 +52,12 @@ class CalendarMigrator implements IMigrator { private const EXPORT_ROOT = Application::APP_ID . '/calendars/'; public function __construct( - CalDavBackend $calDavBackend, - ICalendarManager $calendarManager, - ICSExportPlugin $icsExportPlugin, - Defaults $defaults, - IL10N $l10n + private CalDavBackend $calDavBackend, + private ICalendarManager $calendarManager, + private ICSExportPlugin $icsExportPlugin, + private Defaults $defaults, + private IL10N $l10n, ) { - $this->calDavBackend = $calDavBackend; - $this->calendarManager = $calendarManager; - $this->icsExportPlugin = $icsExportPlugin; - $this->defaults = $defaults; - $this->l10n = $l10n; - $root = new RootCollection(); $this->sabreDavServer = new SabreDavServer(new CachingTree($root)); $this->sabreDavServer->addPlugin(new CalDAVPlugin()); @@ -181,14 +148,18 @@ class CalendarMigrator implements IMigrator { ))); } + /** + * @throws InvalidCalendarException + */ private function getUniqueCalendarUri(IUser $user, string $initialCalendarUri): string { $principalUri = $this->getPrincipalUri($user); - try { - $initialCalendarUri = substr($initialCalendarUri, 0, strlen(CalendarMigrator::MIGRATED_URI_PREFIX)) === CalendarMigrator::MIGRATED_URI_PREFIX - ? $initialCalendarUri - : CalendarMigrator::MIGRATED_URI_PREFIX . $initialCalendarUri; - } catch (StringsException $e) { - throw new CalendarMigratorException('Failed to get unique calendar URI', 0, $e); + + $initialCalendarUri = substr($initialCalendarUri, 0, strlen(CalendarMigrator::MIGRATED_URI_PREFIX)) === CalendarMigrator::MIGRATED_URI_PREFIX + ? $initialCalendarUri + : CalendarMigrator::MIGRATED_URI_PREFIX . $initialCalendarUri; + + if ($initialCalendarUri === '') { + throw new InvalidCalendarException(); } $existingCalendarUris = array_map( @@ -209,6 +180,31 @@ class CalendarMigrator implements IMigrator { /** * {@inheritDoc} */ + public function getEstimatedExportSize(IUser $user): int|float { + $calendarExports = $this->getCalendarExports($user, new NullOutput()); + $calendarCount = count($calendarExports); + + // 150B for top-level properties + $size = ($calendarCount * 150) / 1024; + + $componentCount = array_sum(array_map( + function (array $data): int { + /** @var VCalendar $vCalendar */ + $vCalendar = $data['vCalendar']; + return count($vCalendar->getComponents()); + }, + $calendarExports, + )); + + // 450B for each component (events, todos, alarms, etc.) + $size += ($componentCount * 450) / 1024; + + return ceil($size); + } + + /** + * {@inheritDoc} + */ public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { $output->writeln('Exporting calendars into ' . CalendarMigrator::EXPORT_ROOT . '…'); @@ -430,17 +426,20 @@ class CalendarMigrator implements IMigrator { VObjectReader::OPTION_FORGIVING, ); } catch (Throwable $e) { - throw new CalendarMigratorException("Failed to read file \"$importPath\"", 0, $e); + $output->writeln("Failed to read file \"$importPath\", skipping…"); + continue; } $problems = $vCalendar->validate(); if (!empty($problems)) { - throw new CalendarMigratorException("Invalid calendar data contained in \"$importPath\""); + $output->writeln("Invalid calendar data contained in \"$importPath\", skipping…"); + continue; } $splitFilename = explode('.', $filename, 2); if (count($splitFilename) !== 2) { - throw new CalendarMigratorException("Invalid filename \"$filename\", expected filename of the format \"<calendar_name>" . CalendarMigrator::FILENAME_EXT . '"'); + $output->writeln("Invalid filename \"$filename\", expected filename of the format \"<calendar_name>" . CalendarMigrator::FILENAME_EXT . '", skipping…'); + continue; } [$initialCalendarUri, $ext] = $splitFilename; diff --git a/apps/dav/lib/UserMigration/CalendarMigratorException.php b/apps/dav/lib/UserMigration/CalendarMigratorException.php index 3b4f8f89232..f3754809b44 100644 --- a/apps/dav/lib/UserMigration/CalendarMigratorException.php +++ b/apps/dav/lib/UserMigration/CalendarMigratorException.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2022 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\UserMigration; diff --git a/apps/dav/lib/UserMigration/ContactsMigrator.php b/apps/dav/lib/UserMigration/ContactsMigrator.php index aed41e5c82f..96d623938a3 100644 --- a/apps/dav/lib/UserMigration/ContactsMigrator.php +++ b/apps/dav/lib/UserMigration/ContactsMigrator.php @@ -3,31 +3,12 @@ declare(strict_types=1); /** - * @copyright 2022 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\UserMigration; -use function Safe\sort; -use function Safe\substr; use OCA\DAV\AppInfo\Application; use OCA\DAV\CardDAV\CardDavBackend; use OCA\DAV\CardDAV\Plugin as CardDAVPlugin; @@ -39,25 +20,23 @@ use OCP\IUser; use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; use OCP\UserMigration\IMigrator; +use OCP\UserMigration\ISizeEstimationMigrator; use OCP\UserMigration\TMigratorBasicVersionHandling; use Sabre\VObject\Component\VCard; use Sabre\VObject\Parser\Parser as VObjectParser; use Sabre\VObject\Reader as VObjectReader; use Sabre\VObject\Splitter\VCard as VCardSplitter; use Sabre\VObject\UUIDUtil; -use Safe\Exceptions\ArrayException; -use Safe\Exceptions\StringsException; +use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use Throwable; +use function sort; +use function substr; -class ContactsMigrator implements IMigrator { +class ContactsMigrator implements IMigrator, ISizeEstimationMigrator { use TMigratorBasicVersionHandling; - private CardDavBackend $cardDavBackend; - - private IL10N $l10n; - private SabreDavServer $sabreDavServer; private const USERS_URI_ROOT = 'principals/users/'; @@ -71,12 +50,9 @@ class ContactsMigrator implements IMigrator { private const PATH_ROOT = Application::APP_ID . '/address_books/'; public function __construct( - CardDavBackend $cardDavBackend, - IL10N $l10n + private CardDavBackend $cardDavBackend, + private IL10N $l10n, ) { - $this->cardDavBackend = $cardDavBackend; - $this->l10n = $l10n; - $root = new RootCollection(); $this->sabreDavServer = new SabreDavServer(new CachingTree($root)); $this->sabreDavServer->addPlugin(new CardDAVPlugin()); @@ -129,6 +105,10 @@ class ContactsMigrator implements IMigrator { } } + if (count($vCards) === 0) { + throw new InvalidAddressBookException(); + } + return [ 'name' => $addressBookNode->getName(), 'displayName' => $addressBookInfo['{DAV:}displayname'], @@ -156,15 +136,18 @@ class ContactsMigrator implements IMigrator { ))); } + /** + * @throws InvalidAddressBookException + */ private function getUniqueAddressBookUri(IUser $user, string $initialAddressBookUri): string { $principalUri = $this->getPrincipalUri($user); - try { - $initialAddressBookUri = substr($initialAddressBookUri, 0, strlen(ContactsMigrator::MIGRATED_URI_PREFIX)) === ContactsMigrator::MIGRATED_URI_PREFIX - ? $initialAddressBookUri - : ContactsMigrator::MIGRATED_URI_PREFIX . $initialAddressBookUri; - } catch (StringsException $e) { - throw new ContactsMigratorException('Failed to get unique address book URI', 0, $e); + $initialAddressBookUri = substr($initialAddressBookUri, 0, strlen(ContactsMigrator::MIGRATED_URI_PREFIX)) === ContactsMigrator::MIGRATED_URI_PREFIX + ? $initialAddressBookUri + : ContactsMigrator::MIGRATED_URI_PREFIX . $initialAddressBookUri; + + if ($initialAddressBookUri === '') { + throw new InvalidAddressBookException(); } $existingAddressBookUris = array_map( @@ -196,6 +179,27 @@ class ContactsMigrator implements IMigrator { /** * {@inheritDoc} */ + public function getEstimatedExportSize(IUser $user): int|float { + $addressBookExports = $this->getAddressBookExports($user, new NullOutput()); + $addressBookCount = count($addressBookExports); + + // 50B for each metadata JSON + $size = ($addressBookCount * 50) / 1024; + + $contactsCount = array_sum(array_map( + fn (array $data): int => count($data['vCards']), + $addressBookExports, + )); + + // 350B for each contact + $size += ($contactsCount * 350) / 1024; + + return ceil($size); + } + + /** + * {@inheritDoc} + */ public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { $output->writeln('Exporting contacts into ' . ContactsMigrator::PATH_ROOT . '…'); @@ -221,7 +225,7 @@ class ContactsMigrator implements IMigrator { $exportDestination->addFileContents($exportPath, $this->serializeCards($vCards)); $metadata = array_filter(['displayName' => $displayName, 'description' => $description]); - $exportDestination->addFileContents($metadataExportPath, json_encode($metadata)); + $exportDestination->addFileContents($metadataExportPath, json_encode($metadata, JSON_THROW_ON_ERROR)); } } catch (Throwable $e) { throw new CalendarMigratorException('Could not export address book', 0, $e); @@ -240,13 +244,15 @@ class ContactsMigrator implements IMigrator { $vCard->serialize(), ); } catch (Throwable $e) { - $output->writeln("Error creating contact \"" . ($vCard->FN ?? 'null') . "\" from \"$filename\", skipping…"); + $output->writeln('Error creating contact "' . ($vCard->FN ?? 'null') . "\" from \"$filename\", skipping…"); } } /** * @param array{displayName: string, description?: string} $metadata * @param VCard[] $vCards + * + * @throws InvalidAddressBookException */ private function importAddressBook(IUser $user, string $filename, string $initialAddressBookUri, array $metadata, array $vCards, OutputInterface $output): void { $principalUri = $this->getPrincipalUri($user); @@ -276,11 +282,10 @@ class ContactsMigrator implements IMigrator { fn (string $filename) => pathinfo($filename, PATHINFO_EXTENSION) === ContactsMigrator::METADATA_EXT, ); - try { - sort($addressBookImports); - sort($metadataImports); - } catch (ArrayException $e) { - throw new ContactsMigratorException('Failed to sort address book files in ' . ContactsMigrator::PATH_ROOT, 0, $e); + $addressBookSort = sort($addressBookImports); + $metadataSort = sort($metadataImports); + if ($addressBookSort === false || $metadataSort === false) { + throw new ContactsMigratorException('Failed to sort address book files in ' . ContactsMigrator::PATH_ROOT); } if (count($addressBookImports) !== count($metadataImports)) { @@ -342,24 +347,29 @@ class ContactsMigrator implements IMigrator { $splitFilename = explode('.', $addressBookFilename, 2); if (count($splitFilename) !== 2) { - throw new ContactsMigratorException("Invalid filename \"$addressBookFilename\", expected filename of the format \"<address_book_name>." . ContactsMigrator::FILENAME_EXT . '"'); + $output->writeln("Invalid filename \"$addressBookFilename\", expected filename of the format \"<address_book_name>." . ContactsMigrator::FILENAME_EXT . '", skipping…'); + continue; } [$initialAddressBookUri, $ext] = $splitFilename; /** @var array{displayName: string, description?: string} $metadata */ $metadata = json_decode($importSource->getFileContents($metadataImportPath), true, 512, JSON_THROW_ON_ERROR); - $this->importAddressBook( - $user, - $addressBookFilename, - $initialAddressBookUri, - $metadata, - $vCards, - $output, - ); - - foreach ($vCards as $vCard) { - $vCard->destroy(); + try { + $this->importAddressBook( + $user, + $addressBookFilename, + $initialAddressBookUri, + $metadata, + $vCards, + $output, + ); + } catch (InvalidAddressBookException $e) { + // Allow this exception to skip a failed import + } finally { + foreach ($vCards as $vCard) { + $vCard->destroy(); + } } } } diff --git a/apps/dav/lib/UserMigration/ContactsMigratorException.php b/apps/dav/lib/UserMigration/ContactsMigratorException.php index 63dedebf73d..8d64d3d4428 100644 --- a/apps/dav/lib/UserMigration/ContactsMigratorException.php +++ b/apps/dav/lib/UserMigration/ContactsMigratorException.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2022 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\UserMigration; diff --git a/apps/dav/lib/UserMigration/InvalidAddressBookException.php b/apps/dav/lib/UserMigration/InvalidAddressBookException.php index fd99eac1a73..d904dd9d4dd 100644 --- a/apps/dav/lib/UserMigration/InvalidAddressBookException.php +++ b/apps/dav/lib/UserMigration/InvalidAddressBookException.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2022 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\UserMigration; diff --git a/apps/dav/lib/UserMigration/InvalidCalendarException.php b/apps/dav/lib/UserMigration/InvalidCalendarException.php index 0e42ef1bc20..664989de8b1 100644 --- a/apps/dav/lib/UserMigration/InvalidCalendarException.php +++ b/apps/dav/lib/UserMigration/InvalidCalendarException.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2022 Christopher Ng <chrng8@gmail.com> - * - * @author Christopher Ng <chrng8@gmail.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: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\UserMigration; |