aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/dav/appinfo/info.xml2
-rw-r--r--apps/dav/composer/composer/autoload_classmap.php2
-rw-r--r--apps/dav/composer/composer/autoload_static.php2
-rw-r--r--apps/dav/lib/CalDAV/Calendar.php6
-rw-r--r--apps/dav/lib/CalDAV/CalendarProvider.php35
-rw-r--r--apps/dav/lib/CalDAV/EmbeddedCalDavServer.php4
-rw-r--r--apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php27
-rw-r--r--apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php46
-rw-r--r--apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php55
-rw-r--r--apps/dav/lib/Connector/Sabre/Server.php42
-rw-r--r--apps/dav/lib/Connector/Sabre/ServerFactory.php4
-rw-r--r--apps/dav/lib/Connector/Sabre/SharesPlugin.php62
-rw-r--r--apps/dav/lib/Connector/Sabre/TagsPlugin.php41
-rw-r--r--apps/dav/lib/DAV/CustomPropertiesBackend.php100
-rw-r--r--apps/dav/lib/DAV/Sharing/Plugin.php34
-rw-r--r--apps/dav/lib/Db/Property.php1
-rw-r--r--apps/dav/lib/Db/PropertyMapper.php37
-rw-r--r--apps/dav/lib/Migration/Version1034Date20250813093701.php53
-rw-r--r--apps/dav/lib/Server.php5
-rw-r--r--apps/dav/lib/SystemTag/SystemTagPlugin.php59
-rw-r--r--apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php2
-rw-r--r--apps/dav/tests/unit/Connector/Sabre/PropFindMonitorPluginTest.php86
-rw-r--r--apps/dav/tests/unit/Connector/Sabre/PropFindPreloadNotifyPluginTest.php92
-rw-r--r--apps/dav/tests/unit/Connector/Sabre/SharesPluginTest.php1
-rw-r--r--apps/dav/tests/unit/Connector/Sabre/TagsPluginTest.php2
-rw-r--r--apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php5
-rw-r--r--apps/files_reminders/lib/Dav/PropFindPlugin.php23
27 files changed, 641 insertions, 187 deletions
diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml
index 9021ba98a0f..baf1021d3e6 100644
--- a/apps/dav/appinfo/info.xml
+++ b/apps/dav/appinfo/info.xml
@@ -10,7 +10,7 @@
<name>WebDAV</name>
<summary>WebDAV endpoint</summary>
<description>WebDAV endpoint</description>
- <version>1.34.0</version>
+ <version>1.34.1</version>
<licence>agpl</licence>
<author>owncloud.org</author>
<namespace>DAV</namespace>
diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php
index b9708ea5589..9eab0456159 100644
--- a/apps/dav/composer/composer/autoload_classmap.php
+++ b/apps/dav/composer/composer/autoload_classmap.php
@@ -217,6 +217,7 @@ return array(
'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\\PropFindPreloadNotifyPlugin' => $baseDir . '/../lib/Connector/Sabre/PropFindPreloadNotifyPlugin.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',
@@ -354,6 +355,7 @@ return array(
'OCA\\DAV\\Migration\\Version1029Date20231004091403' => $baseDir . '/../lib/Migration/Version1029Date20231004091403.php',
'OCA\\DAV\\Migration\\Version1030Date20240205103243' => $baseDir . '/../lib/Migration/Version1030Date20240205103243.php',
'OCA\\DAV\\Migration\\Version1031Date20240610134258' => $baseDir . '/../lib/Migration/Version1031Date20240610134258.php',
+ 'OCA\\DAV\\Migration\\Version1034Date20250813093701' => $baseDir . '/../lib/Migration/Version1034Date20250813093701.php',
'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php',
diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php
index 75ac3350160..e9a0ef01c07 100644
--- a/apps/dav/composer/composer/autoload_static.php
+++ b/apps/dav/composer/composer/autoload_static.php
@@ -232,6 +232,7 @@ class ComposerStaticInitDAV
'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\\PropFindPreloadNotifyPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/PropFindPreloadNotifyPlugin.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',
@@ -369,6 +370,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Migration\\Version1029Date20231004091403' => __DIR__ . '/..' . '/../lib/Migration/Version1029Date20231004091403.php',
'OCA\\DAV\\Migration\\Version1030Date20240205103243' => __DIR__ . '/..' . '/../lib/Migration/Version1030Date20240205103243.php',
'OCA\\DAV\\Migration\\Version1031Date20240610134258' => __DIR__ . '/..' . '/../lib/Migration/Version1031Date20240610134258.php',
+ 'OCA\\DAV\\Migration\\Version1034Date20250813093701' => __DIR__ . '/..' . '/../lib/Migration/Version1034Date20250813093701.php',
'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php',
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/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
index 130d4562146..38538fdcff0 100644
--- a/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php
+++ b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php
@@ -48,30 +48,34 @@ class PropFindMonitorPlugin extends ServerPlugin {
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;
+ 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,
+ ]
+ );
}
- $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/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 dda9c29b763..eef65000131 100644
--- a/apps/dav/lib/Connector/Sabre/Server.php
+++ b/apps/dav/lib/Connector/Sabre/Server.php
@@ -27,7 +27,8 @@ class Server extends \Sabre\DAV\Server {
/**
* Tracks queries done by plugins.
- * @var array<int, array<string, array{nodes:int, queries:int}>>
+ * @var array<string, array<int, array<string, array{nodes:int,
+ * queries:int}>>> The keys represent: event name, depth and plugin name
*/
private array $pluginQueries = [];
@@ -50,8 +51,8 @@ class Server extends \Sabre\DAV\Server {
): void {
$this->debugEnabled ? $this->monitorPropfindQueries(
parent::once(...),
- ...func_get_args(),
- ) : parent::once(...func_get_args());
+ ...\func_get_args(),
+ ) : parent::once(...\func_get_args());
}
#[Override]
@@ -62,8 +63,8 @@ class Server extends \Sabre\DAV\Server {
): void {
$this->debugEnabled ? $this->monitorPropfindQueries(
parent::on(...),
- ...func_get_args(),
- ) : parent::on(...func_get_args());
+ ...\func_get_args(),
+ ) : parent::on(...\func_get_args());
}
/**
@@ -76,13 +77,17 @@ class Server extends \Sabre\DAV\Server {
callable $callBack,
int $priority = 100,
): void {
- if ($eventName !== 'propFind') {
+ $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;
}
- $pluginName = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['class'] ?? 'unknown';
- $callback = $this->getMonitoredCallback($callBack, $pluginName);
+ $callback = $this->getMonitoredCallback($callBack, $pluginName, $eventName);
$parentFn($eventName, $callback, $priority);
}
@@ -94,22 +99,26 @@ class Server extends \Sabre\DAV\Server {
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()
);
- return $result;
+ // many callbacks don't care about returning a bool
+ return $result ?? true;
};
}
@@ -118,6 +127,7 @@ class Server extends \Sabre\DAV\Server {
*/
private function trackPluginQueries(
string $pluginName,
+ string $eventName,
int $queriesExecuted,
int $depth,
): void {
@@ -126,11 +136,11 @@ class Server extends \Sabre\DAV\Server {
return;
}
- $this->pluginQueries[$depth][$pluginName]['nodes']
- = ($this->pluginQueries[$depth][$pluginName]['nodes'] ?? 0) + 1;
+ $this->pluginQueries[$eventName][$depth][$pluginName]['nodes']
+ = ($this->pluginQueries[$eventName][$depth][$pluginName]['nodes'] ?? 0) + 1;
- $this->pluginQueries[$depth][$pluginName]['queries']
- = ($this->pluginQueries[$depth][$pluginName]['queries'] ?? 0) + $queriesExecuted;
+ $this->pluginQueries[$eventName][$depth][$pluginName]['queries']
+ = ($this->pluginQueries[$eventName][$depth][$pluginName]['queries'] ?? 0) + $queriesExecuted;
}
/**
@@ -221,8 +231,8 @@ class Server extends \Sabre\DAV\Server {
/**
* Returns queries executed by registered plugins.
- *
- * @return array<int, array<string, array{nodes:int, queries:int}>>
+ * @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 a6a27057177..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;
@@ -94,6 +95,8 @@ class ServerFactory {
$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));
@@ -226,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/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php
index f9a4f8ee986..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\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;
@@ -97,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;
/**
@@ -114,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();
@@ -197,6 +211,13 @@ 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;
@@ -316,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)
@@ -326,6 +351,7 @@ class CustomPropertiesBackend implements BackendInterface {
$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
}
$result->closeCursor();
+ $this->publishedCache[$path] = $props;
return $props;
}
@@ -364,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
*
@@ -410,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
*/
@@ -426,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/Server.php b/apps/dav/lib/Server.php
index a92e162f1b0..9b4a1b3d33c 100644
--- a/apps/dav/lib/Server.php
+++ b/apps/dav/lib/Server.php
@@ -46,6 +46,7 @@ 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;
@@ -54,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;
@@ -237,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());
@@ -248,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);
@@ -306,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();
diff --git a/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php b/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php
index d4021a66299..cafbdd3ca40 100644
--- a/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php
+++ b/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php
@@ -12,6 +12,7 @@ use OCA\DAV\CalDAV\DefaultCalendarValidator;
use OCA\DAV\Connector\Sabre\Directory;
use OCA\DAV\Connector\Sabre\File;
use OCA\DAV\DAV\CustomPropertiesBackend;
+use OCA\DAV\Db\PropertyMapper;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\Server;
@@ -52,6 +53,7 @@ class CustomPropertiesBackendTest extends \Test\TestCase {
$this->tree,
Server::get(IDBConnection::class),
$this->user,
+ Server::get(PropertyMapper::class),
$this->defaultCalendarValidator,
);
}
diff --git a/apps/dav/tests/unit/Connector/Sabre/PropFindMonitorPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/PropFindMonitorPluginTest.php
index b528c3d731c..9d22befa201 100644
--- a/apps/dav/tests/unit/Connector/Sabre/PropFindMonitorPluginTest.php
+++ b/apps/dav/tests/unit/Connector/Sabre/PropFindMonitorPluginTest.php
@@ -29,66 +29,76 @@ class PropFindMonitorPluginTest extends TestCase {
'No queries logged' => [[], 0],
'Plugins with queries in less than threshold nodes should not be logged' => [
[
- [
- 'PluginName' => ['queries' => 100, 'nodes'
- => PropFindMonitorPlugin::THRESHOLD_NODES - 1]
- ],
- [],
+ 'propFind' => [
+ [
+ '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 ],
- ],
- [],
+ 'propFind' => [
+ [
+ '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],
- ],
- [],
+ 'propFind' => [
+ [
+ '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
- ]
+ 'propFind' => [
+ [
+ '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,
+ 'propFind' => [
+ [
+ 'FirstPlugin' => [
+ 'queries' => $minQueriesTrigger,
+ 'nodes' => PropFindMonitorPlugin::THRESHOLD_NODES,
+ ],
+ 'SecondPlugin' => [
+ 'queries' => $minQueriesTrigger,
+ 'nodes' => PropFindMonitorPlugin::THRESHOLD_NODES,
+ ]
],
- 'SecondPlugin' => [
- 'queries' => $minQueriesTrigger,
- 'nodes' => PropFindMonitorPlugin::THRESHOLD_NODES,
- ]
- ],
- []
+ [],
+ ]
],
2
]
diff --git a/apps/dav/tests/unit/Connector/Sabre/PropFindPreloadNotifyPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/PropFindPreloadNotifyPluginTest.php
new file mode 100644
index 00000000000..52fe3eba5bf
--- /dev/null
+++ b/apps/dav/tests/unit/Connector/Sabre/PropFindPreloadNotifyPluginTest.php
@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\Tests\unit\Connector\Sabre;
+
+use OCA\DAV\Connector\Sabre\PropFindPreloadNotifyPlugin;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\MockObject\MockObject;
+use Sabre\DAV\ICollection;
+use Sabre\DAV\IFile;
+use Sabre\DAV\PropFind;
+use Sabre\DAV\Server;
+use Test\TestCase;
+
+class PropFindPreloadNotifyPluginTest extends TestCase {
+
+ private Server&MockObject $server;
+ private PropFindPreloadNotifyPlugin $plugin;
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->server = $this->createMock(Server::class);
+ $this->plugin = new PropFindPreloadNotifyPlugin();
+ }
+
+ public function testInitialize(): void {
+ $this->server
+ ->expects(self::once())
+ ->method('on')
+ ->with('propFind',
+ $this->anything(), 1);
+ $this->plugin->initialize($this->server);
+ }
+
+ public static function dataTestCollectionPreloadNotifier(): array {
+ return [
+ 'When node is not a collection, should not emit' => [
+ IFile::class,
+ 1,
+ false,
+ true
+ ],
+ 'When node is a collection but depth is zero, should not emit' => [
+ ICollection::class,
+ 0,
+ false,
+ true
+ ],
+ 'When node is a collection, and depth > 0, should emit' => [
+ ICollection::class,
+ 1,
+ true,
+ true
+ ],
+ 'When node is a collection, and depth is infinite, should emit'
+ => [
+ ICollection::class,
+ Server::DEPTH_INFINITY,
+ true,
+ true
+ ],
+ 'When called called handler returns false, it should be returned'
+ => [
+ ICollection::class,
+ 1,
+ true,
+ false
+ ]
+ ];
+ }
+
+ #[DataProvider(methodName: 'dataTestCollectionPreloadNotifier')]
+ public function testCollectionPreloadNotifier(string $nodeType, int $depth, bool $shouldEmit, bool $emitReturns):
+ void {
+ $this->plugin->initialize($this->server);
+ $propFind = $this->createMock(PropFind::class);
+ $propFind->expects(self::any())->method('getDepth')->willReturn($depth);
+ $node = $this->createMock($nodeType);
+
+ $expectation = $shouldEmit ? self::once() : self::never();
+ $this->server->expects($expectation)->method('emit')->with('preloadCollection',
+ [$propFind, $node])->willReturn($emitReturns);
+ $return = $this->plugin->collectionPreloadNotifier($propFind, $node);
+ $this->assertEquals($emitReturns, $return);
+ }
+}
diff --git a/apps/dav/tests/unit/Connector/Sabre/SharesPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/SharesPluginTest.php
index 1c8e29dab38..33f579eb913 100644
--- a/apps/dav/tests/unit/Connector/Sabre/SharesPluginTest.php
+++ b/apps/dav/tests/unit/Connector/Sabre/SharesPluginTest.php
@@ -223,6 +223,7 @@ class SharesPluginTest extends \Test\TestCase {
0
);
+ $this->server->emit('preloadCollection', [$propFindRoot, $sabreNode]);
$this->plugin->handleGetProperties(
$propFindRoot,
$sabreNode
diff --git a/apps/dav/tests/unit/Connector/Sabre/TagsPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/TagsPluginTest.php
index 5003280bfdc..554a4a1424e 100644
--- a/apps/dav/tests/unit/Connector/Sabre/TagsPluginTest.php
+++ b/apps/dav/tests/unit/Connector/Sabre/TagsPluginTest.php
@@ -147,6 +147,8 @@ class TagsPluginTest extends \Test\TestCase {
0
);
+ $this->server->emit('preloadCollection', [$propFindRoot, $node]);
+
$this->plugin->handleGetProperties(
$propFindRoot,
$node
diff --git a/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php b/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php
index 2a85c0cbecd..517969fc9a3 100644
--- a/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php
+++ b/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php
@@ -10,6 +10,7 @@ namespace OCA\DAV\Tests\unit\DAV;
use OCA\DAV\CalDAV\Calendar;
use OCA\DAV\CalDAV\DefaultCalendarValidator;
use OCA\DAV\DAV\CustomPropertiesBackend;
+use OCA\DAV\Db\PropertyMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IUser;
@@ -36,6 +37,7 @@ class CustomPropertiesBackendTest extends TestCase {
private IUser&MockObject $user;
private DefaultCalendarValidator&MockObject $defaultCalendarValidator;
private CustomPropertiesBackend $backend;
+ private PropertyMapper $propertyMapper;
protected function setUp(): void {
parent::setUp();
@@ -49,6 +51,7 @@ class CustomPropertiesBackendTest extends TestCase {
->with()
->willReturn('dummy_user_42');
$this->dbConnection = \OCP\Server::get(IDBConnection::class);
+ $this->propertyMapper = \OCP\Server::get(PropertyMapper::class);
$this->defaultCalendarValidator = $this->createMock(DefaultCalendarValidator::class);
$this->backend = new CustomPropertiesBackend(
@@ -56,6 +59,7 @@ class CustomPropertiesBackendTest extends TestCase {
$this->tree,
$this->dbConnection,
$this->user,
+ $this->propertyMapper,
$this->defaultCalendarValidator,
);
}
@@ -129,6 +133,7 @@ class CustomPropertiesBackendTest extends TestCase {
$this->tree,
$db,
$this->user,
+ $this->propertyMapper,
$this->defaultCalendarValidator,
);
diff --git a/apps/files_reminders/lib/Dav/PropFindPlugin.php b/apps/files_reminders/lib/Dav/PropFindPlugin.php
index 014e636eb2d..7fa45a4b854 100644
--- a/apps/files_reminders/lib/Dav/PropFindPlugin.php
+++ b/apps/files_reminders/lib/Dav/PropFindPlugin.php
@@ -16,6 +16,7 @@ use OCA\FilesReminders\Service\ReminderService;
use OCP\Files\Folder;
use OCP\IUser;
use OCP\IUserSession;
+use Sabre\DAV\ICollection;
use Sabre\DAV\INode;
use Sabre\DAV\PropFind;
use Sabre\DAV\Server;
@@ -32,9 +33,22 @@ class PropFindPlugin extends ServerPlugin {
}
public function initialize(Server $server): void {
+ $server->on('preloadCollection', $this->preloadCollection(...));
$server->on('propFind', [$this, 'propFind']);
}
+ private function preloadCollection(
+ PropFind $propFind,
+ ICollection $collection,
+ ): void {
+ if ($collection instanceof Directory && $propFind->getStatus(
+ static::REMINDER_DUE_DATE_PROPERTY
+ ) !== null) {
+ $folder = $collection->getNode();
+ $this->cacheFolder($folder);
+ }
+ }
+
public function propFind(PropFind $propFind, INode $node) {
if (!in_array(static::REMINDER_DUE_DATE_PROPERTY, $propFind->getRequestedProperties())) {
return;
@@ -44,15 +58,6 @@ class PropFindPlugin extends ServerPlugin {
return;
}
- if (
- $node instanceof Directory
- && $propFind->getDepth() > 0
- && $propFind->getStatus(static::REMINDER_DUE_DATE_PROPERTY) !== null
- ) {
- $folder = $node->getNode();
- $this->cacheFolder($folder);
- }
-
$propFind->handle(
static::REMINDER_DUE_DATE_PROPERTY,
function () use ($node) {