diff options
Diffstat (limited to 'apps/dav')
-rw-r--r-- | apps/dav/composer/composer/autoload_classmap.php | 1 | ||||
-rw-r--r-- | apps/dav/composer/composer/autoload_static.php | 1 | ||||
-rw-r--r-- | apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php | 2 | ||||
-rw-r--r-- | apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php | 78 | ||||
-rw-r--r-- | apps/dav/lib/Connector/Sabre/Server.php | 112 | ||||
-rw-r--r-- | apps/dav/lib/Connector/Sabre/ServerFactory.php | 10 | ||||
-rw-r--r-- | apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php | 5 | ||||
-rw-r--r-- | apps/dav/lib/DAV/CustomPropertiesBackend.php | 66 | ||||
-rw-r--r-- | apps/dav/lib/Server.php | 7 | ||||
-rw-r--r-- | apps/dav/tests/unit/Connector/Sabre/PropFindMonitorPluginTest.php | 123 |
10 files changed, 360 insertions, 45 deletions
diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 764f94ef665..b9708ea5589 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -216,6 +216,7 @@ return array( 'OCA\\DAV\\Connector\\Sabre\\Node' => $baseDir . '/../lib/Connector/Sabre/Node.php', 'OCA\\DAV\\Connector\\Sabre\\ObjectTree' => $baseDir . '/../lib/Connector/Sabre/ObjectTree.php', 'OCA\\DAV\\Connector\\Sabre\\Principal' => $baseDir . '/../lib/Connector/Sabre/Principal.php', + 'OCA\\DAV\\Connector\\Sabre\\PropFindMonitorPlugin' => $baseDir . '/../lib/Connector/Sabre/PropFindMonitorPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\PropfindCompressionPlugin' => $baseDir . '/../lib/Connector/Sabre/PropfindCompressionPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\PublicAuth' => $baseDir . '/../lib/Connector/Sabre/PublicAuth.php', 'OCA\\DAV\\Connector\\Sabre\\QuotaPlugin' => $baseDir . '/../lib/Connector/Sabre/QuotaPlugin.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index f3d1eacfcd0..75ac3350160 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -231,6 +231,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Connector\\Sabre\\Node' => __DIR__ . '/..' . '/../lib/Connector/Sabre/Node.php', 'OCA\\DAV\\Connector\\Sabre\\ObjectTree' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ObjectTree.php', 'OCA\\DAV\\Connector\\Sabre\\Principal' => __DIR__ . '/..' . '/../lib/Connector/Sabre/Principal.php', + 'OCA\\DAV\\Connector\\Sabre\\PropFindMonitorPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/PropFindMonitorPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\PropfindCompressionPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/PropfindCompressionPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\PublicAuth' => __DIR__ . '/..' . '/../lib/Connector/Sabre/PublicAuth.php', 'OCA\\DAV\\Connector\\Sabre\\QuotaPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/QuotaPlugin.php', 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/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/Server.php b/apps/dav/lib/Connector/Sabre/Server.php index f3bfac1d6e0..dda9c29b763 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,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) { @@ -30,6 +42,97 @@ 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 { + 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 @@ -115,4 +218,13 @@ class Server extends \Sabre\DAV\Server { $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 3749b506d16..a6a27057177 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -68,6 +68,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 +90,10 @@ class ServerFactory { )); $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 DummyGetResponsePlugin()); $server->addPlugin(new ExceptionLoggerPlugin('webdav', $this->logger)); @@ -117,7 +122,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 +187,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)); 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..f9a4f8ee986 100644 --- a/apps/dav/lib/DAV/CustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php @@ -10,9 +10,9 @@ namespace OCA\DAV\DAV; use Exception; use OCA\DAV\CalDAV\Calendar; +use OCA\DAV\CalDAV\CalendarObject; use OCA\DAV\CalDAV\DefaultCalendarValidator; use OCA\DAV\Connector\Sabre\Directory; -use OCA\DAV\Connector\Sabre\FilesPlugin; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\IUser; @@ -66,38 +66,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', ]; /** @@ -155,14 +133,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 +197,11 @@ class CustomPropertiesBackend implements BackendInterface { $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) { @@ -244,6 +222,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 * diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index f81c7fa6f29..a92e162f1b0 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -45,6 +45,7 @@ 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; @@ -108,6 +109,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 +122,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 +170,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()); diff --git a/apps/dav/tests/unit/Connector/Sabre/PropFindMonitorPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/PropFindMonitorPluginTest.php new file mode 100644 index 00000000000..b528c3d731c --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/PropFindMonitorPluginTest.php @@ -0,0 +1,123 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace unit\Connector\Sabre; + +use OCA\DAV\Connector\Sabre\PropFindMonitorPlugin; +use OCA\DAV\Connector\Sabre\Server; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use Sabre\HTTP\Request; +use Sabre\HTTP\Response; +use Test\TestCase; + +class PropFindMonitorPluginTest extends TestCase { + + private PropFindMonitorPlugin $plugin; + private Server&MockObject $server; + private LoggerInterface&MockObject $logger; + private Request&MockObject $request; + private Response&MockObject $response; + + public static function dataTest(): array { + $minQueriesTrigger = PropFindMonitorPlugin::THRESHOLD_QUERY_FACTOR + * PropFindMonitorPlugin::THRESHOLD_NODES; + return [ + 'No queries logged' => [[], 0], + 'Plugins with queries in less than threshold nodes should not be logged' => [ + [ + [ + 'PluginName' => ['queries' => 100, 'nodes' + => PropFindMonitorPlugin::THRESHOLD_NODES - 1] + ], + [], + ], + 0 + ], + 'Plugins with query-to-node ratio less than threshold should not be logged' => [ + [ + [ + 'PluginName' => [ + 'queries' => $minQueriesTrigger - 1, + 'nodes' => PropFindMonitorPlugin::THRESHOLD_NODES ], + ], + [], + ], + 0 + ], + 'Plugins with more nodes scanned than queries executed should not be logged' => [ + [ + [ + 'PluginName' => [ + 'queries' => $minQueriesTrigger, + 'nodes' => PropFindMonitorPlugin::THRESHOLD_NODES * 2], + ], + [], + ], + 0 + ], + 'Plugins with queries only in highest depth level should not be logged' => [ + [ + [ + 'PluginName' => [ + 'queries' => $minQueriesTrigger, + 'nodes' => PropFindMonitorPlugin::THRESHOLD_NODES - 1 + ] + ], + [ + 'PluginName' => [ + 'queries' => $minQueriesTrigger * 2, + 'nodes' => PropFindMonitorPlugin::THRESHOLD_NODES + ] + ] + ], + 0 + ], + 'Plugins with too many queries should be logged' => [ + [ + [ + 'FirstPlugin' => [ + 'queries' => $minQueriesTrigger, + 'nodes' => PropFindMonitorPlugin::THRESHOLD_NODES, + ], + 'SecondPlugin' => [ + 'queries' => $minQueriesTrigger, + 'nodes' => PropFindMonitorPlugin::THRESHOLD_NODES, + ] + ], + [] + ], + 2 + ] + ]; + } + + /** + * @dataProvider dataTest + */ + public function test(array $queries, $expectedLogCalls): void { + $this->plugin->initialize($this->server); + $this->server->expects($this->once())->method('getPluginQueries') + ->willReturn($queries); + + $this->server->expects(empty($queries) ? $this->never() : $this->once()) + ->method('getLogger') + ->willReturn($this->logger); + + $this->logger->expects($this->exactly($expectedLogCalls))->method('error'); + $this->plugin->afterResponse($this->request, $this->response); + } + + protected function setUp(): void { + parent::setUp(); + + $this->plugin = new PropFindMonitorPlugin(); + $this->server = $this->createMock(Server::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->request = $this->createMock(Request::class); + $this->response = $this->createMock(Response::class); + } +} |