aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dav/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav/lib')
-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/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/CommentPropertiesPlugin.php27
-rw-r--r--apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php82
-rw-r--r--apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php55
-rw-r--r--apps/dav/lib/Connector/Sabre/Server.php122
-rw-r--r--apps/dav/lib/Connector/Sabre/ServerFactory.php14
-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/Connector/Sabre/ZipFolderPlugin.php5
-rw-r--r--apps/dav/lib/DAV/CustomPropertiesBackend.php166
-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/RootCollection.php3
-rw-r--r--apps/dav/lib/Server.php12
-rw-r--r--apps/dav/lib/SystemTag/SystemTagPlugin.php59
25 files changed, 796 insertions, 178 deletions
diff --git a/apps/dav/lib/CalDAV/Calendar.php b/apps/dav/lib/CalDAV/Calendar.php
index dd3a4cf3f69..deb00caa93d 100644
--- a/apps/dav/lib/CalDAV/Calendar.php
+++ b/apps/dav/lib/CalDAV/Calendar.php
@@ -36,7 +36,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable
public function __construct(
BackendInterface $caldavBackend,
- $calendarInfo,
+ array $calendarInfo,
IL10N $l10n,
private IConfig $config,
private LoggerInterface $logger,
@@ -60,6 +60,10 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable
$this->l10n = $l10n;
}
+ public function getUri(): string {
+ return $this->calendarInfo['uri'];
+ }
+
/**
* {@inheritdoc}
* @throws Forbidden
diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php
index 3cc4039ed36..a8b818e59aa 100644
--- a/apps/dav/lib/CalDAV/CalendarProvider.php
+++ b/apps/dav/lib/CalDAV/CalendarProvider.php
@@ -36,9 +36,14 @@ class CalendarProvider implements ICalendarProvider {
});
}
+ $additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos);
$iCalendars = [];
foreach ($calendarInfos as $calendarInfo) {
- $calendarInfo = array_merge($calendarInfo, $this->getAdditionalProperties($calendarInfo['principaluri'], $calendarInfo['uri']));
+ $user = str_replace('principals/users/', '', $calendarInfo['principaluri']);
+ $path = 'calendars/' . $user . '/' . $calendarInfo['uri'];
+
+ $calendarInfo = array_merge($calendarInfo, $additionalProperties[$path] ?? []);
+
$calendar = new Calendar($this->calDavBackend, $calendarInfo, $this->l10n, $this->config, $this->logger);
$iCalendars[] = new CalendarImpl(
$calendar,
@@ -49,16 +54,34 @@ class CalendarProvider implements ICalendarProvider {
return $iCalendars;
}
- public function getAdditionalProperties(string $principalUri, string $calendarUri): array {
- $user = str_replace('principals/users/', '', $principalUri);
- $path = 'calendars/' . $user . '/' . $calendarUri;
+ /**
+ * @param array{
+ * principaluri: string,
+ * uri: string,
+ * }[] $uris
+ * @return array<string, array<string, string|bool>>
+ */
+ private function getAdditionalPropertiesForCalendars(array $uris): array {
+ $calendars = [];
+ foreach ($uris as $uri) {
+ /** @var string $user */
+ $user = str_replace('principals/users/', '', $uri['principaluri']);
+ if (!array_key_exists($user, $calendars)) {
+ $calendars[$user] = [];
+ }
+ $calendars[$user][] = 'calendars/' . $user . '/' . $uri['uri'];
+ }
- $properties = $this->propertyMapper->findPropertiesByPath($user, $path);
+ $properties = $this->propertyMapper->findPropertiesByPathsAndUsers($calendars);
$list = [];
foreach ($properties as $property) {
if ($property instanceof Property) {
- $list[$property->getPropertyname()] = match ($property->getPropertyname()) {
+ if (!isset($list[$property->getPropertypath()])) {
+ $list[$property->getPropertypath()] = [];
+ }
+
+ $list[$property->getPropertypath()][$property->getPropertyname()] = match ($property->getPropertyname()) {
'{http://owncloud.org/ns}calendar-enabled' => (bool)$property->getPropertyvalue(),
default => $property->getPropertyvalue()
};
diff --git a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php
index 21d8c06fa99..d9d6d840c5e 100644
--- a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php
+++ b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php
@@ -20,6 +20,7 @@ use OCA\DAV\Connector\Sabre\DavAclPlugin;
use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin;
use OCA\DAV\Connector\Sabre\LockPlugin;
use OCA\DAV\Connector\Sabre\MaintenancePlugin;
+use OCA\DAV\Connector\Sabre\PropFindPreloadNotifyPlugin;
use OCA\DAV\Events\SabrePluginAuthInitEvent;
use OCA\DAV\RootCollection;
use OCA\Theming\ThemingDefaults;
@@ -96,6 +97,9 @@ class EmbeddedCalDavServer {
$this->server->addPlugin(Server::get(\OCA\DAV\CalDAV\Schedule\IMipPlugin::class));
}
+ // collection preload plugin
+ $this->server->addPlugin(new PropFindPreloadNotifyPlugin());
+
// wait with registering these until auth is handled and the filesystem is setup
$this->server->on('beforeMethod:*', function () use ($root): void {
// register plugins from apps
diff --git a/apps/dav/lib/CardDAV/AddressBook.php b/apps/dav/lib/CardDAV/AddressBook.php
index d2391880585..4d30d507a7d 100644
--- a/apps/dav/lib/CardDAV/AddressBook.php
+++ b/apps/dav/lib/CardDAV/AddressBook.php
@@ -8,7 +8,6 @@
namespace OCA\DAV\CardDAV;
use OCA\DAV\DAV\Sharing\IShareable;
-use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException;
use OCP\DB\Exception;
use OCP\IL10N;
use OCP\Server;
@@ -234,9 +233,6 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMov
}
public function getChanges($syncToken, $syncLevel, $limit = null) {
- if (!$syncToken && $limit) {
- throw new UnsupportedLimitOnInitialSyncException();
- }
return parent::getChanges($syncToken, $syncLevel, $limit);
}
diff --git a/apps/dav/lib/CardDAV/AddressBookImpl.php b/apps/dav/lib/CardDAV/AddressBookImpl.php
index 6bb8e24f628..ae77498539b 100644
--- a/apps/dav/lib/CardDAV/AddressBookImpl.php
+++ b/apps/dav/lib/CardDAV/AddressBookImpl.php
@@ -152,6 +152,10 @@ class AddressBookImpl implements IAddressBookEnabled {
$permissions = $this->addressBook->getACL();
$result = 0;
foreach ($permissions as $permission) {
+ if ($this->addressBookInfo['principaluri'] !== $permission['principal']) {
+ continue;
+ }
+
switch ($permission['privilege']) {
case '{DAV:}read':
$result |= Constants::PERMISSION_READ;
diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php
index 06bd8d8ee2c..a78686eb61d 100644
--- a/apps/dav/lib/CardDAV/CardDavBackend.php
+++ b/apps/dav/lib/CardDAV/CardDavBackend.php
@@ -23,6 +23,7 @@ use OCP\AppFramework\Db\TTransactional;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserManager;
use PDO;
@@ -59,6 +60,7 @@ class CardDavBackend implements BackendInterface, SyncSupport {
private IUserManager $userManager,
private IEventDispatcher $dispatcher,
private Sharing\Backend $sharingBackend,
+ private IConfig $config,
) {
}
@@ -851,6 +853,8 @@ class CardDavBackend implements BackendInterface, SyncSupport {
* @return array
*/
public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
+ $maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500);
+ $limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit);
// Current synctoken
return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
$qb = $this->db->getQueryBuilder();
@@ -873,10 +877,35 @@ class CardDavBackend implements BackendInterface, SyncSupport {
'modified' => [],
'deleted' => [],
];
-
- if ($syncToken) {
+ if (str_starts_with($syncToken, 'init_')) {
+ $syncValues = explode('_', $syncToken);
+ $lastID = $syncValues[1];
+ $initialSyncToken = $syncValues[2];
$qb = $this->db->getQueryBuilder();
- $qb->select('uri', 'operation')
+ $qb->select('id', 'uri')
+ ->from('cards')
+ ->where(
+ $qb->expr()->andX(
+ $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)),
+ $qb->expr()->gt('id', $qb->createNamedParameter($lastID)))
+ )->orderBy('id')
+ ->setMaxResults($limit);
+ $stmt = $qb->executeQuery();
+ $values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+ $stmt->closeCursor();
+ if (count($values) === 0) {
+ $result['syncToken'] = $initialSyncToken;
+ $result['result_truncated'] = false;
+ $result['added'] = [];
+ } else {
+ $lastID = $values[array_key_last($values)]['id'];
+ $result['added'] = array_column($values, 'uri');
+ $result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ;
+ $result['result_truncated'] = count($result['added']) >= $limit;
+ }
+ } elseif ($syncToken) {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('uri', 'operation', 'synctoken')
->from('addressbookchanges')
->where(
$qb->expr()->andX(
@@ -886,22 +915,31 @@ class CardDavBackend implements BackendInterface, SyncSupport {
)
)->orderBy('synctoken');
- if (is_int($limit) && $limit > 0) {
+ if ($limit > 0) {
$qb->setMaxResults($limit);
}
// Fetching all changes
$stmt = $qb->executeQuery();
+ $rowCount = $stmt->rowCount();
$changes = [];
+ $highestSyncToken = 0;
// This loop ensures that any duplicates are overwritten, only the
// last change on a node is relevant.
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$changes[$row['uri']] = $row['operation'];
+ $highestSyncToken = $row['synctoken'];
}
+
$stmt->closeCursor();
+ // No changes found, use current token
+ if (empty($changes)) {
+ $result['syncToken'] = $currentToken;
+ }
+
foreach ($changes as $uri => $operation) {
switch ($operation) {
case 1:
@@ -915,16 +953,43 @@ class CardDavBackend implements BackendInterface, SyncSupport {
break;
}
}
+
+ /*
+ * The synctoken in oc_addressbooks is always the highest synctoken in oc_addressbookchanges for a given addressbook plus one (see addChange).
+ *
+ * For truncated results, it is expected that we return the highest token from the response, so the client can continue from the latest change.
+ *
+ * For non-truncated results, it is expected to return the currentToken. If we return the highest token, as with truncated results, the client will always think it is one change behind.
+ *
+ * Therefore, we differentiate between truncated and non-truncated results when returning the synctoken.
+ */
+ if ($rowCount === $limit && $highestSyncToken < $currentToken) {
+ $result['syncToken'] = $highestSyncToken;
+ $result['result_truncated'] = true;
+ }
} else {
$qb = $this->db->getQueryBuilder();
- $qb->select('uri')
+ $qb->select('id', 'uri')
->from('cards')
->where(
$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
);
// No synctoken supplied, this is the initial sync.
+ $qb->setMaxResults($limit);
$stmt = $qb->executeQuery();
- $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN);
+ $values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+ if (empty($values)) {
+ $result['added'] = [];
+ return $result;
+ }
+ $lastID = $values[array_key_last($values)]['id'];
+ if (count($values) >= $limit) {
+ $result['syncToken'] = 'init_' . $lastID . '_' . $currentToken;
+ $result['result_truncated'] = true;
+ }
+
+ $result['added'] = array_column($values, 'uri');
+
$stmt->closeCursor();
}
return $result;
diff --git a/apps/dav/lib/CardDAV/SyncService.php b/apps/dav/lib/CardDAV/SyncService.php
index 4a75f8ced6c..e6da3ed5923 100644
--- a/apps/dav/lib/CardDAV/SyncService.php
+++ b/apps/dav/lib/CardDAV/SyncService.php
@@ -22,6 +22,7 @@ use Psr\Log\LoggerInterface;
use Sabre\DAV\Xml\Response\MultiStatus;
use Sabre\DAV\Xml\Service;
use Sabre\VObject\Reader;
+use Sabre\Xml\ParseException;
use function is_null;
class SyncService {
@@ -43,9 +44,10 @@ class SyncService {
}
/**
+ * @psalm-return list{0: ?string, 1: boolean}
* @throws \Exception
*/
- public function syncRemoteAddressBook(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken, string $targetBookHash, string $targetPrincipal, array $targetProperties): string {
+ public function syncRemoteAddressBook(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken, string $targetBookHash, string $targetPrincipal, array $targetProperties): array {
// 1. create addressbook
$book = $this->ensureSystemAddressBookExists($targetPrincipal, $targetBookHash, $targetProperties);
$addressBookId = $book['id'];
@@ -83,7 +85,10 @@ class SyncService {
}
}
- return $response['token'];
+ return [
+ $response['token'],
+ $response['truncated'],
+ ];
}
/**
@@ -127,7 +132,7 @@ class SyncService {
private function prepareUri(string $host, string $path): string {
/*
- * The trailing slash is important for merging the uris together.
+ * The trailing slash is important for merging the uris.
*
* $host is stored in oc_trusted_servers.url and usually without a trailing slash.
*
@@ -158,7 +163,9 @@ class SyncService {
}
/**
+ * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool}
* @throws ClientExceptionInterface
+ * @throws ParseException
*/
protected function requestSyncReport(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken): array {
$client = $this->clientService->newClient();
@@ -181,7 +188,7 @@ class SyncService {
$body = $response->getBody();
assert(is_string($body));
- return $this->parseMultiStatus($body);
+ return $this->parseMultiStatus($body, $addressBookUrl);
}
protected function download(string $url, string $userName, string $sharedSecret, string $resourcePath): string {
@@ -219,22 +226,50 @@ class SyncService {
}
/**
- * @param string $body
- * @return array
- * @throws \Sabre\Xml\ParseException
+ * @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool}
+ * @throws ParseException
*/
- private function parseMultiStatus($body) {
- $xml = new Service();
-
+ private function parseMultiStatus(string $body, string $addressBookUrl): array {
/** @var MultiStatus $multiStatus */
- $multiStatus = $xml->expect('{DAV:}multistatus', $body);
+ $multiStatus = (new Service())->expect('{DAV:}multistatus', $body);
$result = [];
+ $truncated = false;
+
foreach ($multiStatus->getResponses() as $response) {
- $result[$response->getHref()] = $response->getResponseProperties();
+ $href = $response->getHref();
+ if ($response->getHttpStatus() === '507' && $this->isResponseForRequestUri($href, $addressBookUrl)) {
+ $truncated = true;
+ } else {
+ $result[$response->getHref()] = $response->getResponseProperties();
+ }
}
- return ['response' => $result, 'token' => $multiStatus->getSyncToken()];
+ return ['response' => $result, 'token' => $multiStatus->getSyncToken(), 'truncated' => $truncated];
+ }
+
+ /**
+ * Determines whether the provided response URI corresponds to the given request URI.
+ */
+ private function isResponseForRequestUri(string $responseUri, string $requestUri): bool {
+ /*
+ * Example response uri:
+ *
+ * /remote.php/dav/addressbooks/system/system/system/
+ * /cloud/remote.php/dav/addressbooks/system/system/system/ (when installed in a subdirectory)
+ *
+ * Example request uri:
+ *
+ * remote.php/dav/addressbooks/system/system/system
+ *
+ * References:
+ * https://github.com/nextcloud/3rdparty/blob/e0a509739b13820f0a62ff9cad5d0fede00e76ee/sabre/dav/lib/DAV/Sync/Plugin.php#L172-L174
+ * https://github.com/nextcloud/server/blob/b40acb34a39592070d8455eb91c5364c07928c50/apps/federation/lib/SyncFederationAddressBooks.php#L41
+ */
+ return str_ends_with(
+ rtrim($responseUri, '/'),
+ rtrim($requestUri, '/')
+ );
}
/**
diff --git a/apps/dav/lib/CardDAV/SystemAddressbook.php b/apps/dav/lib/CardDAV/SystemAddressbook.php
index e0032044e70..912a2f1dcee 100644
--- a/apps/dav/lib/CardDAV/SystemAddressbook.php
+++ b/apps/dav/lib/CardDAV/SystemAddressbook.php
@@ -8,7 +8,6 @@ declare(strict_types=1);
*/
namespace OCA\DAV\CardDAV;
-use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException;
use OCA\Federation\TrustedServers;
use OCP\Accounts\IAccountManager;
use OCP\IConfig;
@@ -212,14 +211,7 @@ class SystemAddressbook extends AddressBook {
}
return new Card($this->carddavBackend, $this->addressBookInfo, $obj);
}
-
- /**
- * @throws UnsupportedLimitOnInitialSyncException
- */
public function getChanges($syncToken, $syncLevel, $limit = null) {
- if (!$syncToken && $limit) {
- throw new UnsupportedLimitOnInitialSyncException();
- }
if (!$this->carddavBackend instanceof SyncSupport) {
return null;
diff --git a/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php b/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php
index 44430b0004e..21358406a4a 100644
--- a/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php
+++ b/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php
@@ -49,7 +49,7 @@ class BlockLegacyClientPlugin extends ServerPlugin {
return;
}
- $minimumSupportedDesktopVersion = $this->config->getSystemValueString('minimum.supported.desktop.version', '2.7.0');
+ $minimumSupportedDesktopVersion = $this->config->getSystemValueString('minimum.supported.desktop.version', '3.1.0');
$maximumSupportedDesktopVersion = $this->config->getSystemValueString('maximum.supported.desktop.version', '99.99.99');
// Check if the client is a desktop client
diff --git a/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php
index e4b6c2636da..ef9bd1ae472 100644
--- a/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php
+++ b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php
@@ -10,6 +10,7 @@ namespace OCA\DAV\Connector\Sabre;
use OCP\Comments\ICommentsManager;
use OCP\IUserSession;
+use Sabre\DAV\ICollection;
use Sabre\DAV\PropFind;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
@@ -21,6 +22,7 @@ class CommentPropertiesPlugin extends ServerPlugin {
protected ?Server $server = null;
private array $cachedUnreadCount = [];
+ private array $cachedDirectories = [];
public function __construct(
private ICommentsManager $commentsManager,
@@ -41,6 +43,8 @@ class CommentPropertiesPlugin extends ServerPlugin {
*/
public function initialize(\Sabre\DAV\Server $server) {
$this->server = $server;
+
+ $this->server->on('preloadCollection', $this->preloadCollection(...));
$this->server->on('propFind', [$this, 'handleGetProperties']);
}
@@ -69,6 +73,21 @@ class CommentPropertiesPlugin extends ServerPlugin {
}
}
+ private function preloadCollection(PropFind $propFind, ICollection $collection):
+ void {
+ if (!($collection instanceof Directory)) {
+ return;
+ }
+
+ $collectionPath = $collection->getPath();
+ if (!isset($this->cachedDirectories[$collectionPath]) && $propFind->getStatus(
+ self::PROPERTY_NAME_UNREAD
+ ) !== null) {
+ $this->cacheDirectory($collection);
+ $this->cachedDirectories[$collectionPath] = true;
+ }
+ }
+
/**
* Adds tags and favorites properties to the response,
* if requested.
@@ -85,14 +104,6 @@ class CommentPropertiesPlugin extends ServerPlugin {
return;
}
- // need prefetch ?
- if ($node instanceof Directory
- && $propFind->getDepth() !== 0
- && !is_null($propFind->getStatus(self::PROPERTY_NAME_UNREAD))
- ) {
- $this->cacheDirectory($node);
- }
-
$propFind->handle(self::PROPERTY_NAME_COUNT, function () use ($node): int {
return $this->commentsManager->getNumberOfCommentsForObject('files', (string)$node->getId());
});
diff --git a/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php
new file mode 100644
index 00000000000..38538fdcff0
--- /dev/null
+++ b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php
@@ -0,0 +1,82 @@
+<?php
+
+declare(strict_types = 1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\Connector\Sabre;
+
+use Sabre\DAV\Server as SabreServer;
+use Sabre\DAV\ServerPlugin;
+use Sabre\HTTP\RequestInterface;
+use Sabre\HTTP\ResponseInterface;
+
+/**
+ * This plugin runs after requests and logs an error if a plugin is detected
+ * to be doing too many SQL requests.
+ */
+class PropFindMonitorPlugin extends ServerPlugin {
+
+ /**
+ * A Plugin can scan up to this amount of nodes without an error being
+ * reported.
+ */
+ public const THRESHOLD_NODES = 50;
+
+ /**
+ * A plugin can use up to this amount of queries per node.
+ */
+ public const THRESHOLD_QUERY_FACTOR = 1;
+
+ private SabreServer $server;
+
+ public function initialize(SabreServer $server): void {
+ $this->server = $server;
+ $this->server->on('afterResponse', [$this, 'afterResponse']);
+ }
+
+ public function afterResponse(
+ RequestInterface $request,
+ ResponseInterface $response): void {
+ if (!$this->server instanceof Server) {
+ return;
+ }
+
+ $pluginQueries = $this->server->getPluginQueries();
+ if (empty($pluginQueries)) {
+ return;
+ }
+
+ $logger = $this->server->getLogger();
+ foreach ($pluginQueries as $eventName => $eventQueries) {
+ $maxDepth = max(0, ...array_keys($eventQueries));
+ // entries at the top are usually not interesting
+ unset($eventQueries[$maxDepth]);
+ foreach ($eventQueries as $depth => $propFinds) {
+ foreach ($propFinds as $pluginName => $propFind) {
+ [
+ 'queries' => $queries,
+ 'nodes' => $nodes
+ ] = $propFind;
+ if ($queries === 0 || $nodes > $queries || $nodes < self::THRESHOLD_NODES
+ || $queries < $nodes * self::THRESHOLD_QUERY_FACTOR) {
+ continue;
+ }
+ $logger->error(
+ '{name}:{event} scanned {scans} nodes with {count} queries in depth {depth}/{maxDepth}. This is bad for performance, please report to the plugin developer!',
+ [
+ 'name' => $pluginName,
+ 'scans' => $nodes,
+ 'count' => $queries,
+ 'depth' => $depth,
+ 'maxDepth' => $maxDepth,
+ 'event' => $eventName,
+ ]
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php b/apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php
new file mode 100644
index 00000000000..c7b0c64132c
--- /dev/null
+++ b/apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types = 1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\Connector\Sabre;
+
+use Sabre\DAV\ICollection;
+use Sabre\DAV\INode;
+use Sabre\DAV\PropFind;
+use Sabre\DAV\Server;
+use Sabre\DAV\ServerPlugin;
+
+/**
+ * This plugin asks other plugins to preload data for a collection, so that
+ * subsequent PROPFIND handlers for children do not query the DB on a per-node
+ * basis.
+ */
+class PropFindPreloadNotifyPlugin extends ServerPlugin {
+
+ private Server $server;
+
+ public function initialize(Server $server): void {
+ $this->server = $server;
+ $this->server->on('propFind', [$this, 'collectionPreloadNotifier' ], 1);
+ }
+
+ /**
+ * Uses the server instance to emit a `preloadCollection` event to signal
+ * to interested plugins that a collection can be preloaded.
+ *
+ * NOTE: this can be emitted several times, so ideally every plugin
+ * should cache what they need and check if a cache exists before
+ * re-fetching.
+ */
+ public function collectionPreloadNotifier(PropFind $propFind, INode $node): bool {
+ if (!$this->shouldPreload($propFind, $node)) {
+ return true;
+ }
+
+ return $this->server->emit('preloadCollection', [$propFind, $node]);
+ }
+
+ private function shouldPreload(
+ PropFind $propFind,
+ INode $node,
+ ): bool {
+ $depth = $propFind->getDepth();
+ return $node instanceof ICollection
+ && ($depth === Server::DEPTH_INFINITY || $depth > 0);
+ }
+}
diff --git a/apps/dav/lib/Connector/Sabre/Server.php b/apps/dav/lib/Connector/Sabre/Server.php
index f3bfac1d6e0..eef65000131 100644
--- a/apps/dav/lib/Connector/Sabre/Server.php
+++ b/apps/dav/lib/Connector/Sabre/Server.php
@@ -7,7 +7,11 @@
*/
namespace OCA\DAV\Connector\Sabre;
+use OC\DB\Connection;
+use Override;
use Sabre\DAV\Exception;
+use Sabre\DAV\INode;
+use Sabre\DAV\PropFind;
use Sabre\DAV\Version;
use TypeError;
@@ -22,6 +26,15 @@ class Server extends \Sabre\DAV\Server {
/** @var CachingTree $tree */
/**
+ * Tracks queries done by plugins.
+ * @var array<string, array<int, array<string, array{nodes:int,
+ * queries:int}>>> The keys represent: event name, depth and plugin name
+ */
+ private array $pluginQueries = [];
+
+ public bool $debugEnabled = false;
+
+ /**
* @see \Sabre\DAV\Server
*/
public function __construct($treeOrNode = null) {
@@ -30,6 +43,106 @@ class Server extends \Sabre\DAV\Server {
$this->enablePropfindDepthInfinity = true;
}
+ #[Override]
+ public function once(
+ string $eventName,
+ callable $callBack,
+ int $priority = 100,
+ ): void {
+ $this->debugEnabled ? $this->monitorPropfindQueries(
+ parent::once(...),
+ ...\func_get_args(),
+ ) : parent::once(...\func_get_args());
+ }
+
+ #[Override]
+ public function on(
+ string $eventName,
+ callable $callBack,
+ int $priority = 100,
+ ): void {
+ $this->debugEnabled ? $this->monitorPropfindQueries(
+ parent::on(...),
+ ...\func_get_args(),
+ ) : parent::on(...\func_get_args());
+ }
+
+ /**
+ * Wraps the handler $callBack into a query-monitoring function and calls
+ * $parentFn to register it.
+ */
+ private function monitorPropfindQueries(
+ callable $parentFn,
+ string $eventName,
+ callable $callBack,
+ int $priority = 100,
+ ): void {
+ $pluginName = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['class'] ?? 'unknown';
+ // The NotifyPlugin needs to be excluded as it emits the
+ // `preloadCollection` event, which causes many plugins run queries.
+ /** @psalm-suppress TypeDoesNotContainType */
+ if ($pluginName === PropFindPreloadNotifyPlugin::class || ($eventName !== 'propFind'
+ && $eventName !== 'preloadCollection')) {
+ $parentFn($eventName, $callBack, $priority);
+ return;
+ }
+
+ $callback = $this->getMonitoredCallback($callBack, $pluginName, $eventName);
+
+ $parentFn($eventName, $callback, $priority);
+ }
+
+ /**
+ * Returns a callable that wraps $callBack with code that monitors and
+ * records queries per plugin.
+ */
+ private function getMonitoredCallback(
+ callable $callBack,
+ string $pluginName,
+ string $eventName,
+ ): callable {
+ return function (PropFind $propFind, INode $node) use (
+ $callBack,
+ $pluginName,
+ $eventName,
+ ): bool {
+ $connection = \OCP\Server::get(Connection::class);
+ $queriesBefore = $connection->getStats()['executed'];
+ $result = $callBack($propFind, $node);
+ $queriesAfter = $connection->getStats()['executed'];
+ $this->trackPluginQueries(
+ $pluginName,
+ $eventName,
+ $queriesAfter - $queriesBefore,
+ $propFind->getDepth()
+ );
+
+ // many callbacks don't care about returning a bool
+ return $result ?? true;
+ };
+ }
+
+ /**
+ * Tracks the queries executed by a specific plugin.
+ */
+ private function trackPluginQueries(
+ string $pluginName,
+ string $eventName,
+ int $queriesExecuted,
+ int $depth,
+ ): void {
+ // report only nodes which cause queries to the DB
+ if ($queriesExecuted === 0) {
+ return;
+ }
+
+ $this->pluginQueries[$eventName][$depth][$pluginName]['nodes']
+ = ($this->pluginQueries[$eventName][$depth][$pluginName]['nodes'] ?? 0) + 1;
+
+ $this->pluginQueries[$eventName][$depth][$pluginName]['queries']
+ = ($this->pluginQueries[$eventName][$depth][$pluginName]['queries'] ?? 0) + $queriesExecuted;
+ }
+
/**
*
* @return void
@@ -115,4 +228,13 @@ class Server extends \Sabre\DAV\Server {
$this->sapi->sendResponse($this->httpResponse);
}
}
+
+ /**
+ * Returns queries executed by registered plugins.
+ * @return array<string, array<int, array<string, array{nodes:int,
+ * queries:int}>>> The keys represent: event name, depth and plugin name
+ */
+ public function getPluginQueries(): array {
+ return $this->pluginQueries;
+ }
}
diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php
index 3749b506d16..1b4de841ec6 100644
--- a/apps/dav/lib/Connector/Sabre/ServerFactory.php
+++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php
@@ -14,6 +14,7 @@ use OCA\DAV\CalDAV\DefaultCalendarValidator;
use OCA\DAV\CalDAV\Proxy\ProxyMapper;
use OCA\DAV\DAV\CustomPropertiesBackend;
use OCA\DAV\DAV\ViewOnlyPlugin;
+use OCA\DAV\Db\PropertyMapper;
use OCA\DAV\Files\BrowserErrorPagePlugin;
use OCA\DAV\Files\Sharing\RootCollection;
use OCA\DAV\Upload\CleanupService;
@@ -68,6 +69,7 @@ class ServerFactory {
Plugin $authPlugin,
callable $viewCallBack,
): Server {
+ $debugEnabled = $this->config->getSystemValue('debug', false);
// Fire up server
if ($isPublicShare) {
$rootCollection = new SimpleCollection('root');
@@ -89,6 +91,12 @@ class ServerFactory {
));
$server->addPlugin(new AnonymousOptionsPlugin());
$server->addPlugin($authPlugin);
+ if ($debugEnabled) {
+ $server->debugEnabled = $debugEnabled;
+ $server->addPlugin(new PropFindMonitorPlugin());
+ }
+
+ $server->addPlugin(new PropFindPreloadNotifyPlugin());
// FIXME: The following line is a workaround for legacy components relying on being able to send a GET to /
$server->addPlugin(new DummyGetResponsePlugin());
$server->addPlugin(new ExceptionLoggerPlugin('webdav', $this->logger));
@@ -117,7 +125,8 @@ class ServerFactory {
}
// wait with registering these until auth is handled and the filesystem is setup
- $server->on('beforeMethod:*', function () use ($server, $tree, $viewCallBack, $isPublicShare, $rootCollection): void {
+ $server->on('beforeMethod:*', function () use ($server, $tree,
+ $viewCallBack, $isPublicShare, $rootCollection, $debugEnabled): void {
// ensure the skeleton is copied
$userFolder = \OC::$server->getUserFolder();
@@ -181,7 +190,7 @@ class ServerFactory {
\OCP\Server::get(IFilenameValidator::class),
\OCP\Server::get(IAccountManager::class),
false,
- !$this->config->getSystemValue('debug', false)
+ !$debugEnabled
)
);
$server->addPlugin(new QuotaPlugin($view));
@@ -220,6 +229,7 @@ class ServerFactory {
$tree,
$this->databaseConnection,
$this->userSession->getUser(),
+ \OCP\Server::get(PropertyMapper::class),
\OCP\Server::get(DefaultCalendarValidator::class),
)
)
diff --git a/apps/dav/lib/Connector/Sabre/SharesPlugin.php b/apps/dav/lib/Connector/Sabre/SharesPlugin.php
index f49e85333f3..11e50362dc2 100644
--- a/apps/dav/lib/Connector/Sabre/SharesPlugin.php
+++ b/apps/dav/lib/Connector/Sabre/SharesPlugin.php
@@ -15,6 +15,7 @@ use OCP\Files\NotFoundException;
use OCP\IUserSession;
use OCP\Share\IManager;
use OCP\Share\IShare;
+use Sabre\DAV\ICollection;
use Sabre\DAV\PropFind;
use Sabre\DAV\Server;
use Sabre\DAV\Tree;
@@ -38,7 +39,14 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin {
/** @var IShare[][] */
private array $cachedShares = [];
- /** @var string[] */
+
+ /**
+ * Tracks which folders have been cached.
+ * When a folder is cached, it will appear with its path as key and true
+ * as value.
+ *
+ * @var bool[]
+ */
private array $cachedFolders = [];
public function __construct(
@@ -67,6 +75,7 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin {
$server->protectedProperties[] = self::SHAREES_PROPERTYNAME;
$this->server = $server;
+ $this->server->on('preloadCollection', $this->preloadCollection(...));
$this->server->on('propFind', [$this, 'handleGetProperties']);
}
@@ -89,28 +98,28 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin {
];
foreach ($requestedShareTypes as $requestedShareType) {
- $result = array_merge($result, $this->shareManager->getSharesBy(
+ $result[] = $this->shareManager->getSharesBy(
$this->userId,
$requestedShareType,
$node,
false,
-1
- ));
+ );
// Also check for shares where the user is the recipient
try {
- $result = array_merge($result, $this->shareManager->getSharedWith(
+ $result[] = $this->shareManager->getSharedWith(
$this->userId,
$requestedShareType,
$node,
-1
- ));
+ );
} catch (BackendError $e) {
// ignore
}
}
- return $result;
+ return array_merge(...$result);
}
/**
@@ -141,7 +150,7 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin {
// if we already cached the folder containing this file
// then we already know there are no shares here.
- if (array_search($parentPath, $this->cachedFolders) === false) {
+ if (!isset($this->cachedFolders[$parentPath])) {
try {
$node = $sabreNode->getNode();
} catch (NotFoundException $e) {
@@ -156,6 +165,27 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin {
return [];
}
+ private function preloadCollection(PropFind $propFind, ICollection $collection): void {
+ if (!$collection instanceof Directory
+ || isset($this->cachedFolders[$collection->getPath()])
+ || (
+ $propFind->getStatus(self::SHARETYPES_PROPERTYNAME) === null
+ && $propFind->getStatus(self::SHAREES_PROPERTYNAME) === null
+ )
+ ) {
+ return;
+ }
+
+ // If the node is a directory and we are requesting share types or sharees
+ // then we get all the shares in the folder and cache them.
+ // This is more performant than iterating each files afterwards.
+ $folderNode = $collection->getNode();
+ $this->cachedFolders[$collection->getPath()] = true;
+ foreach ($this->getSharesFolder($folderNode) as $id => $shares) {
+ $this->cachedShares[$id] = $shares;
+ }
+ }
+
/**
* Adds shares to propfind response
*
@@ -170,24 +200,6 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin {
return;
}
- // If the node is a directory and we are requesting share types or sharees
- // then we get all the shares in the folder and cache them.
- // This is more performant than iterating each files afterwards.
- if ($sabreNode instanceof Directory
- && $propFind->getDepth() !== 0
- && (
- !is_null($propFind->getStatus(self::SHARETYPES_PROPERTYNAME))
- || !is_null($propFind->getStatus(self::SHAREES_PROPERTYNAME))
- )
- ) {
- $folderNode = $sabreNode->getNode();
- $this->cachedFolders[] = $sabreNode->getPath();
- $childShares = $this->getSharesFolder($folderNode);
- foreach ($childShares as $id => $shares) {
- $this->cachedShares[$id] = $shares;
- }
- }
-
$propFind->handle(self::SHARETYPES_PROPERTYNAME, function () use ($sabreNode): ShareTypeList {
$shares = $this->getShares($sabreNode);
diff --git a/apps/dav/lib/Connector/Sabre/TagsPlugin.php b/apps/dav/lib/Connector/Sabre/TagsPlugin.php
index 25c1633df36..ec3e6fc5320 100644
--- a/apps/dav/lib/Connector/Sabre/TagsPlugin.php
+++ b/apps/dav/lib/Connector/Sabre/TagsPlugin.php
@@ -31,6 +31,7 @@ use OCP\EventDispatcher\IEventDispatcher;
use OCP\ITagManager;
use OCP\ITags;
use OCP\IUserSession;
+use Sabre\DAV\ICollection;
use Sabre\DAV\PropFind;
use Sabre\DAV\PropPatch;
@@ -61,6 +62,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin {
* @var array
*/
private $cachedTags;
+ private array $cachedDirectories;
/**
* @param \Sabre\DAV\Tree $tree tree
@@ -92,6 +94,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin {
$server->xml->elementMap[self::TAGS_PROPERTYNAME] = TagList::class;
$this->server = $server;
+ $this->server->on('preloadCollection', $this->preloadCollection(...));
$this->server->on('propFind', [$this, 'handleGetProperties']);
$this->server->on('propPatch', [$this, 'handleUpdateProperties']);
$this->server->on('preloadProperties', [$this, 'handlePreloadProperties']);
@@ -194,6 +197,29 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin {
}
}
+ private function preloadCollection(PropFind $propFind, ICollection $collection):
+ void {
+ if (!($collection instanceof Node)) {
+ return;
+ }
+
+ // need prefetch ?
+ if ($collection instanceof Directory
+ && !isset($this->cachedDirectories[$collection->getPath()])
+ && (!is_null($propFind->getStatus(self::TAGS_PROPERTYNAME))
+ || !is_null($propFind->getStatus(self::FAVORITE_PROPERTYNAME))
+ )) {
+ // note: pre-fetching only supported for depth <= 1
+ $folderContent = $collection->getChildren();
+ $fileIds = [(int)$collection->getId()];
+ foreach ($folderContent as $info) {
+ $fileIds[] = (int)$info->getId();
+ }
+ $this->prefetchTagsForFileIds($fileIds);
+ $this->cachedDirectories[$collection->getPath()] = true;
+ }
+ }
+
/**
* Adds tags and favorites properties to the response,
* if requested.
@@ -210,21 +236,6 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin {
return;
}
- // need prefetch ?
- if ($node instanceof Directory
- && $propFind->getDepth() !== 0
- && (!is_null($propFind->getStatus(self::TAGS_PROPERTYNAME))
- || !is_null($propFind->getStatus(self::FAVORITE_PROPERTYNAME))
- )) {
- // note: pre-fetching only supported for depth <= 1
- $folderContent = $node->getChildren();
- $fileIds = [(int)$node->getId()];
- foreach ($folderContent as $info) {
- $fileIds[] = (int)$info->getId();
- }
- $this->prefetchTagsForFileIds($fileIds);
- }
-
$isFav = null;
$propFind->handle(self::TAGS_PROPERTYNAME, function () use (&$isFav, $node) {
diff --git a/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php
index 220988caba0..f198519b454 100644
--- a/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php
+++ b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php
@@ -67,15 +67,16 @@ class ZipFolderPlugin extends ServerPlugin {
// Remove the root path from the filename to make it relative to the requested folder
$filename = str_replace($rootPath, '', $node->getPath());
+ $mtime = $node->getMTime();
if ($node instanceof NcFile) {
$resource = $node->fopen('rb');
if ($resource === false) {
$this->logger->info('Cannot read file for zip stream', ['filePath' => $node->getPath()]);
throw new \Sabre\DAV\Exception\ServiceUnavailable('Requested file can currently not be accessed.');
}
- $streamer->addFileFromStream($resource, $filename, $node->getSize(), $node->getMTime());
+ $streamer->addFileFromStream($resource, $filename, $node->getSize(), $mtime);
} elseif ($node instanceof NcFolder) {
- $streamer->addEmptyDir($filename);
+ $streamer->addEmptyDir($filename, $mtime);
$content = $node->getDirectoryListing();
foreach ($content as $subNode) {
$this->streamNode($streamer, $subNode, $rootPath);
diff --git a/apps/dav/lib/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php
index f3fff11b3da..be7345f25df 100644
--- a/apps/dav/lib/DAV/CustomPropertiesBackend.php
+++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php
@@ -9,13 +9,20 @@
namespace OCA\DAV\DAV;
use Exception;
+use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Calendar;
+use OCA\DAV\CalDAV\CalendarHome;
+use OCA\DAV\CalDAV\CalendarObject;
use OCA\DAV\CalDAV\DefaultCalendarValidator;
+use OCA\DAV\CalDAV\Integration\ExternalCalendar;
+use OCA\DAV\CalDAV\Outbox;
+use OCA\DAV\CalDAV\Trashbin\TrashbinHome;
use OCA\DAV\Connector\Sabre\Directory;
-use OCA\DAV\Connector\Sabre\FilesPlugin;
+use OCA\DAV\Db\PropertyMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IUser;
+use Sabre\CalDAV\Schedule\Inbox;
use Sabre\DAV\Exception as DavException;
use Sabre\DAV\PropertyStorage\Backend\BackendInterface;
use Sabre\DAV\PropFind;
@@ -66,38 +73,16 @@ class CustomPropertiesBackend implements BackendInterface {
'{DAV:}getetag',
'{DAV:}quota-used-bytes',
'{DAV:}quota-available-bytes',
- '{http://owncloud.org/ns}permissions',
- '{http://owncloud.org/ns}downloadURL',
- '{http://owncloud.org/ns}dDC',
- '{http://owncloud.org/ns}size',
- '{http://nextcloud.org/ns}is-encrypted',
-
- // Currently, returning null from any propfind handler would still trigger the backend,
- // so we add all known Nextcloud custom properties in here to avoid that
-
- // text app
- '{http://nextcloud.org/ns}rich-workspace',
- '{http://nextcloud.org/ns}rich-workspace-file',
- // groupfolders
- '{http://nextcloud.org/ns}acl-enabled',
- '{http://nextcloud.org/ns}acl-can-manage',
- '{http://nextcloud.org/ns}acl-list',
- '{http://nextcloud.org/ns}inherited-acl-list',
- '{http://nextcloud.org/ns}group-folder-id',
- // files_lock
- '{http://nextcloud.org/ns}lock',
- '{http://nextcloud.org/ns}lock-owner-type',
- '{http://nextcloud.org/ns}lock-owner',
- '{http://nextcloud.org/ns}lock-owner-displayname',
- '{http://nextcloud.org/ns}lock-owner-editor',
- '{http://nextcloud.org/ns}lock-time',
- '{http://nextcloud.org/ns}lock-timeout',
- '{http://nextcloud.org/ns}lock-token',
- // photos
- '{http://nextcloud.org/ns}realpath',
- '{http://nextcloud.org/ns}nbItems',
- '{http://nextcloud.org/ns}face-detections',
- '{http://nextcloud.org/ns}face-preview-image',
+ ];
+
+ /**
+ * Allowed properties for the oc/nc namespace, all other properties in the namespace are ignored
+ *
+ * @var string[]
+ */
+ private const ALLOWED_NC_PROPERTIES = [
+ '{http://owncloud.org/ns}calendar-enabled',
+ '{http://owncloud.org/ns}enabled',
];
/**
@@ -119,11 +104,17 @@ class CustomPropertiesBackend implements BackendInterface {
];
/**
+ * Map of well-known property names to default values
+ */
+ private const PROPERTY_DEFAULT_VALUES = [
+ '{http://owncloud.org/ns}calendar-enabled' => '1',
+ ];
+
+ /**
* Properties cache
- *
- * @var array
*/
- private $userCache = [];
+ private array $userCache = [];
+ private array $publishedCache = [];
private XmlService $xmlService;
/**
@@ -136,6 +127,7 @@ class CustomPropertiesBackend implements BackendInterface {
private Tree $tree,
private IDBConnection $connection,
private IUser $user,
+ private PropertyMapper $propertyMapper,
private DefaultCalendarValidator $defaultCalendarValidator,
) {
$this->xmlService = new XmlService();
@@ -155,14 +147,9 @@ class CustomPropertiesBackend implements BackendInterface {
public function propFind($path, PropFind $propFind) {
$requestedProps = $propFind->get404Properties();
- // these might appear
- $requestedProps = array_diff(
- $requestedProps,
- self::IGNORED_PROPERTIES,
- );
$requestedProps = array_filter(
$requestedProps,
- fn ($prop) => !str_starts_with($prop, FilesPlugin::FILE_METADATA_PREFIX),
+ $this->isPropertyAllowed(...),
);
// substr of calendars/ => path is inside the CalDAV component
@@ -224,6 +211,18 @@ class CustomPropertiesBackend implements BackendInterface {
$this->cacheDirectory($path, $node);
}
+ if ($node instanceof CalendarHome && $propFind->getDepth() !== 0) {
+ $backend = $node->getCalDAVBackend();
+ if ($backend instanceof CalDavBackend) {
+ $this->cacheCalendars($node, $requestedProps);
+ }
+ }
+
+ if ($node instanceof CalendarObject) {
+ // No custom properties supported on individual events
+ return;
+ }
+
// First fetch the published properties (set by another user), then get the ones set by
// the current user. If both are set then the latter as priority.
foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
@@ -244,6 +243,16 @@ class CustomPropertiesBackend implements BackendInterface {
}
}
+ private function isPropertyAllowed(string $property): bool {
+ if (in_array($property, self::IGNORED_PROPERTIES)) {
+ return false;
+ }
+ if (str_starts_with($property, '{http://owncloud.org/ns}') || str_starts_with($property, '{http://nextcloud.org/ns}')) {
+ return in_array($property, self::ALLOWED_NC_PROPERTIES);
+ }
+ return true;
+ }
+
/**
* Updates properties for a path
*
@@ -328,6 +337,10 @@ class CustomPropertiesBackend implements BackendInterface {
return [];
}
+ if (isset($this->publishedCache[$path])) {
+ return $this->publishedCache[$path];
+ }
+
$qb = $this->connection->getQueryBuilder();
$qb->select('*')
->from(self::TABLE_NAME)
@@ -338,6 +351,7 @@ class CustomPropertiesBackend implements BackendInterface {
$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
}
$result->closeCursor();
+ $this->publishedCache[$path] = $props;
return $props;
}
@@ -376,6 +390,62 @@ class CustomPropertiesBackend implements BackendInterface {
$this->userCache = array_merge($this->userCache, $propsByPath);
}
+ private function cacheCalendars(CalendarHome $node, array $requestedProperties): void {
+ $calendars = $node->getChildren();
+
+ $users = [];
+ foreach ($calendars as $calendar) {
+ if ($calendar instanceof Calendar) {
+ $user = str_replace('principals/users/', '', $calendar->getPrincipalURI());
+ if (!isset($users[$user])) {
+ $users[$user] = ['calendars/' . $user];
+ }
+ $users[$user][] = 'calendars/' . $user . '/' . $calendar->getUri();
+ } elseif ($calendar instanceof Inbox || $calendar instanceof Outbox || $calendar instanceof TrashbinHome || $calendar instanceof ExternalCalendar) {
+ if ($calendar->getOwner()) {
+ $user = str_replace('principals/users/', '', $calendar->getOwner());
+ if (!isset($users[$user])) {
+ $users[$user] = ['calendars/' . $user];
+ }
+ $users[$user][] = 'calendars/' . $user . '/' . $calendar->getName();
+ }
+ }
+ }
+
+ // user properties
+ $properties = $this->propertyMapper->findPropertiesByPathsAndUsers($users);
+
+ $propsByPath = [];
+ foreach ($users as $paths) {
+ foreach ($paths as $path) {
+ $propsByPath[$path] = [];
+ }
+ }
+
+ foreach ($properties as $property) {
+ $propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype());
+ }
+ $this->userCache = array_merge($this->userCache, $propsByPath);
+
+ // published properties
+ $allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
+ if (empty($allowedProps)) {
+ return;
+ }
+ $paths = [];
+ foreach ($users as $nestedPaths) {
+ $paths = array_merge($paths, $nestedPaths);
+ }
+ $paths = array_unique($paths);
+
+ $propsByPath = array_fill_keys(array_values($paths), []);
+ $properties = $this->propertyMapper->findPropertiesByPaths($paths, $allowedProps);
+ foreach ($properties as $property) {
+ $propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype());
+ }
+ $this->publishedCache = array_merge($this->publishedCache, $propsByPath);
+ }
+
/**
* Returns a list of properties for the given path and current user
*
@@ -422,6 +492,14 @@ class CustomPropertiesBackend implements BackendInterface {
return $props;
}
+ private function isPropertyDefaultValue(string $name, mixed $value): bool {
+ if (!isset(self::PROPERTY_DEFAULT_VALUES[$name])) {
+ return false;
+ }
+
+ return self::PROPERTY_DEFAULT_VALUES[$name] === $value;
+ }
+
/**
* @throws Exception
*/
@@ -438,8 +516,8 @@ class CustomPropertiesBackend implements BackendInterface {
'propertyName' => $propertyName,
];
- // If it was null, we need to delete the property
- if (is_null($propertyValue)) {
+ // If it was null or set to the default value, we need to delete the property
+ if (is_null($propertyValue) || $this->isPropertyDefaultValue($propertyName, $propertyValue)) {
if (array_key_exists($propertyName, $existing)) {
$deleteQuery = $deleteQuery ?? $this->createDeleteQuery();
$deleteQuery
diff --git a/apps/dav/lib/DAV/Sharing/Plugin.php b/apps/dav/lib/DAV/Sharing/Plugin.php
index 03e63813bab..82b000bc8ce 100644
--- a/apps/dav/lib/DAV/Sharing/Plugin.php
+++ b/apps/dav/lib/DAV/Sharing/Plugin.php
@@ -16,6 +16,7 @@ use OCP\AppFramework\Http;
use OCP\IConfig;
use OCP\IRequest;
use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\ICollection;
use Sabre\DAV\INode;
use Sabre\DAV\PropFind;
use Sabre\DAV\Server;
@@ -89,6 +90,7 @@ class Plugin extends ServerPlugin {
$this->server->xml->elementMap['{' . Plugin::NS_OWNCLOUD . '}invite'] = Invite::class;
$this->server->on('method:POST', [$this, 'httpPost']);
+ $this->server->on('preloadCollection', $this->preloadCollection(...));
$this->server->on('propFind', [$this, 'propFind']);
}
@@ -168,6 +170,24 @@ class Plugin extends ServerPlugin {
}
}
+ private function preloadCollection(PropFind $propFind, ICollection $collection): void {
+ if (!$collection instanceof CalendarHome || $propFind->getDepth() !== 1) {
+ return;
+ }
+
+ $backend = $collection->getCalDAVBackend();
+ if (!$backend instanceof CalDavBackend) {
+ return;
+ }
+
+ $calendars = $collection->getChildren();
+ $calendars = array_filter($calendars, static fn (INode $node) => $node instanceof IShareable);
+ /** @var int[] $resourceIds */
+ $resourceIds = array_map(
+ static fn (IShareable $node) => $node->getResourceId(), $calendars);
+ $backend->preloadShares($resourceIds);
+ }
+
/**
* This event is triggered when properties are requested for a certain
* node.
@@ -179,20 +199,6 @@ class Plugin extends ServerPlugin {
* @return void
*/
public function propFind(PropFind $propFind, INode $node) {
- if ($node instanceof CalendarHome && $propFind->getDepth() === 1) {
- $backend = $node->getCalDAVBackend();
- if ($backend instanceof CalDavBackend) {
- $calendars = $node->getChildren();
- $calendars = array_filter($calendars, function (INode $node) {
- return $node instanceof IShareable;
- });
- /** @var int[] $resourceIds */
- $resourceIds = array_map(function (IShareable $node) {
- return $node->getResourceId();
- }, $calendars);
- $backend->preloadShares($resourceIds);
- }
- }
if ($node instanceof IShareable) {
$propFind->handle('{' . Plugin::NS_OWNCLOUD . '}invite', function () use ($node) {
return new Invite(
diff --git a/apps/dav/lib/Db/Property.php b/apps/dav/lib/Db/Property.php
index 96c5f75ef4f..6c1e249ac47 100644
--- a/apps/dav/lib/Db/Property.php
+++ b/apps/dav/lib/Db/Property.php
@@ -16,6 +16,7 @@ use OCP\AppFramework\Db\Entity;
* @method string getPropertypath()
* @method string getPropertyname()
* @method string getPropertyvalue()
+ * @method int getValuetype()
*/
class Property extends Entity {
diff --git a/apps/dav/lib/Db/PropertyMapper.php b/apps/dav/lib/Db/PropertyMapper.php
index 1789194ee7a..a3dbdaa7d98 100644
--- a/apps/dav/lib/Db/PropertyMapper.php
+++ b/apps/dav/lib/Db/PropertyMapper.php
@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace OCA\DAV\Db;
use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
@@ -39,17 +40,43 @@ class PropertyMapper extends QBMapper {
}
/**
+ * @param array<string, string[]> $calendars
* @return Property[]
+ * @throws \OCP\DB\Exception
*/
- public function findPropertiesByPath(string $userId, string $path): array {
+ public function findPropertiesByPathsAndUsers(array $calendars): array {
$selectQb = $this->db->getQueryBuilder();
$selectQb->select('*')
- ->from(self::TABLE_NAME)
- ->where(
- $selectQb->expr()->eq('userid', $selectQb->createNamedParameter($userId)),
- $selectQb->expr()->eq('propertypath', $selectQb->createNamedParameter($path)),
+ ->from(self::TABLE_NAME);
+
+ foreach ($calendars as $user => $paths) {
+ $selectQb->orWhere(
+ $selectQb->expr()->andX(
+ $selectQb->expr()->eq('userid', $selectQb->createNamedParameter($user)),
+ $selectQb->expr()->in('propertypath', $selectQb->createNamedParameter($paths, IQueryBuilder::PARAM_STR_ARRAY)),
+ )
);
+ }
+
return $this->findEntities($selectQb);
}
+ /**
+ * @param string[] $calendars
+ * @param string[] $allowedProperties
+ * @return Property[]
+ * @throws \OCP\DB\Exception
+ */
+ public function findPropertiesByPaths(array $calendars, array $allowedProperties = []): array {
+ $selectQb = $this->db->getQueryBuilder();
+ $selectQb->select('*')
+ ->from(self::TABLE_NAME)
+ ->where($selectQb->expr()->in('propertypath', $selectQb->createNamedParameter($calendars, IQueryBuilder::PARAM_STR_ARRAY)));
+
+ if ($allowedProperties) {
+ $selectQb->andWhere($selectQb->expr()->in('propertyname', $selectQb->createNamedParameter($allowedProperties, IQueryBuilder::PARAM_STR_ARRAY)));
+ }
+
+ return $this->findEntities($selectQb);
+ }
}
diff --git a/apps/dav/lib/Migration/Version1034Date20250813093701.php b/apps/dav/lib/Migration/Version1034Date20250813093701.php
new file mode 100644
index 00000000000..10be71f067b
--- /dev/null
+++ b/apps/dav/lib/Migration/Version1034Date20250813093701.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+use Override;
+
+class Version1034Date20250813093701 extends SimpleMigrationStep {
+ public function __construct(
+ private IDBConnection $db,
+ ) {
+ }
+
+ /**
+ * @param IOutput $output
+ * @param Closure(): ISchemaWrapper $schemaClosure
+ * @param array $options
+ */
+ #[Override]
+ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete('properties')
+ ->where($qb->expr()->eq(
+ 'propertyname',
+ $qb->createNamedParameter(
+ '{http://owncloud.org/ns}calendar-enabled',
+ IQueryBuilder::PARAM_STR,
+ ),
+ IQueryBuilder::PARAM_STR,
+ ))
+ ->andWhere($qb->expr()->eq(
+ 'propertyvalue',
+ $qb->createNamedParameter(
+ '1',
+ IQueryBuilder::PARAM_STR,
+ ),
+ IQueryBuilder::PARAM_STR,
+ ))
+ ->executeStatement();
+ }
+}
diff --git a/apps/dav/lib/RootCollection.php b/apps/dav/lib/RootCollection.php
index f1963c0ef01..870aa0d4540 100644
--- a/apps/dav/lib/RootCollection.php
+++ b/apps/dav/lib/RootCollection.php
@@ -132,6 +132,7 @@ class RootCollection extends SimpleCollection {
);
$contactsSharingBackend = Server::get(\OCA\DAV\CardDAV\Sharing\Backend::class);
+ $config = Server::get(IConfig::class);
$pluginManager = new PluginManager(\OC::$server, Server::get(IAppManager::class));
$usersCardDavBackend = new CardDavBackend(
@@ -140,6 +141,7 @@ class RootCollection extends SimpleCollection {
$userManager,
$dispatcher,
$contactsSharingBackend,
+ $config
);
$usersAddressBookRoot = new AddressBookRoot($userPrincipalBackend, $usersCardDavBackend, $pluginManager, $userSession->getUser(), $groupManager, 'principals/users');
$usersAddressBookRoot->disableListing = $disableListing;
@@ -150,6 +152,7 @@ class RootCollection extends SimpleCollection {
$userManager,
$dispatcher,
$contactsSharingBackend,
+ $config
);
$systemAddressBookRoot = new AddressBookRoot(new SystemPrincipalBackend(), $systemCardDavBackend, $pluginManager, $userSession->getUser(), $groupManager, 'principals/system');
$systemAddressBookRoot->disableListing = $disableListing;
diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php
index f81c7fa6f29..9b4a1b3d33c 100644
--- a/apps/dav/lib/Server.php
+++ b/apps/dav/lib/Server.php
@@ -45,6 +45,8 @@ use OCA\DAV\Connector\Sabre\FilesReportPlugin;
use OCA\DAV\Connector\Sabre\LockPlugin;
use OCA\DAV\Connector\Sabre\MaintenancePlugin;
use OCA\DAV\Connector\Sabre\PropfindCompressionPlugin;
+use OCA\DAV\Connector\Sabre\PropFindMonitorPlugin;
+use OCA\DAV\Connector\Sabre\PropFindPreloadNotifyPlugin;
use OCA\DAV\Connector\Sabre\QuotaPlugin;
use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin;
use OCA\DAV\Connector\Sabre\SharesPlugin;
@@ -53,6 +55,7 @@ use OCA\DAV\Connector\Sabre\ZipFolderPlugin;
use OCA\DAV\DAV\CustomPropertiesBackend;
use OCA\DAV\DAV\PublicAuth;
use OCA\DAV\DAV\ViewOnlyPlugin;
+use OCA\DAV\Db\PropertyMapper;
use OCA\DAV\Events\SabrePluginAddEvent;
use OCA\DAV\Events\SabrePluginAuthInitEvent;
use OCA\DAV\Files\BrowserErrorPagePlugin;
@@ -108,6 +111,7 @@ class Server {
private IRequest $request,
private string $baseUri,
) {
+ $debugEnabled = \OCP\Server::get(IConfig::class)->getSystemValue('debug', false);
$this->profiler = \OCP\Server::get(IProfiler::class);
if ($this->profiler->isEnabled()) {
/** @var IEventLogger $eventLogger */
@@ -120,6 +124,7 @@ class Server {
$root = new RootCollection();
$this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root));
+ $this->server->setLogger($logger);
// Add maintenance plugin
$this->server->addPlugin(new MaintenancePlugin(\OCP\Server::get(IConfig::class), \OC::$server->getL10N('dav')));
@@ -167,7 +172,9 @@ class Server {
$authPlugin->addBackend($authBackend);
// debugging
- if (\OCP\Server::get(IConfig::class)->getSystemValue('debug', false)) {
+ if ($debugEnabled) {
+ $this->server->debugEnabled = true;
+ $this->server->addPlugin(new PropFindMonitorPlugin());
$this->server->addPlugin(new \Sabre\DAV\Browser\Plugin());
} else {
$this->server->addPlugin(new DummyGetResponsePlugin());
@@ -232,6 +239,7 @@ class Server {
\OCP\Server::get(IUserSession::class)
));
+ // performance improvement plugins
$this->server->addPlugin(new CopyEtagHeaderPlugin());
$this->server->addPlugin(new RequestIdHeaderPlugin(\OCP\Server::get(IRequest::class)));
$this->server->addPlugin(new UploadAutoMkcolPlugin());
@@ -243,6 +251,7 @@ class Server {
$eventDispatcher,
));
$this->server->addPlugin(\OCP\Server::get(PaginatePlugin::class));
+ $this->server->addPlugin(new PropFindPreloadNotifyPlugin());
// allow setup of additional plugins
$eventDispatcher->dispatch('OCA\DAV\Connector\Sabre::addPlugin', $event);
@@ -301,6 +310,7 @@ class Server {
$this->server->tree,
\OCP\Server::get(IDBConnection::class),
\OCP\Server::get(IUserSession::class)->getUser(),
+ \OCP\Server::get(PropertyMapper::class),
\OCP\Server::get(DefaultCalendarValidator::class),
)
)
diff --git a/apps/dav/lib/SystemTag/SystemTagPlugin.php b/apps/dav/lib/SystemTag/SystemTagPlugin.php
index 4d4499c7559..6be3e8bd1a2 100644
--- a/apps/dav/lib/SystemTag/SystemTagPlugin.php
+++ b/apps/dav/lib/SystemTag/SystemTagPlugin.php
@@ -27,6 +27,7 @@ use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\Exception\Conflict;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\UnsupportedMediaType;
+use Sabre\DAV\ICollection;
use Sabre\DAV\PropFind;
use Sabre\DAV\PropPatch;
use Sabre\HTTP\RequestInterface;
@@ -94,6 +95,7 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
$server->protectedProperties[] = self::ID_PROPERTYNAME;
+ $server->on('preloadCollection', $this->preloadCollection(...));
$server->on('propFind', [$this, 'handleGetProperties']);
$server->on('propPatch', [$this, 'handleUpdateProperties']);
$server->on('method:POST', [$this, 'httpPost']);
@@ -199,6 +201,40 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
}
}
+ private function preloadCollection(
+ PropFind $propFind,
+ ICollection $collection,
+ ): void {
+ if (!$collection instanceof Node) {
+ return;
+ }
+
+ if ($collection instanceof Directory
+ && !isset($this->cachedTagMappings[$collection->getId()])
+ && $propFind->getStatus(
+ self::SYSTEM_TAGS_PROPERTYNAME
+ ) !== null) {
+ $fileIds = [$collection->getId()];
+
+ // note: pre-fetching only supported for depth <= 1
+ $folderContent = $collection->getChildren();
+ foreach ($folderContent as $info) {
+ if ($info instanceof Node) {
+ $fileIds[] = $info->getId();
+ }
+ }
+
+ $tags = $this->tagMapper->getTagIdsForObjects($fileIds, 'files');
+
+ $this->cachedTagMappings += $tags;
+ $emptyFileIds = array_diff($fileIds, array_keys($tags));
+
+ // also cache the ones that were not found
+ foreach ($emptyFileIds as $fileId) {
+ $this->cachedTagMappings[$fileId] = [];
+ }
+ }
+ }
/**
* Retrieves system tag properties
@@ -297,29 +333,6 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
}
private function propfindForFile(PropFind $propFind, Node $node): void {
- if ($node instanceof Directory
- && $propFind->getDepth() !== 0
- && !is_null($propFind->getStatus(self::SYSTEM_TAGS_PROPERTYNAME))) {
- $fileIds = [$node->getId()];
-
- // note: pre-fetching only supported for depth <= 1
- $folderContent = $node->getChildren();
- foreach ($folderContent as $info) {
- if ($info instanceof Node) {
- $fileIds[] = $info->getId();
- }
- }
-
- $tags = $this->tagMapper->getTagIdsForObjects($fileIds, 'files');
-
- $this->cachedTagMappings = $this->cachedTagMappings + $tags;
- $emptyFileIds = array_diff($fileIds, array_keys($tags));
-
- // also cache the ones that were not found
- foreach ($emptyFileIds as $fileId) {
- $this->cachedTagMappings[$fileId] = [];
- }
- }
$propFind->handle(self::SYSTEM_TAGS_PROPERTYNAME, function () use ($node) {
$user = $this->userSession->getUser();