aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dav
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav')
-rw-r--r--apps/dav/appinfo/v1/carddav.php1
-rw-r--r--apps/dav/composer/composer/autoload_classmap.php1
-rw-r--r--apps/dav/composer/composer/autoload_static.php1
-rw-r--r--apps/dav/lib/CardDAV/AddressBook.php4
-rw-r--r--apps/dav/lib/CardDAV/AddressBookImpl.php4
-rw-r--r--apps/dav/lib/CardDAV/CardDavBackend.php77
-rw-r--r--apps/dav/lib/CardDAV/SyncService.php61
-rw-r--r--apps/dav/lib/CardDAV/SystemAddressbook.php8
-rw-r--r--apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php2
-rw-r--r--apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php78
-rw-r--r--apps/dav/lib/Connector/Sabre/Server.php112
-rw-r--r--apps/dav/lib/Connector/Sabre/ServerFactory.php10
-rw-r--r--apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php5
-rw-r--r--apps/dav/lib/DAV/CustomPropertiesBackend.php66
-rw-r--r--apps/dav/lib/RootCollection.php3
-rw-r--r--apps/dav/lib/Server.php7
-rw-r--r--apps/dav/tests/unit/CardDAV/AddressBookImplTest.php17
-rw-r--r--apps/dav/tests/unit/CardDAV/CardDavBackendTest.php21
-rw-r--r--apps/dav/tests/unit/CardDAV/SyncServiceTest.php8
-rw-r--r--apps/dav/tests/unit/Connector/Sabre/PropFindMonitorPluginTest.php123
20 files changed, 512 insertions, 97 deletions
diff --git a/apps/dav/appinfo/v1/carddav.php b/apps/dav/appinfo/v1/carddav.php
index bcd66e47090..415a5c9634a 100644
--- a/apps/dav/appinfo/v1/carddav.php
+++ b/apps/dav/appinfo/v1/carddav.php
@@ -63,6 +63,7 @@ $cardDavBackend = new CardDavBackend(
Server::get(IUserManager::class),
Server::get(IEventDispatcher::class),
Server::get(\OCA\DAV\CardDAV\Sharing\Backend::class),
+ Server::get(IConfig::class),
);
$debugging = Server::get(IConfig::class)->getSystemValue('debug', false);
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/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/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/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..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/CardDAV/AddressBookImplTest.php b/apps/dav/tests/unit/CardDAV/AddressBookImplTest.php
index f7daeb41cca..74699cf3925 100644
--- a/apps/dav/tests/unit/CardDAV/AddressBookImplTest.php
+++ b/apps/dav/tests/unit/CardDAV/AddressBookImplTest.php
@@ -241,14 +241,15 @@ class AddressBookImplTest extends TestCase {
public static function dataTestGetPermissions(): array {
return [
[[], 0],
- [[['privilege' => '{DAV:}read']], 1],
- [[['privilege' => '{DAV:}write']], 6],
- [[['privilege' => '{DAV:}all']], 31],
- [[['privilege' => '{DAV:}read'],['privilege' => '{DAV:}write']], 7],
- [[['privilege' => '{DAV:}read'],['privilege' => '{DAV:}all']], 31],
- [[['privilege' => '{DAV:}all'],['privilege' => '{DAV:}write']], 31],
- [[['privilege' => '{DAV:}read'],['privilege' => '{DAV:}write'],['privilege' => '{DAV:}all']], 31],
- [[['privilege' => '{DAV:}all'],['privilege' => '{DAV:}read'],['privilege' => '{DAV:}write']], 31],
+ [[['privilege' => '{DAV:}read', 'principal' => 'principals/system/system']], 1],
+ [[['privilege' => '{DAV:}read', 'principal' => 'principals/system/system'], ['privilege' => '{DAV:}write', 'principal' => 'principals/someone/else']], 1],
+ [[['privilege' => '{DAV:}write', 'principal' => 'principals/system/system']], 6],
+ [[['privilege' => '{DAV:}all', 'principal' => 'principals/system/system']], 31],
+ [[['privilege' => '{DAV:}read', 'principal' => 'principals/system/system'],['privilege' => '{DAV:}write', 'principal' => 'principals/system/system']], 7],
+ [[['privilege' => '{DAV:}read', 'principal' => 'principals/system/system'],['privilege' => '{DAV:}all', 'principal' => 'principals/system/system']], 31],
+ [[['privilege' => '{DAV:}all', 'principal' => 'principals/system/system'],['privilege' => '{DAV:}write', 'principal' => 'principals/system/system']], 31],
+ [[['privilege' => '{DAV:}read', 'principal' => 'principals/system/system'],['privilege' => '{DAV:}write', 'principal' => 'principals/system/system'],['privilege' => '{DAV:}all', 'principal' => 'principals/system/system']], 31],
+ [[['privilege' => '{DAV:}all', 'principal' => 'principals/system/system'],['privilege' => '{DAV:}read', 'principal' => 'principals/system/system'],['privilege' => '{DAV:}write', 'principal' => 'principals/system/system']], 31],
];
}
diff --git a/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php b/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php
index 1966a8d8c9a..c5eafa0764a 100644
--- a/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php
+++ b/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php
@@ -50,6 +50,7 @@ class CardDavBackendTest extends TestCase {
private IUserManager&MockObject $userManager;
private IGroupManager&MockObject $groupManager;
private IEventDispatcher&MockObject $dispatcher;
+ private IConfig&MockObject $config;
private Backend $sharingBackend;
private IDBConnection $db;
private CardDavBackend $backend;
@@ -96,6 +97,7 @@ class CardDavBackendTest extends TestCase {
$this->userManager = $this->createMock(IUserManager::class);
$this->groupManager = $this->createMock(IGroupManager::class);
+ $this->config = $this->createMock(IConfig::class);
$this->principal = $this->getMockBuilder(Principal::class)
->setConstructorArgs([
$this->userManager,
@@ -106,7 +108,7 @@ class CardDavBackendTest extends TestCase {
$this->createMock(IAppManager::class),
$this->createMock(ProxyMapper::class),
$this->createMock(KnownUserService::class),
- $this->createMock(IConfig::class),
+ $this->config,
$this->createMock(IFactory::class)
])
->onlyMethods(['getPrincipalByPath', 'getGroupMembership', 'findByUri'])
@@ -135,6 +137,7 @@ class CardDavBackendTest extends TestCase {
$this->userManager,
$this->dispatcher,
$this->sharingBackend,
+ $this->config,
);
// start every test with a empty cards_properties and cards table
$query = $this->db->getQueryBuilder();
@@ -231,7 +234,7 @@ class CardDavBackendTest extends TestCase {
public function testCardOperations(): void {
/** @var CardDavBackend&MockObject $backend */
$backend = $this->getMockBuilder(CardDavBackend::class)
- ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
+ ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend,$this->config])
->onlyMethods(['updateProperties', 'purgeProperties'])
->getMock();
@@ -291,7 +294,7 @@ class CardDavBackendTest extends TestCase {
public function testMultiCard(): void {
$this->backend = $this->getMockBuilder(CardDavBackend::class)
- ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
+ ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend,$this->config])
->onlyMethods(['updateProperties'])
->getMock();
@@ -345,7 +348,7 @@ class CardDavBackendTest extends TestCase {
public function testMultipleUIDOnDifferentAddressbooks(): void {
$this->backend = $this->getMockBuilder(CardDavBackend::class)
- ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
+ ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend,$this->config])
->onlyMethods(['updateProperties'])
->getMock();
@@ -368,7 +371,7 @@ class CardDavBackendTest extends TestCase {
public function testMultipleUIDDenied(): void {
$this->backend = $this->getMockBuilder(CardDavBackend::class)
- ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
+ ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend, $this->config])
->onlyMethods(['updateProperties'])
->getMock();
@@ -390,7 +393,7 @@ class CardDavBackendTest extends TestCase {
public function testNoValidUID(): void {
$this->backend = $this->getMockBuilder(CardDavBackend::class)
- ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
+ ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend, $this->config])
->onlyMethods(['updateProperties'])
->getMock();
@@ -408,7 +411,7 @@ class CardDavBackendTest extends TestCase {
public function testDeleteWithoutCard(): void {
$this->backend = $this->getMockBuilder(CardDavBackend::class)
- ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
+ ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend, $this->config])
->onlyMethods([
'getCardId',
'addChange',
@@ -453,7 +456,7 @@ class CardDavBackendTest extends TestCase {
public function testSyncSupport(): void {
$this->backend = $this->getMockBuilder(CardDavBackend::class)
- ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
+ ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend, $this->config])
->onlyMethods(['updateProperties'])
->getMock();
@@ -522,7 +525,7 @@ class CardDavBackendTest extends TestCase {
$cardId = 2;
$backend = $this->getMockBuilder(CardDavBackend::class)
- ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
+ ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend, $this->config])
->onlyMethods(['getCardId'])->getMock();
$backend->expects($this->any())->method('getCardId')->willReturn($cardId);
diff --git a/apps/dav/tests/unit/CardDAV/SyncServiceTest.php b/apps/dav/tests/unit/CardDAV/SyncServiceTest.php
index ea4886a67e6..77caed336f4 100644
--- a/apps/dav/tests/unit/CardDAV/SyncServiceTest.php
+++ b/apps/dav/tests/unit/CardDAV/SyncServiceTest.php
@@ -108,7 +108,7 @@ class SyncServiceTest extends TestCase {
'1',
'principals/system/system',
[]
- );
+ )[0];
$this->assertEquals('http://sabre.io/ns/sync/1', $token);
}
@@ -179,7 +179,7 @@ END:VCARD';
'1',
'principals/system/system',
[]
- );
+ )[0];
$this->assertEquals('http://sabre.io/ns/sync/2', $token);
}
@@ -250,7 +250,7 @@ END:VCARD';
'1',
'principals/system/system',
[]
- );
+ )[0];
$this->assertEquals('http://sabre.io/ns/sync/3', $token);
}
@@ -291,7 +291,7 @@ END:VCARD';
'1',
'principals/system/system',
[]
- );
+ )[0];
$this->assertEquals('http://sabre.io/ns/sync/4', $token);
}
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);
+ }
+}