diff --git a/apps/dav/appinfo/v1/publicwebdav.php b/apps/dav/appinfo/v1/publicwebdav.php index 4dbf058bf239f..62c1d7a8a3bdb 100644 --- a/apps/dav/appinfo/v1/publicwebdav.php +++ b/apps/dav/appinfo/v1/publicwebdav.php @@ -5,13 +5,16 @@ * SPDX-FileCopyrightText: 2016 ownCloud, Inc. * SPDX-License-Identifier: AGPL-3.0-only */ +use NCU\Files\NodeAttributes\NodeAttributeContext; use OC\Files\Filesystem; +use OC\Files\NodeAttributes\NodeAttributeRegistry; use OC\Files\Storage\Wrapper\DirPermissionsMask; use OC\Files\Storage\Wrapper\PermissionsMask; use OC\Files\View; use OC\OCM\OCMSignatoryManager; use OCA\DAV\Connector\LegacyPublicAuth; use OCA\DAV\Connector\Sabre\BearerAuth; +use OCA\DAV\Connector\Sabre\NodeAttributesPlugin; use OCA\DAV\Connector\Sabre\ServerFactory; use OCA\DAV\Files\Sharing\FilesDropPlugin; use OCA\DAV\Files\Sharing\PublicLinkCheckPlugin; @@ -27,7 +30,6 @@ use OCP\Files\Storage\IStorage; use OCP\IConfig; use OCP\IDBConnection; -use OCP\IPreview; use OCP\IRequest; use OCP\ISession; use OCP\ITagManager; @@ -79,7 +81,6 @@ Server::get(IMountManager::class), Server::get(ITagManager::class), Server::get(IRequest::class), - Server::get(IPreview::class), $eventDispatcher, Server::get(IL10nFactory::class)->get('dav') ); @@ -159,6 +160,10 @@ function (\Sabre\DAV\Server $server) use ( $filesDropPlugin->enable(); } $filesDropPlugin->setShare($share); + $server->addPlugin(new NodeAttributesPlugin( + Server::get(NodeAttributeRegistry::class), + static fn (): NodeAttributeContext => NodeAttributeContext::forShare($share), + )); return new View($node->getPath()); }); diff --git a/apps/dav/appinfo/v1/webdav.php b/apps/dav/appinfo/v1/webdav.php index 5453c0a8bfb05..33c934a046a68 100644 --- a/apps/dav/appinfo/v1/webdav.php +++ b/apps/dav/appinfo/v1/webdav.php @@ -14,7 +14,6 @@ use OCP\Files\Mount\IMountManager; use OCP\IConfig; use OCP\IDBConnection; -use OCP\IPreview; use OCP\IRequest; use OCP\ISession; use OCP\ITagManager; @@ -46,7 +45,6 @@ Server::get(IMountManager::class), Server::get(ITagManager::class), Server::get(IRequest::class), - Server::get(IPreview::class), $dispatcher, Server::get(IL10nFactory::class)->get('dav') ); diff --git a/apps/dav/appinfo/v2/publicremote.php b/apps/dav/appinfo/v2/publicremote.php index 5e0d51825fc8c..3df919db30c34 100644 --- a/apps/dav/appinfo/v2/publicremote.php +++ b/apps/dav/appinfo/v2/publicremote.php @@ -5,12 +5,15 @@ * SPDX-FileCopyrightText: 2016 ownCloud, Inc. * SPDX-License-Identifier: AGPL-3.0-only */ +use NCU\Files\NodeAttributes\NodeAttributeContext; use OC\Files\Filesystem; +use OC\Files\NodeAttributes\NodeAttributeRegistry; use OC\Files\Storage\Wrapper\DirPermissionsMask; use OC\Files\Storage\Wrapper\PermissionsMask; use OC\Files\View; use OC\OCM\OCMSignatoryManager; use OCA\DAV\Connector\Sabre\BearerAuth; +use OCA\DAV\Connector\Sabre\NodeAttributesPlugin; use OCA\DAV\Connector\Sabre\PublicAuth; use OCA\DAV\Connector\Sabre\ServerFactory; use OCA\DAV\Files\Sharing\FilesDropPlugin; @@ -31,7 +34,6 @@ use OCP\ICacheFactory; use OCP\IConfig; use OCP\IDBConnection; -use OCP\IPreview; use OCP\IRequest; use OCP\ISession; use OCP\ITagManager; @@ -91,7 +93,6 @@ Server::get(IMountManager::class), Server::get(ITagManager::class), $request, - Server::get(IPreview::class), $eventDispatcher, $l10nFactory->get('dav'), ); @@ -183,6 +184,10 @@ $filesDropPlugin->enable(); } $filesDropPlugin->setShare($share); + $server->addPlugin(new NodeAttributesPlugin( + Server::get(NodeAttributeRegistry::class), + static fn (): NodeAttributeContext => NodeAttributeContext::forShare($share), + )); $view = new View($node->getPath()); return $view; diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 09e996cae1955..3cbe10a457239 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -257,6 +257,8 @@ 'OCA\\DAV\\Connector\\Sabre\\MaintenancePlugin' => $baseDir . '/../lib/Connector/Sabre/MaintenancePlugin.php', 'OCA\\DAV\\Connector\\Sabre\\MtimeSanitizer' => $baseDir . '/../lib/Connector/Sabre/MtimeSanitizer.php', 'OCA\\DAV\\Connector\\Sabre\\Node' => $baseDir . '/../lib/Connector/Sabre/Node.php', + 'OCA\\DAV\\Connector\\Sabre\\NodeAttributeListXml' => $baseDir . '/../lib/Connector/Sabre/NodeAttributeListXml.php', + 'OCA\\DAV\\Connector\\Sabre\\NodeAttributesPlugin' => $baseDir . '/../lib/Connector/Sabre/NodeAttributesPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ObjectTree' => $baseDir . '/../lib/Connector/Sabre/ObjectTree.php', 'OCA\\DAV\\Connector\\Sabre\\Principal' => $baseDir . '/../lib/Connector/Sabre/Principal.php', 'OCA\\DAV\\Connector\\Sabre\\PropFindMonitorPlugin' => $baseDir . '/../lib/Connector/Sabre/PropFindMonitorPlugin.php', @@ -343,6 +345,8 @@ 'OCA\\DAV\\Files\\FileSearchBackend' => $baseDir . '/../lib/Files/FileSearchBackend.php', 'OCA\\DAV\\Files\\FilesHome' => $baseDir . '/../lib/Files/FilesHome.php', 'OCA\\DAV\\Files\\LazySearchBackend' => $baseDir . '/../lib/Files/LazySearchBackend.php', + 'OCA\\DAV\\Files\\NodeAttributes\\FavoriteProvider' => $baseDir . '/../lib/Files/NodeAttributes/FavoriteProvider.php', + 'OCA\\DAV\\Files\\NodeAttributes\\HasPreviewProvider' => $baseDir . '/../lib/Files/NodeAttributes/HasPreviewProvider.php', 'OCA\\DAV\\Files\\RootCollection' => $baseDir . '/../lib/Files/RootCollection.php', 'OCA\\DAV\\Files\\Sharing\\FilesDropPlugin' => $baseDir . '/../lib/Files/Sharing/FilesDropPlugin.php', 'OCA\\DAV\\Files\\Sharing\\PublicLinkCheckPlugin' => $baseDir . '/../lib/Files/Sharing/PublicLinkCheckPlugin.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index e4a30f57c1a9c..8652c44217600 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -272,6 +272,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Connector\\Sabre\\MaintenancePlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/MaintenancePlugin.php', 'OCA\\DAV\\Connector\\Sabre\\MtimeSanitizer' => __DIR__ . '/..' . '/../lib/Connector/Sabre/MtimeSanitizer.php', 'OCA\\DAV\\Connector\\Sabre\\Node' => __DIR__ . '/..' . '/../lib/Connector/Sabre/Node.php', + 'OCA\\DAV\\Connector\\Sabre\\NodeAttributeListXml' => __DIR__ . '/..' . '/../lib/Connector/Sabre/NodeAttributeListXml.php', + 'OCA\\DAV\\Connector\\Sabre\\NodeAttributesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/NodeAttributesPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ObjectTree' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ObjectTree.php', 'OCA\\DAV\\Connector\\Sabre\\Principal' => __DIR__ . '/..' . '/../lib/Connector/Sabre/Principal.php', 'OCA\\DAV\\Connector\\Sabre\\PropFindMonitorPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/PropFindMonitorPlugin.php', @@ -358,6 +360,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Files\\FileSearchBackend' => __DIR__ . '/..' . '/../lib/Files/FileSearchBackend.php', 'OCA\\DAV\\Files\\FilesHome' => __DIR__ . '/..' . '/../lib/Files/FilesHome.php', 'OCA\\DAV\\Files\\LazySearchBackend' => __DIR__ . '/..' . '/../lib/Files/LazySearchBackend.php', + 'OCA\\DAV\\Files\\NodeAttributes\\FavoriteProvider' => __DIR__ . '/..' . '/../lib/Files/NodeAttributes/FavoriteProvider.php', + 'OCA\\DAV\\Files\\NodeAttributes\\HasPreviewProvider' => __DIR__ . '/..' . '/../lib/Files/NodeAttributes/HasPreviewProvider.php', 'OCA\\DAV\\Files\\RootCollection' => __DIR__ . '/..' . '/../lib/Files/RootCollection.php', 'OCA\\DAV\\Files\\Sharing\\FilesDropPlugin' => __DIR__ . '/..' . '/../lib/Files/Sharing/FilesDropPlugin.php', 'OCA\\DAV\\Files\\Sharing\\PublicLinkCheckPlugin' => __DIR__ . '/..' . '/../lib/Files/Sharing/PublicLinkCheckPlugin.php', diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php index f9bb2fd57d10e..e01e3f86ce6b6 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -29,6 +29,8 @@ use OCA\DAV\CardDAV\Search\ContactAccountScopedSearchProvider; use OCA\DAV\CardDAV\SyncService; use OCA\DAV\ConfigLexicon; +use OCA\DAV\Connector\Sabre\FilesPlugin; +use OCA\DAV\Connector\Sabre\TagsPlugin; use OCA\DAV\Events\AddressBookCreatedEvent; use OCA\DAV\Events\AddressBookDeletedEvent; use OCA\DAV\Events\AddressBookShareUpdatedEvent; @@ -47,6 +49,8 @@ use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\DAV\Events\SubscriptionCreatedEvent; use OCA\DAV\Events\SubscriptionDeletedEvent; +use OCA\DAV\Files\NodeAttributes\FavoriteProvider; +use OCA\DAV\Files\NodeAttributes\HasPreviewProvider; use OCA\DAV\Listener\ActivityUpdaterListener; use OCA\DAV\Listener\AddMissingIndicesListener; use OCA\DAV\Listener\AddressbookListener; @@ -128,6 +132,8 @@ public function __construct() { #[\Override] public function register(IRegistrationContext $context): void { $context->registerServiceAlias('CardDAVSyncService', SyncService::class); + $context->registerNodeAttributeProvider(HasPreviewProvider::class, [FilesPlugin::HAS_PREVIEW_PROPERTYNAME]); + $context->registerNodeAttributeProvider(FavoriteProvider::class, [TagsPlugin::FAVORITE_PROPERTYNAME]); $context->registerService(AppCalendarPlugin::class, function (ContainerInterface $c) { return new AppCalendarPlugin( $c->get(ICalendarManager::class), diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index e386b6579feea..717bd89f96936 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -24,7 +24,6 @@ use OCP\FilesMetadata\IFilesMetadataManager; use OCP\FilesMetadata\Model\IMetadataValueWrapper; use OCP\IConfig; -use OCP\IPreview; use OCP\IRequest; use OCP\IUserSession; use OCP\L10N\IFactory; @@ -84,7 +83,6 @@ class FilesPlugin extends ServerPlugin { * @param Tree $tree * @param IConfig $config * @param IRequest $request - * @param IPreview $previewManager * @param IUserSession $userSession * @param bool $isPublic Whether this is public WebDAV. If true, some returned information will be stripped off. * @param bool $downloadAttachment @@ -94,7 +92,6 @@ public function __construct( private Tree $tree, private IConfig $config, private IRequest $request, - private IPreview $previewManager, private IUserSession $userSession, private IFilenameValidator $validator, private IAccountManager $accountManager, @@ -397,9 +394,6 @@ public function handleGetProperties(PropFind $propFind, \Sabre\DAV\INode $node) return null; }); - $propFind->handle(self::HAS_PREVIEW_PROPERTYNAME, function () use ($node) { - return json_encode($this->previewManager->isAvailable($node->getFileInfo()), JSON_THROW_ON_ERROR); - }); $propFind->handle(self::SIZE_PROPERTYNAME, function () use ($node): int|float { return $node->getSize(); }); diff --git a/apps/dav/lib/Connector/Sabre/NodeAttributeListXml.php b/apps/dav/lib/Connector/Sabre/NodeAttributeListXml.php new file mode 100644 index 0000000000000..60470338256c4 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/NodeAttributeListXml.php @@ -0,0 +1,35 @@ + $values + */ + public function __construct( + private readonly string $childElement, + private readonly array $values, + ) { + } + + #[\Override] + public function xmlSerialize(Writer $writer): void { + foreach ($this->values as $value) { + $writer->writeElement($this->childElement, is_bool($value) ? ($value ? 'true' : 'false') : (string)$value); + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/NodeAttributesPlugin.php b/apps/dav/lib/Connector/Sabre/NodeAttributesPlugin.php new file mode 100644 index 0000000000000..e849f9de03e77 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/NodeAttributesPlugin.php @@ -0,0 +1,143 @@ +on('preloadCollection', $this->preloadCollection(...)); + $server->on('preloadProperties', $this->preloadProperties(...)); + $server->on('propFind', $this->propFind(...)); + } + + private function preloadCollection(PropFind $propFind, ICollection $collection): void { + if (!($collection instanceof Directory)) { + return; + } + + $attributes = $this->getRequestedAttributes($propFind); + if ($attributes === []) { + return; + } + + $nodes = [$collection->getNode()]; + foreach ($collection->getChildren() as $child) { + if ($child instanceof Node) { + $nodes[] = $child->getNode(); + } + } + $this->preload($nodes, $attributes); + } + + /** + * Preload the attributes of search results + * + * @param INode[] $nodes + * @param string[] $requestProperties + */ + private function preloadProperties(array $nodes, array $requestProperties): void { + $attributes = array_values(array_intersect($requestProperties, $this->registry->getAttributeNames())); + if ($attributes === []) { + return; + } + + $fileNodes = []; + foreach ($nodes as $node) { + if ($node instanceof Node) { + $fileNodes[] = $node->getNode(); + } + } + $this->preload($fileNodes, $attributes); + } + + private function propFind(PropFind $propFind, INode $node): void { + if (!($node instanceof Node)) { + return; + } + + $attributes = $this->getRequestedAttributes($propFind); + $context = ($this->contextResolver)(); + if ($attributes === [] || $context === null) { + return; + } + + $fileNode = $node->getNode(); + $this->registry->preload($context, [$fileNode], $attributes); + + foreach ($this->registry->getProvidersFor($attributes) as $group) { + $values = $group['provider']->getAttributes($context, $fileNode, $group['attributes']); + foreach ($group['attributes'] as $attribute) { + if (!isset($values[$attribute])) { + continue; + } + $value = $this->toDavValue($attribute, $values[$attribute]); + $propFind->handle($attribute, static fn () => $value); + } + } + } + + /** + * Attributes are only served when requested, also for allprop requests like other custom properties + * + * @return list + */ + private function getRequestedAttributes(PropFind $propFind): array { + return array_values(array_intersect($propFind->getRequestedProperties(), $this->registry->getAttributeNames())); + } + + /** + * @param list<\OCP\Files\Node> $nodes + * @param list $attributes + */ + private function preload(array $nodes, array $attributes): void { + $context = ($this->contextResolver)(); + if ($context === null || $nodes === []) { + return; + } + + $this->registry->preload($context, $nodes, $attributes); + } + + /** + * Values are typed for JSON, WebDAV carries text that the client parses back + */ + private function toDavValue(string $attribute, bool|int|float|string|NodeAttributeList $value): int|float|string|NodeAttributeListXml { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if ($value instanceof NodeAttributeList) { + $namespace = substr($attribute, 0, (int)strrpos($attribute, '}') + 1); + return new NodeAttributeListXml($namespace . $value->getChildName(), $value->getValues()); + } + return $value; + } +} diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index d8b50bd4bea00..60995f8967bc8 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -8,6 +8,8 @@ namespace OCA\DAV\Connector\Sabre; +use NCU\Files\NodeAttributes\NodeAttributeContext; +use OC\Files\NodeAttributes\NodeAttributeRegistry; use OC\Files\View; use OC\KnownUser\KnownUserService; use OCA\DAV\AppInfo\PluginManager; @@ -33,7 +35,6 @@ use OCP\IDBConnection; use OCP\IGroupManager; use OCP\IL10N; -use OCP\IPreview; use OCP\IRequest; use OCP\ITagManager; use OCP\IUserManager; @@ -56,7 +57,6 @@ public function __construct( private IMountManager $mountManager, private ITagManager $tagManager, private IRequest $request, - private IPreview $previewManager, private IEventDispatcher $eventDispatcher, private IL10N $l10n, ) { @@ -171,7 +171,6 @@ public function createServer( $tree, $this->config, $this->request, - $this->previewManager, $this->userSession, \OCP\Server::get(IFilenameValidator::class), \OCP\Server::get(IAccountManager::class), @@ -189,6 +188,13 @@ public function createServer( if ($this->userSession->isLoggedIn()) { $server->addPlugin(new TagsPlugin($tree, $this->tagManager, $this->eventDispatcher, $this->userSession)); + if (!$isPublicShare) { + // Public shares add their own, with the share as context + $server->addPlugin(new NodeAttributesPlugin( + \OCP\Server::get(NodeAttributeRegistry::class), + fn (): ?NodeAttributeContext => ($user = $this->userSession->getUser()) !== null ? NodeAttributeContext::forUser($user) : null, + )); + } $server->addPlugin(new SharesPlugin( $tree, $this->userSession, diff --git a/apps/dav/lib/Connector/Sabre/TagsPlugin.php b/apps/dav/lib/Connector/Sabre/TagsPlugin.php index 5d9549e08a282..e86fc24473061 100644 --- a/apps/dav/lib/Connector/Sabre/TagsPlugin.php +++ b/apps/dav/lib/Connector/Sabre/TagsPlugin.php @@ -211,9 +211,7 @@ private function preloadCollection(PropFind $propFind, ICollection $collection): // 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)) - )) { + && !is_null($propFind->getStatus(self::TAGS_PROPERTYNAME))) { // note: pre-fetching only supported for depth <= 1 $folderContent = $collection->getChildren(); $fileIds = [(int)$collection->getId()]; @@ -226,8 +224,8 @@ private function preloadCollection(PropFind $propFind, ICollection $collection): } /** - * Adds tags and favorites properties to the response, - * if requested. + * Adds the tags property to the response, if requested. + * Favorites are served by the favorite node attribute provider. * * @param PropFind $propFind * @param \Sabre\DAV\INode $node @@ -241,23 +239,10 @@ public function handleGetProperties( return; } - $isFav = null; - - $propFind->handle(self::TAGS_PROPERTYNAME, function () use (&$isFav, $node) { - [$tags, $isFav] = $this->getTagsAndFav($node->getId()); + $propFind->handle(self::TAGS_PROPERTYNAME, function () use ($node) { + [$tags] = $this->getTagsAndFav($node->getId()); return new TagList($tags); }); - - $propFind->handle(self::FAVORITE_PROPERTYNAME, function () use ($isFav, $node) { - if (is_null($isFav)) { - [, $isFav] = $this->getTagsAndFav($node->getId()); - } - if ($isFav) { - return 1; - } else { - return 0; - } - }); } /** @@ -296,10 +281,7 @@ public function handleUpdateProperties($path, PropPatch $propPatch) { } public function handlePreloadProperties(array $nodes, array $requestProperties): void { - if ( - !in_array(self::FAVORITE_PROPERTYNAME, $requestProperties, true) - && !in_array(self::TAGS_PROPERTYNAME, $requestProperties, true) - ) { + if (!in_array(self::TAGS_PROPERTYNAME, $requestProperties, true)) { return; } $this->prefetchTagsForFileIds(array_map(fn ($node) => $node->getId(), $nodes)); diff --git a/apps/dav/lib/Files/NodeAttributes/FavoriteProvider.php b/apps/dav/lib/Files/NodeAttributes/FavoriteProvider.php new file mode 100644 index 0000000000000..7b123d14c11e2 --- /dev/null +++ b/apps/dav/lib/Files/NodeAttributes/FavoriteProvider.php @@ -0,0 +1,55 @@ +> favorite state by user id and file id */ + private array $favorites = []; + + public function __construct( + private readonly ITagManager $tagManager, + ) { + } + + #[\Override] + public function preload(NodeAttributeContext $context, array $nodes, array $attributes): void { + $userId = $context->getUser()?->getUID(); + if ($userId === null) { + return; + } + + $fileIds = array_map(static fn (Node $node): int => $node->getId(), $nodes); + $tags = $this->tagManager->load('files', [], false, $userId)?->getTagsForObjects($fileIds) ?: []; + foreach ($fileIds as $fileId) { + $this->favorites[$userId][$fileId] = in_array(TagsPlugin::TAG_FAVORITE, $tags[$fileId] ?? [], true); + } + } + + #[\Override] + public function getAttributes(NodeAttributeContext $context, Node $node, array $attributes): array { + $userId = $context->getUser()?->getUID(); + if ($userId === null) { + return []; + } + + return [ + TagsPlugin::FAVORITE_PROPERTYNAME => ($this->favorites[$userId][$node->getId()] ?? false) ? 1 : 0, + ]; + } +} diff --git a/apps/dav/lib/Files/NodeAttributes/HasPreviewProvider.php b/apps/dav/lib/Files/NodeAttributes/HasPreviewProvider.php new file mode 100644 index 0000000000000..eb7eb98bb9c01 --- /dev/null +++ b/apps/dav/lib/Files/NodeAttributes/HasPreviewProvider.php @@ -0,0 +1,38 @@ + $this->previewManager->isAvailable($node), + ]; + } +} diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index e80229b2e3398..78ae1c425850d 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -8,7 +8,9 @@ namespace OCA\DAV; +use NCU\Files\NodeAttributes\NodeAttributeContext; use OC\Files\Filesystem; +use OC\Files\NodeAttributes\NodeAttributeRegistry; use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\BulkUpload\BulkUploadPlugin; use OCA\DAV\CalDAV\BirthdayCalendar\EnablePlugin; @@ -46,6 +48,7 @@ use OCA\DAV\Connector\Sabre\FilesReportPlugin; use OCA\DAV\Connector\Sabre\LockPlugin; use OCA\DAV\Connector\Sabre\MaintenancePlugin; +use OCA\DAV\Connector\Sabre\NodeAttributesPlugin; use OCA\DAV\Connector\Sabre\PropfindCompressionPlugin; use OCA\DAV\Connector\Sabre\PropFindMonitorPlugin; use OCA\DAV\Connector\Sabre\PropFindPreloadNotifyPlugin; @@ -90,7 +93,6 @@ use OCP\IDateTimeZone; use OCP\IDBConnection; use OCP\IGroupManager; -use OCP\IPreview; use OCP\IRequest; use OCP\ISession; use OCP\ITagManager; @@ -305,7 +307,6 @@ public function __construct( $this->server->tree, $config, $this->request, - \OCP\Server::get(IPreview::class), \OCP\Server::get(IUserSession::class), \OCP\Server::get(IFilenameValidator::class), \OCP\Server::get(IAccountManager::class), @@ -336,6 +337,10 @@ public function __construct( $this->server->tree, \OCP\Server::get(ITagManager::class), \OCP\Server::get(IEventDispatcher::class), \OCP\Server::get(IUserSession::class) ) ); + $this->server->addPlugin(new NodeAttributesPlugin( + \OCP\Server::get(NodeAttributeRegistry::class), + static fn (): ?NodeAttributeContext => ($sessionUser = $userSession->getUser()) !== null ? NodeAttributeContext::forUser($sessionUser) : null, + )); $shareManager = \OCP\Server::get(\OCP\Share\IManager::class); $this->server->addPlugin(new SharesPlugin( diff --git a/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php index a5b79b757a8ad..e7799fecee5ef 100644 --- a/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php @@ -23,7 +23,6 @@ use OCP\Files\InvalidPathException; use OCP\Files\StorageNotAvailableException; use OCP\IConfig; -use OCP\IPreview; use OCP\IRequest; use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; @@ -45,7 +44,6 @@ class FilesPluginTest extends TestCase { private Server&MockObject $server; private IConfig&MockObject $config; private IRequest&MockObject $request; - private IPreview&MockObject $previewManager; private IUserSession&MockObject $userSession; private IFilenameValidator&MockObject $filenameValidator; private IAccountManager&MockObject $accountManager; @@ -60,7 +58,6 @@ protected function setUp(): void { ->with($this->equalTo('data-fingerprint'), $this->equalTo('')) ->willReturn('my_fingerprint'); $this->request = $this->createMock(IRequest::class); - $this->previewManager = $this->createMock(IPreview::class); $this->userSession = $this->createMock(IUserSession::class); $this->filenameValidator = $this->createMock(IFilenameValidator::class); $this->accountManager = $this->createMock(IAccountManager::class); @@ -69,7 +66,6 @@ protected function setUp(): void { $this->tree, $this->config, $this->request, - $this->previewManager, $this->userSession, $this->filenameValidator, $this->accountManager, @@ -318,7 +314,6 @@ public function testGetPublicPermissions(): void { $this->tree, $this->config, $request, - $this->previewManager, $this->userSession, $this->filenameValidator, $this->accountManager, @@ -691,28 +686,4 @@ public function testDownloadHeaders(bool $isClumsyAgent, string $contentDisposit $this->plugin->httpGet($request, $response); } - - public function testHasPreview(): void { - /** @var Directory&MockObject $node */ - $node = $this->createTestNode(Directory::class); - - $propFind = new PropFind( - '/dummyPath', - [ - FilesPlugin::HAS_PREVIEW_PROPERTYNAME - ], - 0 - ); - - $this->previewManager->expects($this->once()) - ->method('isAvailable') - ->willReturn(false); - - $this->plugin->handleGetProperties( - $propFind, - $node - ); - - $this->assertEquals('false', $propFind->get(FilesPlugin::HAS_PREVIEW_PROPERTYNAME)); - } } diff --git a/apps/dav/tests/unit/Connector/Sabre/FilesReportPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/FilesReportPluginTest.php index f3dbe89fdc9dc..f6811c2684ee0 100644 --- a/apps/dav/tests/unit/Connector/Sabre/FilesReportPluginTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/FilesReportPluginTest.php @@ -22,7 +22,6 @@ use OCP\Files\IUserFolder; use OCP\IConfig; use OCP\IGroupManager; -use OCP\IPreview; use OCP\IRequest; use OCP\ITagManager; use OCP\ITags; @@ -51,7 +50,6 @@ class FilesReportPluginTest extends \Test\TestCase { private View&MockObject $view; private IGroupManager&MockObject $groupManager; private Folder&MockObject $userFolder; - private IPreview&MockObject $previewManager; private IAppManager&MockObject $appManager; protected function setUp(): void { @@ -75,7 +73,6 @@ protected function setUp(): void { $this->groupManager = $this->createMock(IGroupManager::class); $this->userFolder = $this->createMock(IUserFolder::class); - $this->previewManager = $this->createMock(IPreview::class); $this->appManager = $this->createMock(IAppManager::class); $this->tagManager = $this->createMock(ISystemTagManager::class); $this->tagMapper = $this->createMock(ISystemTagObjectMapper::class); @@ -345,7 +342,6 @@ public function testPrepareResponses(): void { $this->tree, $config, $this->createMock(IRequest::class), - $this->previewManager, $this->createMock(IUserSession::class), $validator, $accountManager, diff --git a/apps/dav/tests/unit/Connector/Sabre/NodeAttributesPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/NodeAttributesPluginTest.php new file mode 100644 index 0000000000000..5777f64be3b22 --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/NodeAttributesPluginTest.php @@ -0,0 +1,150 @@ +registry = $this->createMock(NodeAttributeRegistry::class); + $this->provider = $this->createMock(INodeAttributeProvider::class); + $this->registry->method('getAttributeNames')->willReturn([self::HAS_PREVIEW, self::FAVORITE, self::SHARE_TYPES]); + $this->registry->method('getProvidersFor')->willReturnCallback(fn (array $attributes): array => $attributes === [] + ? [] + : [['provider' => $this->provider, 'attributes' => array_values($attributes)]]); + + $user = $this->createMock(IUser::class); + $this->context = NodeAttributeContext::forUser($user); + + $this->server = new Server(); + $this->server->addPlugin(new NodeAttributesPlugin($this->registry, fn (): ?NodeAttributeContext => $this->context)); + } + + /** + * @template T of File|Directory + * @param class-string $class + * @return T&MockObject + */ + private function davNode(string $class, int $id): File|Directory { + $node = $this->createMock($class === Directory::class ? Folder::class : \OCP\Files\File::class); + $node->method('getId')->willReturn($id); + $davNode = $this->createMock($class); + $davNode->method('getNode')->willReturn($node); + $davNode->method('getId')->willReturn($id); + return $davNode; + } + + public function testPropFindServesTypedValuesAsDavText(): void { + $davNode = $this->davNode(File::class, 11); + $this->registry->expects(self::once()) + ->method('preload') + ->with($this->context, [$davNode->getNode()], [self::HAS_PREVIEW, self::FAVORITE]); + $this->provider->method('getAttributes')->willReturn([ + self::HAS_PREVIEW => true, + self::FAVORITE => 1, + ]); + + $propFind = new PropFind('/file.txt', [self::HAS_PREVIEW, self::FAVORITE, '{http://example.com/ns}other'], 0); + $this->server->emit('propFind', [$propFind, $davNode]); + + self::assertSame('true', $propFind->get(self::HAS_PREVIEW)); + self::assertSame(1, $propFind->get(self::FAVORITE)); + self::assertNull($propFind->get('{http://example.com/ns}other')); + } + + public function testCollectionIsPreloadedAtOnce(): void { + $child1 = $this->davNode(File::class, 21); + $child2 = $this->davNode(File::class, 22); + $directory = $this->davNode(Directory::class, 20); + $directory->method('getChildren')->willReturn([$child1, $child2]); + + $this->registry->expects(self::once()) + ->method('preload') + ->with($this->context, [$directory->getNode(), $child1->getNode(), $child2->getNode()], [self::FAVORITE]); + + $propFind = new PropFind('/dir', [self::FAVORITE], 1); + $this->server->emit('preloadCollection', [$propFind, $directory]); + } + + public function testSearchResultsArePreloaded(): void { + $result1 = $this->davNode(File::class, 31); + $result2 = $this->davNode(File::class, 32); + + $this->registry->expects(self::once()) + ->method('preload') + ->with($this->context, [$result1->getNode(), $result2->getNode()], [self::HAS_PREVIEW]); + + $this->server->emit('preloadProperties', [[$result1, $result2], [self::HAS_PREVIEW, '{DAV:}getetag']]); + } + + public function testAllPropsDoesNotIncludeAttributes(): void { + $this->provider->expects(self::never())->method('getAttributes'); + + $propFind = new PropFind('/file.txt', [], 0, PropFind::ALLPROPS); + $this->server->emit('propFind', [$propFind, $this->davNode(File::class, 11)]); + + self::assertNull($propFind->get(self::HAS_PREVIEW)); + } + + public function testNothingWithoutContext(): void { + $this->context = null; + $this->registry->expects(self::never())->method('preload'); + $this->provider->expects(self::never())->method('getAttributes'); + + $propFind = new PropFind('/file.txt', [self::HAS_PREVIEW], 0); + $this->server->emit('propFind', [$propFind, $this->davNode(File::class, 11)]); + + self::assertNull($propFind->get(self::HAS_PREVIEW)); + } + + public function testListsAreSerializedAsChildElements(): void { + $davNode = $this->davNode(File::class, 11); + $this->provider->method('getAttributes')->willReturn([ + self::SHARE_TYPES => new NodeAttributeList('share-type', [0, 3]), + ]); + + $propFind = new PropFind('/file.txt', [self::SHARE_TYPES], 0); + $this->server->emit('propFind', [$propFind, $davNode]); + $value = $propFind->get(self::SHARE_TYPES); + self::assertInstanceOf(NodeAttributeListXml::class, $value); + + $document = new \DOMDocument(); + $document->loadXML((new Service())->write('{DAV:}prop', [self::SHARE_TYPES => $value])); + $children = $document->getElementsByTagNameNS('http://owncloud.org/ns', 'share-type'); + self::assertSame(['0', '3'], array_map(static fn (\DOMElement $element): string => $element->textContent, iterator_to_array($children))); + self::assertSame('share-types', $children->item(0)->parentNode->localName); + } +} diff --git a/apps/dav/tests/unit/Connector/Sabre/RequestTest/RequestTestCase.php b/apps/dav/tests/unit/Connector/Sabre/RequestTest/RequestTestCase.php index b4f08f77c2579..3831b5625dad7 100644 --- a/apps/dav/tests/unit/Connector/Sabre/RequestTest/RequestTestCase.php +++ b/apps/dav/tests/unit/Connector/Sabre/RequestTest/RequestTestCase.php @@ -16,7 +16,6 @@ use OCP\Files\Mount\IMountManager; use OCP\IConfig; use OCP\IDBConnection; -use OCP\IPreview; use OCP\IRequest; use OCP\IRequestId; use OCP\ITagManager; @@ -52,7 +51,6 @@ protected function setUp(): void { \OCP\Server::get(IMountManager::class), \OCP\Server::get(ITagManager::class), $this->createMock(IRequest::class), - \OCP\Server::get(IPreview::class), \OCP\Server::get(IEventDispatcher::class), \OCP\Server::get(IFactory::class)->get('dav'), ); diff --git a/apps/dav/tests/unit/Connector/Sabre/TagsPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/TagsPluginTest.php index e50cc9280845c..f405c360b5431 100644 --- a/apps/dav/tests/unit/Connector/Sabre/TagsPluginTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/TagsPluginTest.php @@ -172,18 +172,7 @@ public function testPreloadThenGetProperties(array $tags, array $requestedProper public static function tagsGetPropertiesDataProvider(): array { return [ - // request both, receive both - [ - ['tag1', 'tag2', self::TAG_FAVORITE], - [self::TAGS_PROPERTYNAME, self::FAVORITE_PROPERTYNAME], - [ - 200 => [ - self::TAGS_PROPERTYNAME => new TagList(['tag1', 'tag2']), - self::FAVORITE_PROPERTYNAME => true, - ] - ] - ], - // request tags alone + // favorites are not part of the tags [ ['tag1', 'tag2', self::TAG_FAVORITE], [self::TAGS_PROPERTYNAME], @@ -193,16 +182,6 @@ public static function tagsGetPropertiesDataProvider(): array { ] ] ], - // request fav alone - [ - ['tag1', 'tag2', self::TAG_FAVORITE], - [self::FAVORITE_PROPERTYNAME], - [ - 200 => [ - self::FAVORITE_PROPERTYNAME => true, - ] - ] - ], // request none [ ['tag1', 'tag2', self::TAG_FAVORITE], @@ -211,14 +190,13 @@ public static function tagsGetPropertiesDataProvider(): array { 200 => [] ], ], - // request both with none set, receive both + // request tags with none set [ [], - [self::TAGS_PROPERTYNAME, self::FAVORITE_PROPERTYNAME], + [self::TAGS_PROPERTYNAME], [ 200 => [ self::TAGS_PROPERTYNAME => new TagList([]), - self::FAVORITE_PROPERTYNAME => false, ] ] ], diff --git a/apps/dav/tests/unit/Files/NodeAttributes/FavoriteProviderTest.php b/apps/dav/tests/unit/Files/NodeAttributes/FavoriteProviderTest.php new file mode 100644 index 0000000000000..6ba109f6f9e7a --- /dev/null +++ b/apps/dav/tests/unit/Files/NodeAttributes/FavoriteProviderTest.php @@ -0,0 +1,75 @@ +tags = $this->createMock(ITags::class); + $this->tagManager = $this->createMock(ITagManager::class); + $this->tagManager->method('load')->with('files', [], false, 'alice')->willReturn($this->tags); + $this->provider = new FavoriteProvider($this->tagManager); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->context = NodeAttributeContext::forUser($user); + } + + private function node(int $id): Node&MockObject { + $node = $this->createMock(Node::class); + $node->method('getId')->willReturn($id); + return $node; + } + + public function testFavoritesOfAllNodesAreLoadedInOneQuery(): void { + $nodes = [$this->node(1), $this->node(2), $this->node(3)]; + $this->tags->expects(self::once()) + ->method('getTagsForObjects') + ->with([1, 2, 3]) + ->willReturn([ + 1 => ['work', TagsPlugin::TAG_FAVORITE], + 2 => ['work'], + ]); + + $this->provider->preload($this->context, $nodes, self::ATTRIBUTES); + + self::assertSame([TagsPlugin::FAVORITE_PROPERTYNAME => 1], $this->provider->getAttributes($this->context, $nodes[0], self::ATTRIBUTES)); + self::assertSame([TagsPlugin::FAVORITE_PROPERTYNAME => 0], $this->provider->getAttributes($this->context, $nodes[1], self::ATTRIBUTES)); + self::assertSame([TagsPlugin::FAVORITE_PROPERTYNAME => 0], $this->provider->getAttributes($this->context, $nodes[2], self::ATTRIBUTES)); + } + + public function testNoFavoritesOnPublicShares(): void { + $context = NodeAttributeContext::forShare($this->createMock(IShare::class)); + $this->tagManager->expects(self::never())->method('load'); + + $this->provider->preload($context, [$this->node(1)], self::ATTRIBUTES); + self::assertSame([], $this->provider->getAttributes($context, $this->node(1), self::ATTRIBUTES)); + } +} diff --git a/apps/dav/tests/unit/Files/NodeAttributes/HasPreviewProviderTest.php b/apps/dav/tests/unit/Files/NodeAttributes/HasPreviewProviderTest.php new file mode 100644 index 0000000000000..de3fa9b43c955 --- /dev/null +++ b/apps/dav/tests/unit/Files/NodeAttributes/HasPreviewProviderTest.php @@ -0,0 +1,40 @@ +createMock(IUser::class)) + : NodeAttributeContext::forShare($this->createMock(IShare::class)); + $image = $this->createMock(Node::class); + $text = $this->createMock(Node::class); + $previewManager = $this->createMock(IPreview::class); + $previewManager->method('isAvailable')->willReturnCallback(static fn ($node): bool => $node === $image); + $provider = new HasPreviewProvider($previewManager); + + self::assertSame([FilesPlugin::HAS_PREVIEW_PROPERTYNAME => true], $provider->getAttributes($context, $image, [FilesPlugin::HAS_PREVIEW_PROPERTYNAME])); + self::assertSame([FilesPlugin::HAS_PREVIEW_PROPERTYNAME => false], $provider->getAttributes($context, $text, [FilesPlugin::HAS_PREVIEW_PROPERTYNAME])); + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 3e97fa37854aa..941234b4a8b1b 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -17,6 +17,10 @@ 'NCU\\Config\\Lexicon\\Preset' => $baseDir . '/lib/unstable/Config/Lexicon/Preset.php', 'NCU\\Config\\ValueType' => $baseDir . '/lib/unstable/Config/ValueType.php', 'NCU\\Federation\\ISignedCloudFederationProvider' => $baseDir . '/lib/unstable/Federation/ISignedCloudFederationProvider.php', + 'NCU\\Files\\NodeAttributes\\INodeAttributeProvider' => $baseDir . '/lib/unstable/Files/NodeAttributes/INodeAttributeProvider.php', + 'NCU\\Files\\NodeAttributes\\INodeSerializer' => $baseDir . '/lib/unstable/Files/NodeAttributes/INodeSerializer.php', + 'NCU\\Files\\NodeAttributes\\NodeAttributeContext' => $baseDir . '/lib/unstable/Files/NodeAttributes/NodeAttributeContext.php', + 'NCU\\Files\\NodeAttributes\\NodeAttributeList' => $baseDir . '/lib/unstable/Files/NodeAttributes/NodeAttributeList.php', 'NCU\\Search\\AccountScopedSearchResult' => $baseDir . '/lib/unstable/Search/AccountScopedSearchResult.php', 'NCU\\Search\\Exceptions\\AccountUnavailableException' => $baseDir . '/lib/unstable/Search/Exceptions/AccountUnavailableException.php', 'NCU\\Search\\Exceptions\\SearchTruncatedException' => $baseDir . '/lib/unstable/Search/Exceptions/SearchTruncatedException.php', @@ -1213,6 +1217,7 @@ 'OC\\AppFramework\\Bootstrap\\EventListenerRegistration' => $baseDir . '/lib/private/AppFramework/Bootstrap/EventListenerRegistration.php', 'OC\\AppFramework\\Bootstrap\\FunctionInjector' => $baseDir . '/lib/private/AppFramework/Bootstrap/FunctionInjector.php', 'OC\\AppFramework\\Bootstrap\\MiddlewareRegistration' => $baseDir . '/lib/private/AppFramework/Bootstrap/MiddlewareRegistration.php', + 'OC\\AppFramework\\Bootstrap\\NodeAttributeProviderRegistration' => $baseDir . '/lib/private/AppFramework/Bootstrap/NodeAttributeProviderRegistration.php', 'OC\\AppFramework\\Bootstrap\\ParameterRegistration' => $baseDir . '/lib/private/AppFramework/Bootstrap/ParameterRegistration.php', 'OC\\AppFramework\\Bootstrap\\PreviewProviderRegistration' => $baseDir . '/lib/private/AppFramework/Bootstrap/PreviewProviderRegistration.php', 'OC\\AppFramework\\Bootstrap\\RegistrationContext' => $baseDir . '/lib/private/AppFramework/Bootstrap/RegistrationContext.php', @@ -1941,6 +1946,8 @@ 'OC\\Files\\Mount\\MountPoint' => $baseDir . '/lib/private/Files/Mount/MountPoint.php', 'OC\\Files\\Mount\\ObjectHomeMountProvider' => $baseDir . '/lib/private/Files/Mount/ObjectHomeMountProvider.php', 'OC\\Files\\Mount\\RootMountProvider' => $baseDir . '/lib/private/Files/Mount/RootMountProvider.php', + 'OC\\Files\\NodeAttributes\\NodeAttributeRegistry' => $baseDir . '/lib/private/Files/NodeAttributes/NodeAttributeRegistry.php', + 'OC\\Files\\NodeAttributes\\NodeSerializer' => $baseDir . '/lib/private/Files/NodeAttributes/NodeSerializer.php', 'OC\\Files\\Node\\File' => $baseDir . '/lib/private/Files/Node/File.php', 'OC\\Files\\Node\\Folder' => $baseDir . '/lib/private/Files/Node/Folder.php', 'OC\\Files\\Node\\HookConnector' => $baseDir . '/lib/private/Files/Node/HookConnector.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index d728162291c78..65ee51a5eec52 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -63,6 +63,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'NCU\\Config\\Lexicon\\Preset' => __DIR__ . '/../../..' . '/lib/unstable/Config/Lexicon/Preset.php', 'NCU\\Config\\ValueType' => __DIR__ . '/../../..' . '/lib/unstable/Config/ValueType.php', 'NCU\\Federation\\ISignedCloudFederationProvider' => __DIR__ . '/../../..' . '/lib/unstable/Federation/ISignedCloudFederationProvider.php', + 'NCU\\Files\\NodeAttributes\\INodeAttributeProvider' => __DIR__ . '/../../..' . '/lib/unstable/Files/NodeAttributes/INodeAttributeProvider.php', + 'NCU\\Files\\NodeAttributes\\INodeSerializer' => __DIR__ . '/../../..' . '/lib/unstable/Files/NodeAttributes/INodeSerializer.php', + 'NCU\\Files\\NodeAttributes\\NodeAttributeContext' => __DIR__ . '/../../..' . '/lib/unstable/Files/NodeAttributes/NodeAttributeContext.php', + 'NCU\\Files\\NodeAttributes\\NodeAttributeList' => __DIR__ . '/../../..' . '/lib/unstable/Files/NodeAttributes/NodeAttributeList.php', 'NCU\\Search\\AccountScopedSearchResult' => __DIR__ . '/../../..' . '/lib/unstable/Search/AccountScopedSearchResult.php', 'NCU\\Search\\Exceptions\\AccountUnavailableException' => __DIR__ . '/../../..' . '/lib/unstable/Search/Exceptions/AccountUnavailableException.php', 'NCU\\Search\\Exceptions\\SearchTruncatedException' => __DIR__ . '/../../..' . '/lib/unstable/Search/Exceptions/SearchTruncatedException.php', @@ -1259,6 +1263,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\AppFramework\\Bootstrap\\EventListenerRegistration' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Bootstrap/EventListenerRegistration.php', 'OC\\AppFramework\\Bootstrap\\FunctionInjector' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Bootstrap/FunctionInjector.php', 'OC\\AppFramework\\Bootstrap\\MiddlewareRegistration' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Bootstrap/MiddlewareRegistration.php', + 'OC\\AppFramework\\Bootstrap\\NodeAttributeProviderRegistration' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Bootstrap/NodeAttributeProviderRegistration.php', 'OC\\AppFramework\\Bootstrap\\ParameterRegistration' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Bootstrap/ParameterRegistration.php', 'OC\\AppFramework\\Bootstrap\\PreviewProviderRegistration' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Bootstrap/PreviewProviderRegistration.php', 'OC\\AppFramework\\Bootstrap\\RegistrationContext' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Bootstrap/RegistrationContext.php', @@ -1987,6 +1992,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Files\\Mount\\MountPoint' => __DIR__ . '/../../..' . '/lib/private/Files/Mount/MountPoint.php', 'OC\\Files\\Mount\\ObjectHomeMountProvider' => __DIR__ . '/../../..' . '/lib/private/Files/Mount/ObjectHomeMountProvider.php', 'OC\\Files\\Mount\\RootMountProvider' => __DIR__ . '/../../..' . '/lib/private/Files/Mount/RootMountProvider.php', + 'OC\\Files\\NodeAttributes\\NodeAttributeRegistry' => __DIR__ . '/../../..' . '/lib/private/Files/NodeAttributes/NodeAttributeRegistry.php', + 'OC\\Files\\NodeAttributes\\NodeSerializer' => __DIR__ . '/../../..' . '/lib/private/Files/NodeAttributes/NodeSerializer.php', 'OC\\Files\\Node\\File' => __DIR__ . '/../../..' . '/lib/private/Files/Node/File.php', 'OC\\Files\\Node\\Folder' => __DIR__ . '/../../..' . '/lib/private/Files/Node/Folder.php', 'OC\\Files\\Node\\HookConnector' => __DIR__ . '/../../..' . '/lib/private/Files/Node/HookConnector.php', diff --git a/lib/private/AppFramework/Bootstrap/NodeAttributeProviderRegistration.php b/lib/private/AppFramework/Bootstrap/NodeAttributeProviderRegistration.php new file mode 100644 index 0000000000000..166fa28e56f5b --- /dev/null +++ b/lib/private/AppFramework/Bootstrap/NodeAttributeProviderRegistration.php @@ -0,0 +1,36 @@ + + */ +class NodeAttributeProviderRegistration extends ServiceRegistration { + /** + * @param list $attributes in Clark notation + */ + public function __construct( + string $appId, + string $service, + private array $attributes, + ) { + parent::__construct($appId, $service); + } + + /** + * @return list + */ + public function getAttributes(): array { + return $this->attributes; + } +} diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index f86890441243b..0f584f1c1ca6d 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -154,6 +154,9 @@ class RegistrationContext { /** @var PreviewProviderRegistration[] */ private array $previewProviders = []; + /** @var NodeAttributeProviderRegistration[] */ + private array $nodeAttributeProviders = []; + /** @var ServiceRegistration[] */ private array $declarativeSettings = []; @@ -390,6 +393,15 @@ public function registerReferenceProvider(string $class): void { ); } + #[\Override] + public function registerNodeAttributeProvider(string $class, array $attributes): void { + $this->context->registerNodeAttributeProvider( + $this->appId, + $class, + $attributes + ); + } + #[\Override] public function registerProfileLinkAction(string $actionClass): void { $this->context->registerProfileLinkAction( @@ -636,6 +648,13 @@ public function registerReferenceProvider(string $appId, string $class): void { $this->referenceProviders[] = new ServiceRegistration($appId, $class); } + /** + * @param list $attributes + */ + public function registerNodeAttributeProvider(string $appId, string $class, array $attributes): void { + $this->nodeAttributeProviders[] = new NodeAttributeProviderRegistration($appId, $class, $attributes); + } + /** * @psalm-param class-string $actionClass */ @@ -1030,6 +1049,13 @@ public function getPreviewProviders(): array { return $this->previewProviders; } + /** + * @return NodeAttributeProviderRegistration[] + */ + public function getNodeAttributeProviders(): array { + return $this->nodeAttributeProviders; + } + /** * @return ServiceRegistration[] */ diff --git a/lib/private/Files/NodeAttributes/NodeAttributeRegistry.php b/lib/private/Files/NodeAttributes/NodeAttributeRegistry.php new file mode 100644 index 0000000000000..f368a7ca15ac1 --- /dev/null +++ b/lib/private/Files/NodeAttributes/NodeAttributeRegistry.php @@ -0,0 +1,175 @@ +|null registration by attribute in Clark notation */ + private ?array $registrations = null; + + /** @var array */ + private array $providers = []; + + /** @var array>> preloaded file ids by context and attribute */ + private array $preloaded = []; + + public function __construct( + private readonly Coordinator $coordinator, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + } + + /** + * @return list the registered attributes in Clark notation + */ + public function getAttributeNames(): array { + return array_keys($this->getRegistrations()); + } + + /** + * Group the requested attributes by the provider serving them, unknown attributes are skipped + * + * @param list $attributes in Clark notation + * @return list}> + */ + public function getProvidersFor(array $attributes): array { + $registrations = $this->getRegistrations(); + $groups = []; + foreach (array_unique($attributes) as $attribute) { + $registration = $registrations[$attribute] ?? null; + if ($registration === null) { + continue; + } + + $service = $registration->getService(); + $provider = $this->getProvider($service); + if ($provider === null) { + continue; + } + + $groups[$service] ??= ['provider' => $provider, 'attributes' => []]; + $groups[$service]['attributes'][] = $attribute; + } + return array_values($groups); + } + + /** + * Preload the requested attributes of these nodes, skipping the ones already preloaded for this context + * + * @param list $nodes + * @param list $attributes in Clark notation + */ + public function preload(NodeAttributeContext $context, array $nodes, array $attributes): void { + $contextKey = $this->getContextKey($context); + foreach ($this->getProvidersFor($attributes) as $group) { + $missing = array_values(array_filter($nodes, function (Node $node) use ($contextKey, $group): bool { + foreach ($group['attributes'] as $attribute) { + if (!isset($this->preloaded[$contextKey][$attribute][$node->getId()])) { + return true; + } + } + return false; + })); + if ($missing === []) { + continue; + } + + $group['provider']->preload($context, $missing, $group['attributes']); + foreach ($group['attributes'] as $attribute) { + foreach ($missing as $node) { + $this->preloaded[$contextKey][$attribute][$node->getId()] = true; + } + } + } + } + + /** + * Get the local name of an attribute in Clark notation, e.g. `has-preview` for `{http://nextcloud.org/ns}has-preview` + */ + public static function getLocalName(string $attribute): string { + $position = strrpos($attribute, '}'); + return $position === false ? $attribute : substr($attribute, $position + 1); + } + + /** + * @return array + */ + private function getRegistrations(): array { + if ($this->registrations !== null) { + return $this->registrations; + } + + $context = $this->coordinator->getRegistrationContext(); + if ($context === null) { + // Too early, apps are not registered yet: do not cache the empty result + return []; + } + + $this->registrations = []; + $localNames = []; + foreach ($context->getNodeAttributeProviders() as $registration) { + foreach ($registration->getAttributes() as $attribute) { + $localName = self::getLocalName($attribute); + // Attributes are keyed by local name in the serialized nodes, so they must be unique + if (isset($localNames[$localName])) { + $this->logger->error('Node attribute ' . $attribute . ' of ' . $registration->getService() . ' is ignored, ' . $localNames[$localName] . ' already uses the name ' . $localName, [ + 'app' => $registration->getAppId(), + ]); + continue; + } + + $localNames[$localName] = $attribute; + $this->registrations[$attribute] = $registration; + } + } + return $this->registrations; + } + + private function getContextKey(NodeAttributeContext $context): string { + $user = $context->getUser(); + if ($user !== null) { + return 'user:' . $user->getUID(); + } + return 'share:' . ($context->getShare()?->getToken() ?? ''); + } + + /** + * @param class-string $service + */ + private function getProvider(string $service): ?INodeAttributeProvider { + if (!array_key_exists($service, $this->providers)) { + try { + $provider = $this->container->get($service); + $this->providers[$service] = $provider instanceof INodeAttributeProvider ? $provider : null; + if ($this->providers[$service] === null) { + $this->logger->error($service . ' is registered as node attribute provider but does not implement ' . INodeAttributeProvider::class); + } + } catch (Throwable $e) { + $this->logger->error('Could not load node attribute provider ' . $service . ': ' . $e->getMessage(), [ + 'exception' => $e, + ]); + $this->providers[$service] = null; + } + } + return $this->providers[$service]; + } +} diff --git a/lib/private/Files/NodeAttributes/NodeSerializer.php b/lib/private/Files/NodeAttributes/NodeSerializer.php new file mode 100644 index 0000000000000..406f6ca39f7e8 --- /dev/null +++ b/lib/private/Files/NodeAttributes/NodeSerializer.php @@ -0,0 +1,131 @@ +registry->getAttributeNames(); + $this->registry->preload($context, $nodes, $attributes); + $groups = $this->registry->getProvidersFor($attributes); + + [$root, $rootPath, $remoteUrl] = $this->getRoot($context); + $result = []; + foreach ($nodes as $node) { + $path = $this->getRelativePath($rootPath, $node->getPath()); + if ($path === null) { + $this->logger->warning('Skipping ' . $node->getPath() . ', it is outside of ' . $rootPath); + continue; + } + + $data = $this->getNodeData($node, $root, $path, $remoteUrl, $context); + foreach ($groups as $group) { + $values = $group['provider']->getAttributes($context, $node, $group['attributes']); + foreach ($group['attributes'] as $attribute) { + if (array_key_exists($attribute, $values)) { + $value = $values[$attribute]; + $data['attributes'][NodeAttributeRegistry::getLocalName($attribute)] = $value instanceof NodeAttributeList ? $value->jsonSerialize() : $value; + } + } + } + $result[] = $data; + } + return $result; + } + + #[\Override] + public function getAttributeNames(): array { + return $this->registry->getAttributeNames(); + } + + /** + * The DAV root of the context, the absolute path it maps to and the DAV remote URL + * + * @return array{string, string, string} + */ + private function getRoot(NodeAttributeContext $context): array { + $user = $context->getUser(); + if ($user !== null) { + return [ + '/files/' . $user->getUID(), + $this->rootFolder->getUserFolder($user->getUID())->getPath(), + $this->urlGenerator->getAbsoluteURL('/remote.php/dav'), + ]; + } + + $share = $context->getShare(); + if ($share === null) { + throw new \InvalidArgumentException('The node attribute context has neither a user nor a share'); + } + return [ + '/files/' . $share->getToken(), + $share->getNode()->getPath(), + $this->urlGenerator->getAbsoluteURL('/public.php/dav'), + ]; + } + + /** + * Path of a node within the root, starting with a slash, null when outside of it + */ + private function getRelativePath(string $rootPath, string $path): ?string { + if ($path === $rootPath) { + return '/'; + } + if (!str_starts_with($path, rtrim($rootPath, '/') . '/')) { + return null; + } + return substr($path, strlen(rtrim($rootPath, '/'))); + } + + /** + * @return NodeData + */ + private function getNodeData(Node $node, string $root, string $path, string $remoteUrl, NodeAttributeContext $context): array { + $isFolder = $node->getType() === FileInfo::TYPE_FOLDER; + $creationTime = $node->getCreationTime(); + + return [ + 'id' => $node->getId(), + 'source' => $remoteUrl . $root . ($path === '/' ? '' : $path), + 'root' => $root, + 'owner' => $node->getOwner()?->getUID() ?? $context->getUser()?->getUID(), + 'mime' => $isFolder ? 'httpd/unix-directory' : $node->getMimetype(), + 'mtime' => $node->getMTime(), + 'crtime' => $creationTime > 0 ? $creationTime : null, + 'size' => $node->getSize(), + 'permissions' => $node->getPermissions(), + 'displayname' => $node->getName(), + 'type' => $isFolder ? 'folder' : 'file', + 'attributes' => [ + 'etag' => $node->getEtag(), + ], + ]; + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index 1ca6e3cf341c9..2d741f9242a07 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -1013,6 +1013,7 @@ public function __construct( $this->registerAlias(\OCP\Collaboration\Resources\IManager::class, \OC\Collaboration\Resources\Manager::class); $this->registerAlias(IReferenceManager::class, ReferenceManager::class); + $this->registerAlias(\NCU\Files\NodeAttributes\INodeSerializer::class, \OC\Files\NodeAttributes\NodeSerializer::class); $this->registerAlias(ITeamManager::class, TeamManager::class); $this->registerDeprecatedAlias('SettingsManager', \OC\Settings\Manager::class); diff --git a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php index 6609c4747051e..df664d5179a72 100644 --- a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php +++ b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php @@ -318,6 +318,18 @@ public function registerCalendarProvider(string $class): void; */ public function registerReferenceProvider(string $class): void; + /** + * Register a provider of file and folder attributes, served over WebDAV + * and by the node serializer alike. + * `\NCU\Files\NodeAttributes\INodeAttributeProvider` is currently experimental and may still change. + * + * @param string $class + * @psalm-param class-string<\NCU\Files\NodeAttributes\INodeAttributeProvider> $class + * @param list $attributes the attributes the provider serves, in Clark notation, e.g. `{http://nextcloud.org/ns}has-preview` + * @since 36.0.0 + */ + public function registerNodeAttributeProvider(string $class, array $attributes): void; + /** * Register an implementation of \OCP\Profile\ILinkAction that * will handle the implementation of a profile link action diff --git a/lib/unstable/Files/NodeAttributes/INodeAttributeProvider.php b/lib/unstable/Files/NodeAttributes/INodeAttributeProvider.php new file mode 100644 index 0000000000000..8e51f8e436d9e --- /dev/null +++ b/lib/unstable/Files/NodeAttributes/INodeAttributeProvider.php @@ -0,0 +1,47 @@ + $nodes + * @param list $attributes the requested attributes of this provider, in Clark notation + * @experimental 36.0.0 + */ + public function preload(NodeAttributeContext $context, array $nodes, array $attributes): void; + + /** + * Get the requested attributes of a node. + * Attributes that do not apply to this node or context are left out. + * + * @param list $attributes the requested attributes of this provider, in Clark notation + * @return array values by attribute in Clark notation + * @experimental 36.0.0 + */ + public function getAttributes(NodeAttributeContext $context, Node $node, array $attributes): array; +} diff --git a/lib/unstable/Files/NodeAttributes/INodeSerializer.php b/lib/unstable/Files/NodeAttributes/INodeSerializer.php new file mode 100644 index 0000000000000..fda3e966050e1 --- /dev/null +++ b/lib/unstable/Files/NodeAttributes/INodeSerializer.php @@ -0,0 +1,50 @@ +, + * } + * @experimental 36.0.0 + */ +interface INodeSerializer { + /** + * @param list $nodes nodes as seen by the context: from the user folder, or within the public share + * @param list|null $attributes attributes to include in Clark notation, null for all registered ones + * @return list + * @experimental 36.0.0 + */ + public function serialize(NodeAttributeContext $context, array $nodes, ?array $attributes = null): array; + + /** + * Get the attributes the registered providers offer, in Clark notation + * + * @return list + * @experimental 36.0.0 + */ + public function getAttributeNames(): array; +} diff --git a/lib/unstable/Files/NodeAttributes/NodeAttributeContext.php b/lib/unstable/Files/NodeAttributes/NodeAttributeContext.php new file mode 100644 index 0000000000000..8d742c0407624 --- /dev/null +++ b/lib/unstable/Files/NodeAttributes/NodeAttributeContext.php @@ -0,0 +1,59 @@ +user; + } + + /** + * The public share, null for logged in users + * + * @experimental 36.0.0 + */ + public function getShare(): ?IShare { + return $this->share; + } +} diff --git a/lib/unstable/Files/NodeAttributes/NodeAttributeList.php b/lib/unstable/Files/NodeAttributes/NodeAttributeList.php new file mode 100644 index 0000000000000..3f5679b11e0a8 --- /dev/null +++ b/lib/unstable/Files/NodeAttributes/NodeAttributeList.php @@ -0,0 +1,56 @@ +0`. + * As JSON it has the shape the WebDAV client produces: `{ "share-type": [0] }`. + * + * @experimental 36.0.0 + */ +final class NodeAttributeList implements \JsonSerializable { + /** + * @param string $childName local name of the child elements + * @param list $values + * @experimental 36.0.0 + */ + public function __construct( + private readonly string $childName, + private readonly array $values, + ) { + } + + /** + * @experimental 36.0.0 + */ + public function getChildName(): string { + return $this->childName; + } + + /** + * @return list + * @experimental 36.0.0 + */ + public function getValues(): array { + return $this->values; + } + + /** + * @return array> + * @experimental 36.0.0 + */ + #[\Override] + public function jsonSerialize(): array { + return [$this->childName => $this->values]; + } +} diff --git a/tests/lib/AppFramework/Bootstrap/RegistrationContextTest.php b/tests/lib/AppFramework/Bootstrap/RegistrationContextTest.php index a3f87e7078edc..72bfa092f9287 100644 --- a/tests/lib/AppFramework/Bootstrap/RegistrationContextTest.php +++ b/tests/lib/AppFramework/Bootstrap/RegistrationContextTest.php @@ -46,6 +46,18 @@ public function testRegisterCapability(): void { ]); } + public function testRegisterNodeAttributeProvider(): void { + $attributes = ['{http://nextcloud.org/ns}has-preview']; + + $this->context->for('myapp')->registerNodeAttributeProvider('ProviderClass', $attributes); + + $registrations = $this->context->getNodeAttributeProviders(); + self::assertCount(1, $registrations); + self::assertSame('myapp', $registrations[0]->getAppId()); + self::assertSame('ProviderClass', $registrations[0]->getService()); + self::assertSame($attributes, $registrations[0]->getAttributes()); + } + public function testRegisterEventListener(): void { $event = 'abc'; $service = 'def'; diff --git a/tests/lib/Files/NodeAttributes/NodeAttributeRegistryTest.php b/tests/lib/Files/NodeAttributes/NodeAttributeRegistryTest.php new file mode 100644 index 0000000000000..4937c5796f0fe --- /dev/null +++ b/tests/lib/Files/NodeAttributes/NodeAttributeRegistryTest.php @@ -0,0 +1,171 @@ +coordinator = $this->createMock(Coordinator::class); + $this->context = $this->createMock(RegistrationContext::class); + $this->container = $this->createMock(ContainerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->coordinator->method('getRegistrationContext')->willReturn($this->context); + + $this->registry = new NodeAttributeRegistry($this->coordinator, $this->container, $this->logger); + } + + /** + * @param array> $providers attributes by provider class + */ + private function register(array $providers): void { + $this->context->method('getNodeAttributeProviders')->willReturn(array_map( + static fn (string $class, array $attributes) => new NodeAttributeProviderRegistration('app', $class, $attributes), + array_keys($providers), + array_values($providers), + )); + } + + public function testGetAttributeNames(): void { + $this->register(['PreviewProvider' => [self::HAS_PREVIEW], 'TagsProvider' => [self::FAVORITE, self::TAGS]]); + + self::assertSame([self::HAS_PREVIEW, self::FAVORITE, self::TAGS], $this->registry->getAttributeNames()); + } + + public function testGetProvidersForGroupsAttributesByProvider(): void { + $this->register(['PreviewProvider' => [self::HAS_PREVIEW], 'TagsProvider' => [self::FAVORITE, self::TAGS]]); + $previewProvider = $this->createMock(INodeAttributeProvider::class); + $tagsProvider = $this->createMock(INodeAttributeProvider::class); + $this->container->expects(self::exactly(2)) + ->method('get') + ->willReturnMap([['PreviewProvider', $previewProvider], ['TagsProvider', $tagsProvider]]); + + $groups = $this->registry->getProvidersFor([self::FAVORITE, '{DAV:}unknown', self::HAS_PREVIEW, self::TAGS, self::FAVORITE]); + + self::assertSame([ + ['provider' => $tagsProvider, 'attributes' => [self::FAVORITE, self::TAGS]], + ['provider' => $previewProvider, 'attributes' => [self::HAS_PREVIEW]], + ], $groups); + + // Providers are only loaded once + $this->registry->getProvidersFor([self::HAS_PREVIEW, self::TAGS]); + } + + private function node(int $id): Node&MockObject { + $node = $this->createMock(Node::class); + $node->method('getId')->willReturn($id); + return $node; + } + + private function userContext(string $uid): NodeAttributeContext { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + return NodeAttributeContext::forUser($user); + } + + public function testPreloadOnlyPassesNodesNotPreloadedYet(): void { + $this->register(['TagsProvider' => [self::FAVORITE, self::TAGS]]); + $provider = $this->createMock(INodeAttributeProvider::class); + $this->container->method('get')->willReturn($provider); + [$node1, $node2, $node3] = [$this->node(1), $this->node(2), $this->node(3)]; + $context = $this->userContext('alice'); + + $calls = []; + $provider->method('preload')->willReturnCallback(static function (NodeAttributeContext $context, array $nodes, array $attributes) use (&$calls): void { + $calls[] = [array_map(static fn (Node $node): int => $node->getId(), $nodes), $attributes]; + }); + + $this->registry->preload($context, [$node1, $node2], [self::FAVORITE]); + $this->registry->preload($this->userContext('alice'), [$node1, $node2], [self::FAVORITE]); + $this->registry->preload($context, [$node2, $node3], [self::FAVORITE]); + // Another attribute of the same provider is not preloaded yet + $this->registry->preload($context, [$node1], [self::FAVORITE, self::TAGS]); + + self::assertSame([ + [[1, 2], [self::FAVORITE]], + [[3], [self::FAVORITE]], + [[1], [self::FAVORITE, self::TAGS]], + ], $calls); + } + + public function testPreloadIsTrackedPerContext(): void { + $this->register(['TagsProvider' => [self::FAVORITE]]); + $provider = $this->createMock(INodeAttributeProvider::class); + $this->container->method('get')->willReturn($provider); + $share = $this->createMock(IShare::class); + $share->method('getToken')->willReturn('token'); + $node = $this->node(1); + + $provider->expects(self::exactly(3))->method('preload'); + + $this->registry->preload($this->userContext('alice'), [$node], [self::FAVORITE]); + $this->registry->preload($this->userContext('bob'), [$node], [self::FAVORITE]); + $this->registry->preload(NodeAttributeContext::forShare($share), [$node], [self::FAVORITE]); + $this->registry->preload($this->userContext('alice'), [$node], [self::FAVORITE]); + } + + public function testAttributesMustHaveUniqueLocalNames(): void { + $this->register(['FirstProvider' => [self::FAVORITE], 'OtherProvider' => ['{http://example.com/ns}favorite']]); + $this->logger->expects(self::once())->method('error'); + + self::assertSame([self::FAVORITE], $this->registry->getAttributeNames()); + } + + public function testBrokenProvidersAreSkipped(): void { + $this->register(['BrokenProvider' => [self::HAS_PREVIEW], 'NotAProvider' => [self::FAVORITE]]); + $this->container->method('get')->willReturnCallback(static fn (string $class) => match ($class) { + 'BrokenProvider' => throw new \RuntimeException('broken'), + default => new \stdClass(), + }); + $this->logger->expects(self::exactly(2))->method('error'); + + self::assertSame([], $this->registry->getProvidersFor([self::HAS_PREVIEW, self::FAVORITE])); + } + + public function testNothingRegisteredBeforeAppsAreLoaded(): void { + $coordinator = $this->createMock(Coordinator::class); + $coordinator->method('getRegistrationContext')->willReturnOnConsecutiveCalls(null, $this->context); + $this->register(['PreviewProvider' => [self::HAS_PREVIEW]]); + $registry = new NodeAttributeRegistry($coordinator, $this->container, $this->logger); + + self::assertSame([], $registry->getAttributeNames()); + self::assertSame([self::HAS_PREVIEW], $registry->getAttributeNames()); + } + + public function testGetLocalName(): void { + self::assertSame('has-preview', NodeAttributeRegistry::getLocalName(self::HAS_PREVIEW)); + self::assertSame('plain', NodeAttributeRegistry::getLocalName('plain')); + } +} diff --git a/tests/lib/Files/NodeAttributes/NodeSerializerTest.php b/tests/lib/Files/NodeAttributes/NodeSerializerTest.php new file mode 100644 index 0000000000000..b4dad553db137 --- /dev/null +++ b/tests/lib/Files/NodeAttributes/NodeSerializerTest.php @@ -0,0 +1,175 @@ +registry = $this->createMock(NodeAttributeRegistry::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('getAbsoluteURL')->willReturnCallback(static fn (string $url): string => 'https://cloud.example.com' . $url); + + $this->user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn('alice'); + $userFolder = $this->createMock(IUserFolder::class); + $userFolder->method('getPath')->willReturn('/alice/files'); + $this->rootFolder->method('getUserFolder')->with('alice')->willReturn($userFolder); + + $this->serializer = new NodeSerializer($this->registry, $this->rootFolder, $urlGenerator, $this->createMock(LoggerInterface::class)); + } + + private function node(string $class, int $id, string $path, string $mime = 'text/plain'): Node&MockObject { + $node = $this->createMock($class); + $node->method('getId')->willReturn($id); + $node->method('getPath')->willReturn($path); + $node->method('getName')->willReturn(basename($path)); + $node->method('getType')->willReturn($class === Folder::class ? FileInfo::TYPE_FOLDER : FileInfo::TYPE_FILE); + $node->method('getMimetype')->willReturn($mime); + $node->method('getMTime')->willReturn(1760000000); + $node->method('getCreationTime')->willReturn(0); + $node->method('getSize')->willReturn(42); + $node->method('getPermissions')->willReturn(31); + $node->method('getEtag')->willReturn('etag-' . $id); + $node->method('getOwner')->willReturn($this->user); + return $node; + } + + public function testSerializeForUser(): void { + $file = $this->node(File::class, 11, '/alice/files/Photos/cat.jpg', 'image/jpeg'); + $folder = $this->node(Folder::class, 12, '/alice/files/Photos', 'httpd/unix-directory'); + $provider = $this->createMock(INodeAttributeProvider::class); + $this->registry->method('getAttributeNames')->willReturn([self::HAS_PREVIEW, self::SHARE_TYPES]); + $this->registry->method('getProvidersFor') + ->with([self::HAS_PREVIEW, self::SHARE_TYPES]) + ->willReturn([['provider' => $provider, 'attributes' => [self::HAS_PREVIEW, self::SHARE_TYPES]]]); + + // Everything is preloaded at once, before reading any node + $this->registry->expects(self::once()) + ->method('preload') + ->with(self::isInstanceOf(NodeAttributeContext::class), [$file, $folder], [self::HAS_PREVIEW, self::SHARE_TYPES]); + $provider->expects(self::never())->method('preload'); + $provider->method('getAttributes')->willReturnCallback(static fn (NodeAttributeContext $context, Node $node): array => [ + self::HAS_PREVIEW => $node === $file, + self::SHARE_TYPES => new NodeAttributeList('share-type', [0, 3]), + '{DAV:}not-requested' => 'ignored', + ]); + + $result = $this->serializer->serialize(NodeAttributeContext::forUser($this->user), [$file, $folder]); + + self::assertSame([ + [ + 'id' => 11, + 'source' => 'https://cloud.example.com/remote.php/dav/files/alice/Photos/cat.jpg', + 'root' => '/files/alice', + 'owner' => 'alice', + 'mime' => 'image/jpeg', + 'mtime' => 1760000000, + 'crtime' => null, + 'size' => 42, + 'permissions' => 31, + 'displayname' => 'cat.jpg', + 'type' => 'file', + 'attributes' => [ + 'etag' => 'etag-11', + 'has-preview' => true, + 'share-types' => ['share-type' => [0, 3]], + ], + ], + [ + 'id' => 12, + 'source' => 'https://cloud.example.com/remote.php/dav/files/alice/Photos', + 'root' => '/files/alice', + 'owner' => 'alice', + 'mime' => 'httpd/unix-directory', + 'mtime' => 1760000000, + 'crtime' => null, + 'size' => 42, + 'permissions' => 31, + 'displayname' => 'Photos', + 'type' => 'folder', + 'attributes' => [ + 'etag' => 'etag-12', + 'has-preview' => false, + 'share-types' => ['share-type' => [0, 3]], + ], + ], + ], $result); + } + + public function testSerializeForShare(): void { + $sharedFolder = $this->node(Folder::class, 20, '/bob/files/Shared'); + $file = $this->node(File::class, 21, '/bob/files/Shared/sub/doc.md'); + $share = $this->createMock(IShare::class); + $share->method('getToken')->willReturn('AbCdEf'); + $share->method('getNode')->willReturn($sharedFolder); + $this->registry->method('getAttributeNames')->willReturn([]); + $this->registry->method('getProvidersFor')->willReturn([]); + + $result = $this->serializer->serialize(NodeAttributeContext::forShare($share), [$sharedFolder, $file]); + + self::assertSame('/files/AbCdEf', $result[0]['root']); + self::assertSame('https://cloud.example.com/public.php/dav/files/AbCdEf', $result[0]['source']); + self::assertSame('https://cloud.example.com/public.php/dav/files/AbCdEf/sub/doc.md', $result[1]['source']); + } + + public function testOnlyRequestedAttributes(): void { + $file = $this->node(File::class, 11, '/alice/files/cat.jpg'); + $this->registry->expects(self::never())->method('getAttributeNames'); + $this->registry->expects(self::once()) + ->method('getProvidersFor') + ->with([self::HAS_PREVIEW]) + ->willReturn([]); + + $result = $this->serializer->serialize(NodeAttributeContext::forUser($this->user), [$file], [self::HAS_PREVIEW]); + + self::assertSame(['etag' => 'etag-11'], $result[0]['attributes']); + } + + public function testNodesOutsideOfTheRootAreSkipped(): void { + $inside = $this->node(File::class, 11, '/alice/files/cat.jpg'); + $outside = $this->node(File::class, 12, '/bob/files/cat.jpg'); + $sibling = $this->node(File::class, 13, '/alice/files_trashbin/files/cat.jpg'); + $this->registry->method('getAttributeNames')->willReturn([]); + $this->registry->method('getProvidersFor')->willReturn([]); + + $result = $this->serializer->serialize(NodeAttributeContext::forUser($this->user), [$inside, $outside, $sibling]); + + self::assertSame([11], array_column($result, 'id')); + } +}