diff options
Diffstat (limited to 'apps/dav/lib')
25 files changed, 796 insertions, 178 deletions
diff --git a/apps/dav/lib/CalDAV/Calendar.php b/apps/dav/lib/CalDAV/Calendar.php index dd3a4cf3f69..deb00caa93d 100644 --- a/apps/dav/lib/CalDAV/Calendar.php +++ b/apps/dav/lib/CalDAV/Calendar.php @@ -36,7 +36,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable public function __construct( BackendInterface $caldavBackend, - $calendarInfo, + array $calendarInfo, IL10N $l10n, private IConfig $config, private LoggerInterface $logger, @@ -60,6 +60,10 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable $this->l10n = $l10n; } + public function getUri(): string { + return $this->calendarInfo['uri']; + } + /** * {@inheritdoc} * @throws Forbidden diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php index 3cc4039ed36..a8b818e59aa 100644 --- a/apps/dav/lib/CalDAV/CalendarProvider.php +++ b/apps/dav/lib/CalDAV/CalendarProvider.php @@ -36,9 +36,14 @@ class CalendarProvider implements ICalendarProvider { }); } + $additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos); $iCalendars = []; foreach ($calendarInfos as $calendarInfo) { - $calendarInfo = array_merge($calendarInfo, $this->getAdditionalProperties($calendarInfo['principaluri'], $calendarInfo['uri'])); + $user = str_replace('principals/users/', '', $calendarInfo['principaluri']); + $path = 'calendars/' . $user . '/' . $calendarInfo['uri']; + + $calendarInfo = array_merge($calendarInfo, $additionalProperties[$path] ?? []); + $calendar = new Calendar($this->calDavBackend, $calendarInfo, $this->l10n, $this->config, $this->logger); $iCalendars[] = new CalendarImpl( $calendar, @@ -49,16 +54,34 @@ class CalendarProvider implements ICalendarProvider { return $iCalendars; } - public function getAdditionalProperties(string $principalUri, string $calendarUri): array { - $user = str_replace('principals/users/', '', $principalUri); - $path = 'calendars/' . $user . '/' . $calendarUri; + /** + * @param array{ + * principaluri: string, + * uri: string, + * }[] $uris + * @return array<string, array<string, string|bool>> + */ + private function getAdditionalPropertiesForCalendars(array $uris): array { + $calendars = []; + foreach ($uris as $uri) { + /** @var string $user */ + $user = str_replace('principals/users/', '', $uri['principaluri']); + if (!array_key_exists($user, $calendars)) { + $calendars[$user] = []; + } + $calendars[$user][] = 'calendars/' . $user . '/' . $uri['uri']; + } - $properties = $this->propertyMapper->findPropertiesByPath($user, $path); + $properties = $this->propertyMapper->findPropertiesByPathsAndUsers($calendars); $list = []; foreach ($properties as $property) { if ($property instanceof Property) { - $list[$property->getPropertyname()] = match ($property->getPropertyname()) { + if (!isset($list[$property->getPropertypath()])) { + $list[$property->getPropertypath()] = []; + } + + $list[$property->getPropertypath()][$property->getPropertyname()] = match ($property->getPropertyname()) { '{http://owncloud.org/ns}calendar-enabled' => (bool)$property->getPropertyvalue(), default => $property->getPropertyvalue() }; diff --git a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php index 21d8c06fa99..d9d6d840c5e 100644 --- a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php +++ b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php @@ -20,6 +20,7 @@ use OCA\DAV\Connector\Sabre\DavAclPlugin; use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin; use OCA\DAV\Connector\Sabre\LockPlugin; use OCA\DAV\Connector\Sabre\MaintenancePlugin; +use OCA\DAV\Connector\Sabre\PropFindPreloadNotifyPlugin; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\DAV\RootCollection; use OCA\Theming\ThemingDefaults; @@ -96,6 +97,9 @@ class EmbeddedCalDavServer { $this->server->addPlugin(Server::get(\OCA\DAV\CalDAV\Schedule\IMipPlugin::class)); } + // collection preload plugin + $this->server->addPlugin(new PropFindPreloadNotifyPlugin()); + // wait with registering these until auth is handled and the filesystem is setup $this->server->on('beforeMethod:*', function () use ($root): void { // register plugins from apps diff --git a/apps/dav/lib/CardDAV/AddressBook.php b/apps/dav/lib/CardDAV/AddressBook.php index d2391880585..4d30d507a7d 100644 --- a/apps/dav/lib/CardDAV/AddressBook.php +++ b/apps/dav/lib/CardDAV/AddressBook.php @@ -8,7 +8,6 @@ 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; @@ -234,9 +233,6 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMov } public function getChanges($syncToken, $syncLevel, $limit = null) { - if (!$syncToken && $limit) { - throw new UnsupportedLimitOnInitialSyncException(); - } return parent::getChanges($syncToken, $syncLevel, $limit); } diff --git a/apps/dav/lib/CardDAV/AddressBookImpl.php b/apps/dav/lib/CardDAV/AddressBookImpl.php index 6bb8e24f628..ae77498539b 100644 --- a/apps/dav/lib/CardDAV/AddressBookImpl.php +++ b/apps/dav/lib/CardDAV/AddressBookImpl.php @@ -152,6 +152,10 @@ class AddressBookImpl implements IAddressBookEnabled { $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; diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php index 06bd8d8ee2c..a78686eb61d 100644 --- a/apps/dav/lib/CardDAV/CardDavBackend.php +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -23,6 +23,7 @@ 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\IUserManager; use PDO; @@ -59,6 +60,7 @@ class CardDavBackend implements BackendInterface, SyncSupport { private IUserManager $userManager, private IEventDispatcher $dispatcher, private Sharing\Backend $sharingBackend, + private IConfig $config, ) { } @@ -851,6 +853,8 @@ 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 return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) { $qb = $this->db->getQueryBuilder(); @@ -873,10 +877,35 @@ class CardDavBackend implements BackendInterface, SyncSupport { 'modified' => [], 'deleted' => [], ]; - - if ($syncToken) { + if (str_starts_with($syncToken, 'init_')) { + $syncValues = explode('_', $syncToken); + $lastID = $syncValues[1]; + $initialSyncToken = $syncValues[2]; $qb = $this->db->getQueryBuilder(); - $qb->select('uri', 'operation') + $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( @@ -886,22 +915,31 @@ class CardDavBackend implements BackendInterface, SyncSupport { ) )->orderBy('synctoken'); - if (is_int($limit) && $limit > 0) { + if ($limit > 0) { $qb->setMaxResults($limit); } // Fetching all changes $stmt = $qb->executeQuery(); + $rowCount = $stmt->rowCount(); $changes = []; + $highestSyncToken = 0; // 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: @@ -915,16 +953,43 @@ class CardDavBackend implements BackendInterface, SyncSupport { 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('uri') + $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(); - $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); + $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(); } return $result; diff --git a/apps/dav/lib/CardDAV/SyncService.php b/apps/dav/lib/CardDAV/SyncService.php index 4a75f8ced6c..e6da3ed5923 100644 --- a/apps/dav/lib/CardDAV/SyncService.php +++ b/apps/dav/lib/CardDAV/SyncService.php @@ -22,6 +22,7 @@ use Psr\Log\LoggerInterface; use Sabre\DAV\Xml\Response\MultiStatus; use Sabre\DAV\Xml\Service; use Sabre\VObject\Reader; +use Sabre\Xml\ParseException; use function is_null; class SyncService { @@ -43,9 +44,10 @@ class SyncService { } /** + * @psalm-return list{0: ?string, 1: boolean} * @throws \Exception */ - public function syncRemoteAddressBook(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken, string $targetBookHash, string $targetPrincipal, array $targetProperties): string { + 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, $targetBookHash, $targetProperties); $addressBookId = $book['id']; @@ -83,7 +85,10 @@ class SyncService { } } - return $response['token']; + return [ + $response['token'], + $response['truncated'], + ]; } /** @@ -127,7 +132,7 @@ class SyncService { private function prepareUri(string $host, string $path): string { /* - * The trailing slash is important for merging the uris together. + * The trailing slash is important for merging the uris. * * $host is stored in oc_trusted_servers.url and usually without a trailing slash. * @@ -158,7 +163,9 @@ class SyncService { } /** + * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool} * @throws ClientExceptionInterface + * @throws ParseException */ protected function requestSyncReport(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken): array { $client = $this->clientService->newClient(); @@ -181,7 +188,7 @@ class SyncService { $body = $response->getBody(); assert(is_string($body)); - return $this->parseMultiStatus($body); + return $this->parseMultiStatus($body, $addressBookUrl); } protected function download(string $url, string $userName, string $sharedSecret, string $resourcePath): string { @@ -219,22 +226,50 @@ 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, '/') + ); } /** diff --git a/apps/dav/lib/CardDAV/SystemAddressbook.php b/apps/dav/lib/CardDAV/SystemAddressbook.php index e0032044e70..912a2f1dcee 100644 --- a/apps/dav/lib/CardDAV/SystemAddressbook.php +++ b/apps/dav/lib/CardDAV/SystemAddressbook.php @@ -8,7 +8,6 @@ declare(strict_types=1); */ namespace OCA\DAV\CardDAV; -use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException; use OCA\Federation\TrustedServers; use OCP\Accounts\IAccountManager; use OCP\IConfig; @@ -212,14 +211,7 @@ class SystemAddressbook extends AddressBook { } return new Card($this->carddavBackend, $this->addressBookInfo, $obj); } - - /** - * @throws UnsupportedLimitOnInitialSyncException - */ public function getChanges($syncToken, $syncLevel, $limit = null) { - if (!$syncToken && $limit) { - throw new UnsupportedLimitOnInitialSyncException(); - } if (!$this->carddavBackend instanceof SyncSupport) { return null; diff --git a/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php b/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php index 44430b0004e..21358406a4a 100644 --- a/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php +++ b/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php @@ -49,7 +49,7 @@ class BlockLegacyClientPlugin extends ServerPlugin { return; } - $minimumSupportedDesktopVersion = $this->config->getSystemValueString('minimum.supported.desktop.version', '2.7.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 diff --git a/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php index e4b6c2636da..ef9bd1ae472 100644 --- a/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php @@ -10,6 +10,7 @@ namespace OCA\DAV\Connector\Sabre; use OCP\Comments\ICommentsManager; use OCP\IUserSession; +use Sabre\DAV\ICollection; use Sabre\DAV\PropFind; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; @@ -21,6 +22,7 @@ class CommentPropertiesPlugin extends ServerPlugin { protected ?Server $server = null; private array $cachedUnreadCount = []; + private array $cachedDirectories = []; public function __construct( private ICommentsManager $commentsManager, @@ -41,6 +43,8 @@ class CommentPropertiesPlugin extends ServerPlugin { */ public function initialize(\Sabre\DAV\Server $server) { $this->server = $server; + + $this->server->on('preloadCollection', $this->preloadCollection(...)); $this->server->on('propFind', [$this, 'handleGetProperties']); } @@ -69,6 +73,21 @@ class CommentPropertiesPlugin extends ServerPlugin { } } + private function preloadCollection(PropFind $propFind, ICollection $collection): + void { + if (!($collection instanceof Directory)) { + return; + } + + $collectionPath = $collection->getPath(); + if (!isset($this->cachedDirectories[$collectionPath]) && $propFind->getStatus( + self::PROPERTY_NAME_UNREAD + ) !== null) { + $this->cacheDirectory($collection); + $this->cachedDirectories[$collectionPath] = true; + } + } + /** * Adds tags and favorites properties to the response, * if requested. @@ -85,14 +104,6 @@ class CommentPropertiesPlugin extends ServerPlugin { return; } - // need prefetch ? - 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): int { return $this->commentsManager->getNumberOfCommentsForObject('files', (string)$node->getId()); }); diff --git a/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php new file mode 100644 index 00000000000..38538fdcff0 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php @@ -0,0 +1,82 @@ +<?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; + } + + $logger = $this->server->getLogger(); + foreach ($pluginQueries as $eventName => $eventQueries) { + $maxDepth = max(0, ...array_keys($eventQueries)); + // entries at the top are usually not interesting + unset($eventQueries[$maxDepth]); + foreach ($eventQueries 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}:{event} 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, + 'event' => $eventName, + ] + ); + } + } + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php b/apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php new file mode 100644 index 00000000000..c7b0c64132c --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php @@ -0,0 +1,55 @@ +<?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\ICollection; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; + +/** + * This plugin asks other plugins to preload data for a collection, so that + * subsequent PROPFIND handlers for children do not query the DB on a per-node + * basis. + */ +class PropFindPreloadNotifyPlugin extends ServerPlugin { + + private Server $server; + + public function initialize(Server $server): void { + $this->server = $server; + $this->server->on('propFind', [$this, 'collectionPreloadNotifier' ], 1); + } + + /** + * Uses the server instance to emit a `preloadCollection` event to signal + * to interested plugins that a collection can be preloaded. + * + * NOTE: this can be emitted several times, so ideally every plugin + * should cache what they need and check if a cache exists before + * re-fetching. + */ + public function collectionPreloadNotifier(PropFind $propFind, INode $node): bool { + if (!$this->shouldPreload($propFind, $node)) { + return true; + } + + return $this->server->emit('preloadCollection', [$propFind, $node]); + } + + private function shouldPreload( + PropFind $propFind, + INode $node, + ): bool { + $depth = $propFind->getDepth(); + return $node instanceof ICollection + && ($depth === Server::DEPTH_INFINITY || $depth > 0); + } +} diff --git a/apps/dav/lib/Connector/Sabre/Server.php b/apps/dav/lib/Connector/Sabre/Server.php index f3bfac1d6e0..eef65000131 100644 --- a/apps/dav/lib/Connector/Sabre/Server.php +++ b/apps/dav/lib/Connector/Sabre/Server.php @@ -7,7 +7,11 @@ */ 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; @@ -22,6 +26,15 @@ class Server extends \Sabre\DAV\Server { /** @var CachingTree $tree */ /** + * Tracks queries done by plugins. + * @var array<string, array<int, array<string, array{nodes:int, + * queries:int}>>> The keys represent: event name, depth and plugin name + */ + private array $pluginQueries = []; + + public bool $debugEnabled = false; + + /** * @see \Sabre\DAV\Server */ public function __construct($treeOrNode = null) { @@ -30,6 +43,106 @@ class Server extends \Sabre\DAV\Server { $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 { + $pluginName = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['class'] ?? 'unknown'; + // The NotifyPlugin needs to be excluded as it emits the + // `preloadCollection` event, which causes many plugins run queries. + /** @psalm-suppress TypeDoesNotContainType */ + if ($pluginName === PropFindPreloadNotifyPlugin::class || ($eventName !== 'propFind' + && $eventName !== 'preloadCollection')) { + $parentFn($eventName, $callBack, $priority); + return; + } + + $callback = $this->getMonitoredCallback($callBack, $pluginName, $eventName); + + $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, + string $eventName, + ): callable { + return function (PropFind $propFind, INode $node) use ( + $callBack, + $pluginName, + $eventName, + ): bool { + $connection = \OCP\Server::get(Connection::class); + $queriesBefore = $connection->getStats()['executed']; + $result = $callBack($propFind, $node); + $queriesAfter = $connection->getStats()['executed']; + $this->trackPluginQueries( + $pluginName, + $eventName, + $queriesAfter - $queriesBefore, + $propFind->getDepth() + ); + + // many callbacks don't care about returning a bool + return $result ?? true; + }; + } + + /** + * Tracks the queries executed by a specific plugin. + */ + private function trackPluginQueries( + string $pluginName, + string $eventName, + int $queriesExecuted, + int $depth, + ): void { + // report only nodes which cause queries to the DB + if ($queriesExecuted === 0) { + return; + } + + $this->pluginQueries[$eventName][$depth][$pluginName]['nodes'] + = ($this->pluginQueries[$eventName][$depth][$pluginName]['nodes'] ?? 0) + 1; + + $this->pluginQueries[$eventName][$depth][$pluginName]['queries'] + = ($this->pluginQueries[$eventName][$depth][$pluginName]['queries'] ?? 0) + $queriesExecuted; + } + /** * * @return void @@ -115,4 +228,13 @@ class Server extends \Sabre\DAV\Server { $this->sapi->sendResponse($this->httpResponse); } } + + /** + * Returns queries executed by registered plugins. + * @return array<string, array<int, array<string, array{nodes:int, + * queries:int}>>> The keys represent: event name, depth and plugin name + */ + 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 3749b506d16..1b4de841ec6 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -14,6 +14,7 @@ use OCA\DAV\CalDAV\DefaultCalendarValidator; use OCA\DAV\CalDAV\Proxy\ProxyMapper; use OCA\DAV\DAV\CustomPropertiesBackend; use OCA\DAV\DAV\ViewOnlyPlugin; +use OCA\DAV\Db\PropertyMapper; use OCA\DAV\Files\BrowserErrorPagePlugin; use OCA\DAV\Files\Sharing\RootCollection; use OCA\DAV\Upload\CleanupService; @@ -68,6 +69,7 @@ class ServerFactory { Plugin $authPlugin, callable $viewCallBack, ): Server { + $debugEnabled = $this->config->getSystemValue('debug', false); // Fire up server if ($isPublicShare) { $rootCollection = new SimpleCollection('root'); @@ -89,6 +91,12 @@ class ServerFactory { )); $server->addPlugin(new AnonymousOptionsPlugin()); $server->addPlugin($authPlugin); + if ($debugEnabled) { + $server->debugEnabled = $debugEnabled; + $server->addPlugin(new PropFindMonitorPlugin()); + } + + $server->addPlugin(new PropFindPreloadNotifyPlugin()); // FIXME: The following line is a workaround for legacy components relying on being able to send a GET to / $server->addPlugin(new DummyGetResponsePlugin()); $server->addPlugin(new ExceptionLoggerPlugin('webdav', $this->logger)); @@ -117,7 +125,8 @@ class ServerFactory { } // wait with registering these until auth is handled and the filesystem is setup - $server->on('beforeMethod:*', function () use ($server, $tree, $viewCallBack, $isPublicShare, $rootCollection): void { + $server->on('beforeMethod:*', function () use ($server, $tree, + $viewCallBack, $isPublicShare, $rootCollection, $debugEnabled): void { // ensure the skeleton is copied $userFolder = \OC::$server->getUserFolder(); @@ -181,7 +190,7 @@ class ServerFactory { \OCP\Server::get(IFilenameValidator::class), \OCP\Server::get(IAccountManager::class), false, - !$this->config->getSystemValue('debug', false) + !$debugEnabled ) ); $server->addPlugin(new QuotaPlugin($view)); @@ -220,6 +229,7 @@ class ServerFactory { $tree, $this->databaseConnection, $this->userSession->getUser(), + \OCP\Server::get(PropertyMapper::class), \OCP\Server::get(DefaultCalendarValidator::class), ) ) diff --git a/apps/dav/lib/Connector/Sabre/SharesPlugin.php b/apps/dav/lib/Connector/Sabre/SharesPlugin.php index f49e85333f3..11e50362dc2 100644 --- a/apps/dav/lib/Connector/Sabre/SharesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/SharesPlugin.php @@ -15,6 +15,7 @@ use OCP\Files\NotFoundException; use OCP\IUserSession; use OCP\Share\IManager; use OCP\Share\IShare; +use Sabre\DAV\ICollection; use Sabre\DAV\PropFind; use Sabre\DAV\Server; use Sabre\DAV\Tree; @@ -38,7 +39,14 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { /** @var IShare[][] */ private array $cachedShares = []; - /** @var string[] */ + + /** + * Tracks which folders have been cached. + * When a folder is cached, it will appear with its path as key and true + * as value. + * + * @var bool[] + */ private array $cachedFolders = []; public function __construct( @@ -67,6 +75,7 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { $server->protectedProperties[] = self::SHAREES_PROPERTYNAME; $this->server = $server; + $this->server->on('preloadCollection', $this->preloadCollection(...)); $this->server->on('propFind', [$this, 'handleGetProperties']); } @@ -89,28 +98,28 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { ]; foreach ($requestedShareTypes as $requestedShareType) { - $result = array_merge($result, $this->shareManager->getSharesBy( + $result[] = $this->shareManager->getSharesBy( $this->userId, $requestedShareType, $node, false, -1 - )); + ); // Also check for shares where the user is the recipient try { - $result = array_merge($result, $this->shareManager->getSharedWith( + $result[] = $this->shareManager->getSharedWith( $this->userId, $requestedShareType, $node, -1 - )); + ); } catch (BackendError $e) { // ignore } } - return $result; + return array_merge(...$result); } /** @@ -141,7 +150,7 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { // 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) { + if (!isset($this->cachedFolders[$parentPath])) { try { $node = $sabreNode->getNode(); } catch (NotFoundException $e) { @@ -156,6 +165,27 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { return []; } + private function preloadCollection(PropFind $propFind, ICollection $collection): void { + if (!$collection instanceof Directory + || isset($this->cachedFolders[$collection->getPath()]) + || ( + $propFind->getStatus(self::SHARETYPES_PROPERTYNAME) === null + && $propFind->getStatus(self::SHAREES_PROPERTYNAME) === null + ) + ) { + return; + } + + // 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. + $folderNode = $collection->getNode(); + $this->cachedFolders[$collection->getPath()] = true; + foreach ($this->getSharesFolder($folderNode) as $id => $shares) { + $this->cachedShares[$id] = $shares; + } + } + /** * Adds shares to propfind response * @@ -170,24 +200,6 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { return; } - // 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)) - ) - ) { - $folderNode = $sabreNode->getNode(); - $this->cachedFolders[] = $sabreNode->getPath(); - $childShares = $this->getSharesFolder($folderNode); - foreach ($childShares as $id => $shares) { - $this->cachedShares[$id] = $shares; - } - } - $propFind->handle(self::SHARETYPES_PROPERTYNAME, function () use ($sabreNode): ShareTypeList { $shares = $this->getShares($sabreNode); diff --git a/apps/dav/lib/Connector/Sabre/TagsPlugin.php b/apps/dav/lib/Connector/Sabre/TagsPlugin.php index 25c1633df36..ec3e6fc5320 100644 --- a/apps/dav/lib/Connector/Sabre/TagsPlugin.php +++ b/apps/dav/lib/Connector/Sabre/TagsPlugin.php @@ -31,6 +31,7 @@ use OCP\EventDispatcher\IEventDispatcher; use OCP\ITagManager; use OCP\ITags; use OCP\IUserSession; +use Sabre\DAV\ICollection; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; @@ -61,6 +62,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { * @var array */ private $cachedTags; + private array $cachedDirectories; /** * @param \Sabre\DAV\Tree $tree tree @@ -92,6 +94,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { $server->xml->elementMap[self::TAGS_PROPERTYNAME] = TagList::class; $this->server = $server; + $this->server->on('preloadCollection', $this->preloadCollection(...)); $this->server->on('propFind', [$this, 'handleGetProperties']); $this->server->on('propPatch', [$this, 'handleUpdateProperties']); $this->server->on('preloadProperties', [$this, 'handlePreloadProperties']); @@ -194,6 +197,29 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { } } + private function preloadCollection(PropFind $propFind, ICollection $collection): + void { + if (!($collection instanceof Node)) { + return; + } + + // need prefetch ? + if ($collection instanceof Directory + && !isset($this->cachedDirectories[$collection->getPath()]) + && (!is_null($propFind->getStatus(self::TAGS_PROPERTYNAME)) + || !is_null($propFind->getStatus(self::FAVORITE_PROPERTYNAME)) + )) { + // note: pre-fetching only supported for depth <= 1 + $folderContent = $collection->getChildren(); + $fileIds = [(int)$collection->getId()]; + foreach ($folderContent as $info) { + $fileIds[] = (int)$info->getId(); + } + $this->prefetchTagsForFileIds($fileIds); + $this->cachedDirectories[$collection->getPath()] = true; + } + } + /** * Adds tags and favorites properties to the response, * if requested. @@ -210,21 +236,6 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { return; } - // need prefetch ? - 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()]; - foreach ($folderContent as $info) { - $fileIds[] = (int)$info->getId(); - } - $this->prefetchTagsForFileIds($fileIds); - } - $isFav = null; $propFind->handle(self::TAGS_PROPERTYNAME, function () use (&$isFav, $node) { diff --git a/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php index 220988caba0..f198519b454 100644 --- a/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php +++ b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php @@ -67,15 +67,16 @@ class ZipFolderPlugin extends ServerPlugin { // 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(), $node->getMTime()); + $streamer->addFileFromStream($resource, $filename, $node->getSize(), $mtime); } elseif ($node instanceof NcFolder) { - $streamer->addEmptyDir($filename); + $streamer->addEmptyDir($filename, $mtime); $content = $node->getDirectoryListing(); foreach ($content as $subNode) { $this->streamNode($streamer, $subNode, $rootPath); diff --git a/apps/dav/lib/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php index f3fff11b3da..be7345f25df 100644 --- a/apps/dav/lib/DAV/CustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php @@ -9,13 +9,20 @@ namespace OCA\DAV\DAV; use Exception; +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\Integration\ExternalCalendar; +use OCA\DAV\CalDAV\Outbox; +use OCA\DAV\CalDAV\Trashbin\TrashbinHome; use OCA\DAV\Connector\Sabre\Directory; -use OCA\DAV\Connector\Sabre\FilesPlugin; +use OCA\DAV\Db\PropertyMapper; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\IUser; +use Sabre\CalDAV\Schedule\Inbox; use Sabre\DAV\Exception as DavException; use Sabre\DAV\PropertyStorage\Backend\BackendInterface; use Sabre\DAV\PropFind; @@ -66,38 +73,16 @@ 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', - // photos - '{http://nextcloud.org/ns}realpath', - '{http://nextcloud.org/ns}nbItems', - '{http://nextcloud.org/ns}face-detections', - '{http://nextcloud.org/ns}face-preview-image', + ]; + + /** + * Allowed properties for the oc/nc namespace, all other properties in the namespace are ignored + * + * @var string[] + */ + private const ALLOWED_NC_PROPERTIES = [ + '{http://owncloud.org/ns}calendar-enabled', + '{http://owncloud.org/ns}enabled', ]; /** @@ -119,11 +104,17 @@ class CustomPropertiesBackend implements BackendInterface { ]; /** + * Map of well-known property names to default values + */ + private const PROPERTY_DEFAULT_VALUES = [ + '{http://owncloud.org/ns}calendar-enabled' => '1', + ]; + + /** * Properties cache - * - * @var array */ - private $userCache = []; + private array $userCache = []; + private array $publishedCache = []; private XmlService $xmlService; /** @@ -136,6 +127,7 @@ class CustomPropertiesBackend implements BackendInterface { private Tree $tree, private IDBConnection $connection, private IUser $user, + private PropertyMapper $propertyMapper, private DefaultCalendarValidator $defaultCalendarValidator, ) { $this->xmlService = new XmlService(); @@ -155,14 +147,9 @@ class CustomPropertiesBackend implements BackendInterface { public function propFind($path, PropFind $propFind) { $requestedProps = $propFind->get404Properties(); - // these might appear - $requestedProps = array_diff( - $requestedProps, - self::IGNORED_PROPERTIES, - ); $requestedProps = array_filter( $requestedProps, - fn ($prop) => !str_starts_with($prop, FilesPlugin::FILE_METADATA_PREFIX), + $this->isPropertyAllowed(...), ); // substr of calendars/ => path is inside the CalDAV component @@ -224,6 +211,18 @@ class CustomPropertiesBackend implements BackendInterface { $this->cacheDirectory($path, $node); } + if ($node instanceof CalendarHome && $propFind->getDepth() !== 0) { + $backend = $node->getCalDAVBackend(); + if ($backend instanceof CalDavBackend) { + $this->cacheCalendars($node, $requestedProps); + } + } + + 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) { @@ -244,6 +243,16 @@ class CustomPropertiesBackend implements BackendInterface { } } + 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 * @@ -328,6 +337,10 @@ class CustomPropertiesBackend implements BackendInterface { return []; } + if (isset($this->publishedCache[$path])) { + return $this->publishedCache[$path]; + } + $qb = $this->connection->getQueryBuilder(); $qb->select('*') ->from(self::TABLE_NAME) @@ -338,6 +351,7 @@ class CustomPropertiesBackend implements BackendInterface { $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']); } $result->closeCursor(); + $this->publishedCache[$path] = $props; return $props; } @@ -376,6 +390,62 @@ class CustomPropertiesBackend implements BackendInterface { $this->userCache = array_merge($this->userCache, $propsByPath); } + private function cacheCalendars(CalendarHome $node, array $requestedProperties): void { + $calendars = $node->getChildren(); + + $users = []; + foreach ($calendars as $calendar) { + if ($calendar instanceof Calendar) { + $user = str_replace('principals/users/', '', $calendar->getPrincipalURI()); + if (!isset($users[$user])) { + $users[$user] = ['calendars/' . $user]; + } + $users[$user][] = 'calendars/' . $user . '/' . $calendar->getUri(); + } elseif ($calendar instanceof Inbox || $calendar instanceof Outbox || $calendar instanceof TrashbinHome || $calendar instanceof ExternalCalendar) { + if ($calendar->getOwner()) { + $user = str_replace('principals/users/', '', $calendar->getOwner()); + if (!isset($users[$user])) { + $users[$user] = ['calendars/' . $user]; + } + $users[$user][] = 'calendars/' . $user . '/' . $calendar->getName(); + } + } + } + + // user properties + $properties = $this->propertyMapper->findPropertiesByPathsAndUsers($users); + + $propsByPath = []; + foreach ($users as $paths) { + foreach ($paths as $path) { + $propsByPath[$path] = []; + } + } + + foreach ($properties as $property) { + $propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype()); + } + $this->userCache = array_merge($this->userCache, $propsByPath); + + // published properties + $allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties); + if (empty($allowedProps)) { + return; + } + $paths = []; + foreach ($users as $nestedPaths) { + $paths = array_merge($paths, $nestedPaths); + } + $paths = array_unique($paths); + + $propsByPath = array_fill_keys(array_values($paths), []); + $properties = $this->propertyMapper->findPropertiesByPaths($paths, $allowedProps); + foreach ($properties as $property) { + $propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype()); + } + $this->publishedCache = array_merge($this->publishedCache, $propsByPath); + } + /** * Returns a list of properties for the given path and current user * @@ -422,6 +492,14 @@ class CustomPropertiesBackend implements BackendInterface { return $props; } + private function isPropertyDefaultValue(string $name, mixed $value): bool { + if (!isset(self::PROPERTY_DEFAULT_VALUES[$name])) { + return false; + } + + return self::PROPERTY_DEFAULT_VALUES[$name] === $value; + } + /** * @throws Exception */ @@ -438,8 +516,8 @@ class CustomPropertiesBackend implements BackendInterface { 'propertyName' => $propertyName, ]; - // If it was null, we need to delete the property - if (is_null($propertyValue)) { + // If it was null or set to the default value, we need to delete the property + if (is_null($propertyValue) || $this->isPropertyDefaultValue($propertyName, $propertyValue)) { if (array_key_exists($propertyName, $existing)) { $deleteQuery = $deleteQuery ?? $this->createDeleteQuery(); $deleteQuery diff --git a/apps/dav/lib/DAV/Sharing/Plugin.php b/apps/dav/lib/DAV/Sharing/Plugin.php index 03e63813bab..82b000bc8ce 100644 --- a/apps/dav/lib/DAV/Sharing/Plugin.php +++ b/apps/dav/lib/DAV/Sharing/Plugin.php @@ -16,6 +16,7 @@ use OCP\AppFramework\Http; use OCP\IConfig; use OCP\IRequest; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; use Sabre\DAV\INode; use Sabre\DAV\PropFind; use Sabre\DAV\Server; @@ -89,6 +90,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('preloadCollection', $this->preloadCollection(...)); $this->server->on('propFind', [$this, 'propFind']); } @@ -168,6 +170,24 @@ class Plugin extends ServerPlugin { } } + private function preloadCollection(PropFind $propFind, ICollection $collection): void { + if (!$collection instanceof CalendarHome || $propFind->getDepth() !== 1) { + return; + } + + $backend = $collection->getCalDAVBackend(); + if (!$backend instanceof CalDavBackend) { + return; + } + + $calendars = $collection->getChildren(); + $calendars = array_filter($calendars, static fn (INode $node) => $node instanceof IShareable); + /** @var int[] $resourceIds */ + $resourceIds = array_map( + static fn (IShareable $node) => $node->getResourceId(), $calendars); + $backend->preloadShares($resourceIds); + } + /** * This event is triggered when properties are requested for a certain * node. @@ -179,20 +199,6 @@ 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/Db/Property.php b/apps/dav/lib/Db/Property.php index 96c5f75ef4f..6c1e249ac47 100644 --- a/apps/dav/lib/Db/Property.php +++ b/apps/dav/lib/Db/Property.php @@ -16,6 +16,7 @@ use OCP\AppFramework\Db\Entity; * @method string getPropertypath() * @method string getPropertyname() * @method string getPropertyvalue() + * @method int getValuetype() */ class Property extends Entity { diff --git a/apps/dav/lib/Db/PropertyMapper.php b/apps/dav/lib/Db/PropertyMapper.php index 1789194ee7a..a3dbdaa7d98 100644 --- a/apps/dav/lib/Db/PropertyMapper.php +++ b/apps/dav/lib/Db/PropertyMapper.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace OCA\DAV\Db; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; /** @@ -39,17 +40,43 @@ class PropertyMapper extends QBMapper { } /** + * @param array<string, string[]> $calendars * @return Property[] + * @throws \OCP\DB\Exception */ - public function findPropertiesByPath(string $userId, string $path): array { + public function findPropertiesByPathsAndUsers(array $calendars): 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)), + ->from(self::TABLE_NAME); + + foreach ($calendars as $user => $paths) { + $selectQb->orWhere( + $selectQb->expr()->andX( + $selectQb->expr()->eq('userid', $selectQb->createNamedParameter($user)), + $selectQb->expr()->in('propertypath', $selectQb->createNamedParameter($paths, IQueryBuilder::PARAM_STR_ARRAY)), + ) ); + } + return $this->findEntities($selectQb); } + /** + * @param string[] $calendars + * @param string[] $allowedProperties + * @return Property[] + * @throws \OCP\DB\Exception + */ + public function findPropertiesByPaths(array $calendars, array $allowedProperties = []): array { + $selectQb = $this->db->getQueryBuilder(); + $selectQb->select('*') + ->from(self::TABLE_NAME) + ->where($selectQb->expr()->in('propertypath', $selectQb->createNamedParameter($calendars, IQueryBuilder::PARAM_STR_ARRAY))); + + if ($allowedProperties) { + $selectQb->andWhere($selectQb->expr()->in('propertyname', $selectQb->createNamedParameter($allowedProperties, IQueryBuilder::PARAM_STR_ARRAY))); + } + + return $this->findEntities($selectQb); + } } diff --git a/apps/dav/lib/Migration/Version1034Date20250813093701.php b/apps/dav/lib/Migration/Version1034Date20250813093701.php new file mode 100644 index 00000000000..10be71f067b --- /dev/null +++ b/apps/dav/lib/Migration/Version1034Date20250813093701.php @@ -0,0 +1,53 @@ +<?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 Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; +use Override; + +class Version1034Date20250813093701 extends SimpleMigrationStep { + public function __construct( + private IDBConnection $db, + ) { + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + #[Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete('properties') + ->where($qb->expr()->eq( + 'propertyname', + $qb->createNamedParameter( + '{http://owncloud.org/ns}calendar-enabled', + IQueryBuilder::PARAM_STR, + ), + IQueryBuilder::PARAM_STR, + )) + ->andWhere($qb->expr()->eq( + 'propertyvalue', + $qb->createNamedParameter( + '1', + IQueryBuilder::PARAM_STR, + ), + IQueryBuilder::PARAM_STR, + )) + ->executeStatement(); + } +} diff --git a/apps/dav/lib/RootCollection.php b/apps/dav/lib/RootCollection.php index f1963c0ef01..870aa0d4540 100644 --- a/apps/dav/lib/RootCollection.php +++ b/apps/dav/lib/RootCollection.php @@ -132,6 +132,7 @@ class RootCollection extends SimpleCollection { ); $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( @@ -140,6 +141,7 @@ class RootCollection extends SimpleCollection { $userManager, $dispatcher, $contactsSharingBackend, + $config ); $usersAddressBookRoot = new AddressBookRoot($userPrincipalBackend, $usersCardDavBackend, $pluginManager, $userSession->getUser(), $groupManager, 'principals/users'); $usersAddressBookRoot->disableListing = $disableListing; @@ -150,6 +152,7 @@ class RootCollection extends SimpleCollection { $userManager, $dispatcher, $contactsSharingBackend, + $config ); $systemAddressBookRoot = new AddressBookRoot(new SystemPrincipalBackend(), $systemCardDavBackend, $pluginManager, $userSession->getUser(), $groupManager, 'principals/system'); $systemAddressBookRoot->disableListing = $disableListing; diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index f81c7fa6f29..9b4a1b3d33c 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -45,6 +45,8 @@ 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\PropFindPreloadNotifyPlugin; use OCA\DAV\Connector\Sabre\QuotaPlugin; use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin; use OCA\DAV\Connector\Sabre\SharesPlugin; @@ -53,6 +55,7 @@ use OCA\DAV\Connector\Sabre\ZipFolderPlugin; use OCA\DAV\DAV\CustomPropertiesBackend; use OCA\DAV\DAV\PublicAuth; use OCA\DAV\DAV\ViewOnlyPlugin; +use OCA\DAV\Db\PropertyMapper; use OCA\DAV\Events\SabrePluginAddEvent; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\DAV\Files\BrowserErrorPagePlugin; @@ -108,6 +111,7 @@ class Server { 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 */ @@ -120,6 +124,7 @@ class Server { $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 MaintenancePlugin(\OCP\Server::get(IConfig::class), \OC::$server->getL10N('dav'))); @@ -167,7 +172,9 @@ class Server { $authPlugin->addBackend($authBackend); // debugging - if (\OCP\Server::get(IConfig::class)->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()); @@ -232,6 +239,7 @@ class Server { \OCP\Server::get(IUserSession::class) )); + // performance improvement plugins $this->server->addPlugin(new CopyEtagHeaderPlugin()); $this->server->addPlugin(new RequestIdHeaderPlugin(\OCP\Server::get(IRequest::class))); $this->server->addPlugin(new UploadAutoMkcolPlugin()); @@ -243,6 +251,7 @@ class Server { $eventDispatcher, )); $this->server->addPlugin(\OCP\Server::get(PaginatePlugin::class)); + $this->server->addPlugin(new PropFindPreloadNotifyPlugin()); // allow setup of additional plugins $eventDispatcher->dispatch('OCA\DAV\Connector\Sabre::addPlugin', $event); @@ -301,6 +310,7 @@ class Server { $this->server->tree, \OCP\Server::get(IDBConnection::class), \OCP\Server::get(IUserSession::class)->getUser(), + \OCP\Server::get(PropertyMapper::class), \OCP\Server::get(DefaultCalendarValidator::class), ) ) diff --git a/apps/dav/lib/SystemTag/SystemTagPlugin.php b/apps/dav/lib/SystemTag/SystemTagPlugin.php index 4d4499c7559..6be3e8bd1a2 100644 --- a/apps/dav/lib/SystemTag/SystemTagPlugin.php +++ b/apps/dav/lib/SystemTag/SystemTagPlugin.php @@ -27,6 +27,7 @@ use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Conflict; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\UnsupportedMediaType; +use Sabre\DAV\ICollection; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; use Sabre\HTTP\RequestInterface; @@ -94,6 +95,7 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { $server->protectedProperties[] = self::ID_PROPERTYNAME; + $server->on('preloadCollection', $this->preloadCollection(...)); $server->on('propFind', [$this, 'handleGetProperties']); $server->on('propPatch', [$this, 'handleUpdateProperties']); $server->on('method:POST', [$this, 'httpPost']); @@ -199,6 +201,40 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { } } + private function preloadCollection( + PropFind $propFind, + ICollection $collection, + ): void { + if (!$collection instanceof Node) { + return; + } + + if ($collection instanceof Directory + && !isset($this->cachedTagMappings[$collection->getId()]) + && $propFind->getStatus( + self::SYSTEM_TAGS_PROPERTYNAME + ) !== null) { + $fileIds = [$collection->getId()]; + + // note: pre-fetching only supported for depth <= 1 + $folderContent = $collection->getChildren(); + foreach ($folderContent as $info) { + if ($info instanceof Node) { + $fileIds[] = $info->getId(); + } + } + + $tags = $this->tagMapper->getTagIdsForObjects($fileIds, 'files'); + + $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] = []; + } + } + } /** * Retrieves system tag properties @@ -297,29 +333,6 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { } 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(); |