From 9ab57b938677f7db8d5cb2c6dc7ad943018b89aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:43:22 +0200 Subject: [PATCH 1/3] feat(files): allow records in node attribute lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list value can hold records, written over WebDAV as one element per field, like the sharees of a node. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- .../Connector/Sabre/NodeAttributeListXml.php | 18 ++++++++++++++++-- .../Files/NodeAttributes/NodeAttributeList.php | 7 ++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/dav/lib/Connector/Sabre/NodeAttributeListXml.php b/apps/dav/lib/Connector/Sabre/NodeAttributeListXml.php index 60470338256c4..336cfe5b5157f 100644 --- a/apps/dav/lib/Connector/Sabre/NodeAttributeListXml.php +++ b/apps/dav/lib/Connector/Sabre/NodeAttributeListXml.php @@ -18,7 +18,7 @@ class NodeAttributeListXml implements XmlSerializable { /** * @param string $childElement child element name in Clark notation - * @param list $values + * @param list> $values */ public function __construct( private readonly string $childElement, @@ -28,8 +28,22 @@ public function __construct( #[\Override] public function xmlSerialize(Writer $writer): void { + $namespace = substr($this->childElement, 0, (int)strrpos($this->childElement, '}') + 1); foreach ($this->values as $value) { - $writer->writeElement($this->childElement, is_bool($value) ? ($value ? 'true' : 'false') : (string)$value); + if (!is_array($value)) { + $writer->writeElement($this->childElement, self::toText($value)); + continue; + } + + $writer->startElement($this->childElement); + foreach ($value as $field => $fieldValue) { + $writer->writeElement($namespace . $field, $fieldValue === null ? null : self::toText($fieldValue)); + } + $writer->endElement(); } } + + private static function toText(bool|int|float|string $value): string { + return is_bool($value) ? ($value ? 'true' : 'false') : (string)$value; + } } diff --git a/lib/unstable/Files/NodeAttributes/NodeAttributeList.php b/lib/unstable/Files/NodeAttributes/NodeAttributeList.php index 3f5679b11e0a8..7941aeac0a2a9 100644 --- a/lib/unstable/Files/NodeAttributes/NodeAttributeList.php +++ b/lib/unstable/Files/NodeAttributes/NodeAttributeList.php @@ -14,6 +14,7 @@ * * Over WebDAV each value is a child element named `$childName` in the namespace * of the attribute, e.g. `0`. + * A value can also be a record, written as one element per field. * As JSON it has the shape the WebDAV client produces: `{ "share-type": [0] }`. * * @experimental 36.0.0 @@ -21,7 +22,7 @@ final class NodeAttributeList implements \JsonSerializable { /** * @param string $childName local name of the child elements - * @param list $values + * @param list> $values * @experimental 36.0.0 */ public function __construct( @@ -38,7 +39,7 @@ public function getChildName(): string { } /** - * @return list + * @return list> * @experimental 36.0.0 */ public function getValues(): array { @@ -46,7 +47,7 @@ public function getValues(): array { } /** - * @return array> + * @return array>> * @experimental 36.0.0 */ #[\Override] From cc5da87f09461dbf6731d88b327adaef7db42143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:43:23 +0200 Subject: [PATCH 2/3] refactor(dav): serve share types, sharees and share attributes through node attribute providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SharesPlugin keeps the move and copy checks. The share-types and sharees properties move to SharesProvider with the same share manager calls: a node gets the shares the user created and received, the children of a listed folder get the shares in that folder. share-attributes moves from FilesPlugin to ShareAttributesProvider. A test checks that WebDAV and the serializer give the same values. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- .../composer/composer/autoload_classmap.php | 4 +- .../dav/composer/composer/autoload_static.php | 4 +- apps/dav/lib/AppInfo/Application.php | 5 + apps/dav/lib/Connector/Sabre/FilesPlugin.php | 4 - apps/dav/lib/Connector/Sabre/Node.php | 21 -- .../Connector/Sabre/NodeAttributesPlugin.php | 18 +- apps/dav/lib/Connector/Sabre/ShareeList.php | 44 ---- apps/dav/lib/Connector/Sabre/SharesPlugin.php | 113 +-------- .../NodeAttributes/IFolderListingPreload.php | 28 +++ .../ShareAttributesProvider.php | 54 ++++ .../Files/NodeAttributes/SharesProvider.php | 123 ++++++++++ .../Sabre/NodeAttributesPluginTest.php | 21 ++ .../tests/unit/Connector/Sabre/NodeTest.php | 68 ------ .../unit/Connector/Sabre/SharesPluginTest.php | 230 ------------------ .../ShareAttributesProviderTest.php | 99 ++++++++ .../Files/NodeAttributes/SharesParityTest.php | 189 ++++++++++++++ .../NodeAttributes/SharesProviderTest.php | 212 ++++++++++++++++ build/psalm-baseline.xml | 5 - 18 files changed, 752 insertions(+), 490 deletions(-) delete mode 100644 apps/dav/lib/Connector/Sabre/ShareeList.php create mode 100644 apps/dav/lib/Files/NodeAttributes/IFolderListingPreload.php create mode 100644 apps/dav/lib/Files/NodeAttributes/ShareAttributesProvider.php create mode 100644 apps/dav/lib/Files/NodeAttributes/SharesProvider.php create mode 100644 apps/dav/tests/unit/Files/NodeAttributes/ShareAttributesProviderTest.php create mode 100644 apps/dav/tests/unit/Files/NodeAttributes/SharesParityTest.php create mode 100644 apps/dav/tests/unit/Files/NodeAttributes/SharesProviderTest.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 3cbe10a457239..76bdf796b5522 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -270,7 +270,6 @@ 'OCA\\DAV\\Connector\\Sabre\\Server' => $baseDir . '/../lib/Connector/Sabre/Server.php', 'OCA\\DAV\\Connector\\Sabre\\ServerFactory' => $baseDir . '/../lib/Connector/Sabre/ServerFactory.php', 'OCA\\DAV\\Connector\\Sabre\\ShareTypeList' => $baseDir . '/../lib/Connector/Sabre/ShareTypeList.php', - 'OCA\\DAV\\Connector\\Sabre\\ShareeList' => $baseDir . '/../lib/Connector/Sabre/ShareeList.php', 'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => $baseDir . '/../lib/Connector/Sabre/SharesPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\StreamedPropFindNotFoundPlugin' => $baseDir . '/../lib/Connector/Sabre/StreamedPropFindNotFoundPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\TagList' => $baseDir . '/../lib/Connector/Sabre/TagList.php', @@ -347,6 +346,9 @@ '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\\NodeAttributes\\IFolderListingPreload' => $baseDir . '/../lib/Files/NodeAttributes/IFolderListingPreload.php', + 'OCA\\DAV\\Files\\NodeAttributes\\ShareAttributesProvider' => $baseDir . '/../lib/Files/NodeAttributes/ShareAttributesProvider.php', + 'OCA\\DAV\\Files\\NodeAttributes\\SharesProvider' => $baseDir . '/../lib/Files/NodeAttributes/SharesProvider.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 8652c44217600..2763cf5e58cf6 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -285,7 +285,6 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Connector\\Sabre\\Server' => __DIR__ . '/..' . '/../lib/Connector/Sabre/Server.php', 'OCA\\DAV\\Connector\\Sabre\\ServerFactory' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ServerFactory.php', 'OCA\\DAV\\Connector\\Sabre\\ShareTypeList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ShareTypeList.php', - 'OCA\\DAV\\Connector\\Sabre\\ShareeList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ShareeList.php', 'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/SharesPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\StreamedPropFindNotFoundPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/StreamedPropFindNotFoundPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\TagList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagList.php', @@ -362,6 +361,9 @@ class ComposerStaticInitDAV '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\\NodeAttributes\\IFolderListingPreload' => __DIR__ . '/..' . '/../lib/Files/NodeAttributes/IFolderListingPreload.php', + 'OCA\\DAV\\Files\\NodeAttributes\\ShareAttributesProvider' => __DIR__ . '/..' . '/../lib/Files/NodeAttributes/ShareAttributesProvider.php', + 'OCA\\DAV\\Files\\NodeAttributes\\SharesProvider' => __DIR__ . '/..' . '/../lib/Files/NodeAttributes/SharesProvider.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 e01e3f86ce6b6..53c88d745fec7 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -30,6 +30,7 @@ use OCA\DAV\CardDAV\SyncService; use OCA\DAV\ConfigLexicon; use OCA\DAV\Connector\Sabre\FilesPlugin; +use OCA\DAV\Connector\Sabre\SharesPlugin; use OCA\DAV\Connector\Sabre\TagsPlugin; use OCA\DAV\Events\AddressBookCreatedEvent; use OCA\DAV\Events\AddressBookDeletedEvent; @@ -51,6 +52,8 @@ use OCA\DAV\Events\SubscriptionDeletedEvent; use OCA\DAV\Files\NodeAttributes\FavoriteProvider; use OCA\DAV\Files\NodeAttributes\HasPreviewProvider; +use OCA\DAV\Files\NodeAttributes\ShareAttributesProvider; +use OCA\DAV\Files\NodeAttributes\SharesProvider; use OCA\DAV\Listener\ActivityUpdaterListener; use OCA\DAV\Listener\AddMissingIndicesListener; use OCA\DAV\Listener\AddressbookListener; @@ -134,6 +137,8 @@ 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->registerNodeAttributeProvider(SharesProvider::class, [SharesPlugin::SHARETYPES_PROPERTYNAME, SharesPlugin::SHAREES_PROPERTYNAME]); + $context->registerNodeAttributeProvider(ShareAttributesProvider::class, [FilesPlugin::SHARE_ATTRIBUTES_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 717bd89f96936..ac1b55bb77610 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -346,10 +346,6 @@ public function handleGetProperties(PropFind $propFind, \Sabre\DAV\INode $node) return json_encode($ocmPermissions, JSON_THROW_ON_ERROR); }); - $propFind->handle(self::SHARE_ATTRIBUTES_PROPERTYNAME, function () use ($node, $httpRequest) { - return json_encode($node->getShareAttributes(), JSON_THROW_ON_ERROR); - }); - $propFind->handle(self::GETETAG_PROPERTYNAME, function () use ($node): string { return $node->getETag(); }); diff --git a/apps/dav/lib/Connector/Sabre/Node.php b/apps/dav/lib/Connector/Sabre/Node.php index 5bbe1990117a8..d6a9b706e3ece 100644 --- a/apps/dav/lib/Connector/Sabre/Node.php +++ b/apps/dav/lib/Connector/Sabre/Node.php @@ -29,7 +29,6 @@ use OCP\PreConditionNotMetException; use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; -use OCP\Share\IAttributes; use OCP\Share\IManager; use RuntimeException; use Sabre\DAV\Exception; @@ -289,26 +288,6 @@ public function getSharePermissions(?string $user): int { return $permissions; } - public function getShareAttributes(): array { - try { - $storage = $this->node->getStorage(); - } catch (NotFoundException) { - return []; - } - - $attributes = []; - if ($storage->instanceOfStorage(ISharedStorage::class)) { - $attributes = $storage->getShare()->getAttributes(); - if (!$attributes instanceof IAttributes) { - return []; - } - - return $attributes->toArray(); - } - - return $attributes; - } - public function getNoteFromShare(?string $user): ?string { try { $storage = $this->node->getStorage(); diff --git a/apps/dav/lib/Connector/Sabre/NodeAttributesPlugin.php b/apps/dav/lib/Connector/Sabre/NodeAttributesPlugin.php index b04e72c105065..6ee1feaf96046 100644 --- a/apps/dav/lib/Connector/Sabre/NodeAttributesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/NodeAttributesPlugin.php @@ -13,6 +13,8 @@ use NCU\Files\NodeAttributes\NodeAttributeContext; use NCU\Files\NodeAttributes\NodeAttributeList; use OC\Files\NodeAttributes\NodeAttributeRegistry; +use OCA\DAV\Files\NodeAttributes\IFolderListingPreload; +use OCP\Files\Folder; use Sabre\DAV\ICollection; use Sabre\DAV\INode; use Sabre\DAV\PropFind; @@ -52,13 +54,14 @@ private function preloadCollection(PropFind $propFind, ICollection $collection): return; } - $nodes = [$collection->getNode()]; + $folder = $collection->getNode(); + $children = []; foreach ($collection->getChildren() as $child) { if ($child instanceof Node) { - $nodes[] = $child->getNode(); + $children[] = $child->getNode(); } } - $this->preload($nodes, $attributes); + $this->preload([$folder, ...$children], $attributes, $folder); } /** @@ -122,15 +125,20 @@ private function getRequestedAttributes(PropFind $propFind): array { /** * @param list<\OCP\Files\Node> $nodes * @param list $attributes + * @param ?Folder $listing the folder when the nodes are the folder followed by its children */ - private function preload(array $nodes, array $attributes): void { + private function preload(array $nodes, array $attributes, ?Folder $listing = null): void { $context = ($this->contextResolver)(); if ($context === null || $nodes === []) { return; } foreach ($this->registry->getProvidersFor($attributes) as $group) { - $group['provider']->preload($context, $nodes, $group['attributes']); + if ($listing !== null && $group['provider'] instanceof IFolderListingPreload) { + $group['provider']->preloadListing($context, $listing, array_slice($nodes, 1), $group['attributes']); + } else { + $group['provider']->preload($context, $nodes, $group['attributes']); + } } foreach ($nodes as $node) { $this->preloaded[$node->getId()] = true; diff --git a/apps/dav/lib/Connector/Sabre/ShareeList.php b/apps/dav/lib/Connector/Sabre/ShareeList.php deleted file mode 100644 index cf224f99e605e..0000000000000 --- a/apps/dav/lib/Connector/Sabre/ShareeList.php +++ /dev/null @@ -1,44 +0,0 @@ -shares as $share) { - $writer->startElement('{' . self::NS_NEXTCLOUD . '}sharee'); - $writer->writeElement('{' . self::NS_NEXTCLOUD . '}id', $share->getSharedWith()); - $writer->writeElement('{' . self::NS_NEXTCLOUD . '}display-name', $share->getSharedWithDisplayName()); - $writer->writeElement('{' . self::NS_NEXTCLOUD . '}type', $share->getShareType()); - $writer->endElement(); - } - } -} diff --git a/apps/dav/lib/Connector/Sabre/SharesPlugin.php b/apps/dav/lib/Connector/Sabre/SharesPlugin.php index 54385ed97cedf..19ed812683d69 100644 --- a/apps/dav/lib/Connector/Sabre/SharesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/SharesPlugin.php @@ -11,23 +11,20 @@ use OC\Share20\Exception\BackendError; use OCA\DAV\Connector\Sabre\Exception\Forbidden; use OCA\DAV\Connector\Sabre\Node as DavNode; -use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\Mount\IShareOwnerlessMount; use OCP\Files\Node; -use OCP\Files\NotFoundException; use OCP\Files\Storage\ISharedStorage; use OCP\IUserSession; use OCP\Share\IManager; use OCP\Share\IShare; use Sabre\DAV\Exception\NotFound; -use Sabre\DAV\ICollection; -use Sabre\DAV\PropFind; use Sabre\DAV\Server; use Sabre\DAV\Tree; /** - * Sabre Plugin to provide share-related properties + * Sabre Plugin to keep moves and copies from adding nodes to shares the user cannot reshare. + * The share-types and sharees properties are node attributes, served by SharesProvider */ class SharesPlugin extends \Sabre\DAV\ServerPlugin { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; @@ -38,18 +35,6 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { private \Sabre\DAV\Server $server; private string $userId; - /** @var IShare[][] */ - private array $cachedShares = []; - - /** - * 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( private Tree $tree, IUserSession $userSession, @@ -77,8 +62,6 @@ public function initialize(Server $server) { $server->protectedProperties[] = self::SHAREES_PROPERTYNAME; $this->server = $server; - $this->server->on('preloadCollection', $this->preloadCollection(...)); - $this->server->on('propFind', $this->handleGetProperties(...)); $this->server->on('beforeCopy', $this->validateMoveOrCopy(...)); $this->server->on('beforeMove', $this->validateMoveOrCopy(...)); } @@ -149,98 +132,6 @@ private function getSharesForTarget(Node $node): array { return array_merge($shares, ...$outgoingAncestorShares); } - /** - * @param Folder $node - * @return IShare[][] - */ - private function getSharesFolder(Folder $node): array { - return $this->shareManager->getSharesInFolder( - $this->userId, - $node, - true - ); - } - - /** - * @param DavNode $sabreNode - * @return IShare[] - */ - private function getShares(DavNode $sabreNode): array { - if (isset($this->cachedShares[$sabreNode->getId()])) { - return $this->cachedShares[$sabreNode->getId()]; - } - - [$parentPath,] = \Sabre\Uri\split($sabreNode->getPath()); - if ($parentPath === '') { - $parentPath = '/'; - } - - // if we already cached the folder containing this file - // then we already know there are no shares here. - if (!isset($this->cachedFolders[$parentPath])) { - try { - $node = $sabreNode->getNode(); - } catch (NotFoundException $e) { - return []; - } - - $shares = $this->getShare($node); - $this->cachedShares[$sabreNode->getId()] = $shares; - return $shares; - } - - 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 - * - * @param PropFind $propFind propfind object - * @param \Sabre\DAV\INode $sabreNode sabre node - */ - public function handleGetProperties( - PropFind $propFind, - \Sabre\DAV\INode $sabreNode, - ) { - if (!($sabreNode instanceof DavNode)) { - return; - } - - $propFind->handle(self::SHARETYPES_PROPERTYNAME, function () use ($sabreNode): ShareTypeList { - $shares = $this->getShares($sabreNode); - $shareTypes = array_unique(array_map(static fn (IShare $share): int => $share->getShareType(), $shares)); - - return new ShareTypeList($shareTypes); - }); - - $propFind->handle(self::SHAREES_PROPERTYNAME, function () use ($sabreNode): ShareeList { - $shares = $this->getShares($sabreNode); - - return new ShareeList($shares); - }); - } - /** * Ensure that when copying or moving a node it is not transferred from one share to another, * if the user is neither the owner nor has re-share permissions. diff --git a/apps/dav/lib/Files/NodeAttributes/IFolderListingPreload.php b/apps/dav/lib/Files/NodeAttributes/IFolderListingPreload.php new file mode 100644 index 0000000000000..6eebf4db4f45a --- /dev/null +++ b/apps/dav/lib/Files/NodeAttributes/IFolderListingPreload.php @@ -0,0 +1,28 @@ + $children + * @param list $attributes + */ + public function preloadListing(NodeAttributeContext $context, Folder $folder, array $children, array $attributes): void; +} diff --git a/apps/dav/lib/Files/NodeAttributes/ShareAttributesProvider.php b/apps/dav/lib/Files/NodeAttributes/ShareAttributesProvider.php new file mode 100644 index 0000000000000..25d4a04325566 --- /dev/null +++ b/apps/dav/lib/Files/NodeAttributes/ShareAttributesProvider.php @@ -0,0 +1,54 @@ + json_encode($this->getShareAttributes($node), JSON_THROW_ON_ERROR), + ]; + } + + private function getShareAttributes(Node $node): array { + try { + $storage = $node->getStorage(); + } catch (NotFoundException) { + return []; + } + + if (!$storage->instanceOfStorage(ISharedStorage::class)) { + return []; + } + + /** @var ISharedStorage $storage */ + $shareAttributes = $storage->getShare()->getAttributes(); + return $shareAttributes instanceof IAttributes ? $shareAttributes->toArray() : []; + } +} diff --git a/apps/dav/lib/Files/NodeAttributes/SharesProvider.php b/apps/dav/lib/Files/NodeAttributes/SharesProvider.php new file mode 100644 index 0000000000000..112dc8b19b31c --- /dev/null +++ b/apps/dav/lib/Files/NodeAttributes/SharesProvider.php @@ -0,0 +1,123 @@ +>> shares by user id and file id */ + private array $shares = []; + + public function __construct( + private readonly IManager $shareManager, + ) { + } + + #[\Override] + public function preload(NodeAttributeContext $context, array $nodes, array $attributes): void { + $userId = $context->getUser()?->getUID(); + if ($userId === null) { + return; + } + + foreach ($nodes as $node) { + if (!isset($this->shares[$userId][$node->getId()])) { + $this->shares[$userId][$node->getId()] = $this->getShares($userId, $node); + } + } + } + + /** + * The children of a listed folder only get the shares in that folder created by the user + * or reshared from the user, with one call for the whole folder + */ + #[\Override] + public function preloadListing(NodeAttributeContext $context, Folder $folder, array $children, array $attributes): void { + $userId = $context->getUser()?->getUID(); + if ($userId === null) { + return; + } + + $this->preload($context, [$folder], $attributes); + $folderShares = $this->shareManager->getSharesInFolder($userId, $folder, true); + foreach ($children as $child) { + $this->shares[$userId][$child->getId()] ??= array_values($folderShares[$child->getId()] ?? []); + } + } + + #[\Override] + public function getAttributes(NodeAttributeContext $context, Node $node, array $attributes): array { + $userId = $context->getUser()?->getUID(); + if ($userId === null) { + return []; + } + + $this->preload($context, [$node], $attributes); + $shares = $this->shares[$userId][$node->getId()]; + + return [ + SharesPlugin::SHARETYPES_PROPERTYNAME => new NodeAttributeList( + 'share-type', + array_values(array_unique(array_map(static fn (IShare $share): int => $share->getShareType(), $shares))), + ), + SharesPlugin::SHAREES_PROPERTYNAME => new NodeAttributeList( + 'sharee', + array_map(static fn (IShare $share): array => [ + 'id' => $share->getSharedWith(), + 'display-name' => $share->getSharedWithDisplayName(), + 'type' => $share->getShareType(), + ], $shares), + ), + ]; + } + + /** + * Shares the user created on the node, and the ones the user received for it + * + * @return list + */ + private function getShares(string $userId, Node $node): array { + $shares = []; + foreach (self::SHARE_TYPES as $shareType) { + $shares[] = $this->shareManager->getSharesBy($userId, $shareType, $node, false, -1); + try { + $shares[] = $this->shareManager->getSharedWith($userId, $shareType, $node, -1); + } catch (BackendError) { + // a share provider that is not available has no shares to show + } + } + return array_values(array_merge(...$shares)); + } +} diff --git a/apps/dav/tests/unit/Connector/Sabre/NodeAttributesPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/NodeAttributesPluginTest.php index cfa3c4524b005..6205337437922 100644 --- a/apps/dav/tests/unit/Connector/Sabre/NodeAttributesPluginTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/NodeAttributesPluginTest.php @@ -17,6 +17,7 @@ use OCA\DAV\Connector\Sabre\File; use OCA\DAV\Connector\Sabre\NodeAttributeListXml; use OCA\DAV\Connector\Sabre\NodeAttributesPlugin; +use OCA\DAV\Files\NodeAttributes\IFolderListingPreload; use OCP\Files\Folder; use OCP\IUser; use PHPUnit\Framework\MockObject\MockObject; @@ -103,6 +104,26 @@ public function testCollectionIsPreloadedOnce(): void { } } + public function testListingAwareProvidersGetTheListing(): void { + $provider = $this->createMock(IFolderListingPreload::class); + $this->registry = $this->createMock(NodeAttributeRegistry::class); + $this->registry->method('getAttributeNames')->willReturn([self::SHARE_TYPES]); + $this->registry->method('getProvidersFor')->willReturn([['provider' => $provider, 'attributes' => [self::SHARE_TYPES]]]); + $server = new Server(); + $server->addPlugin(new NodeAttributesPlugin($this->registry, fn (): ?NodeAttributeContext => $this->context)); + + $child = $this->davNode(File::class, 51); + $directory = $this->davNode(Directory::class, 50); + $directory->method('getChildren')->willReturn([$child]); + + $provider->expects(self::once()) + ->method('preloadListing') + ->with($this->context, $directory->getNode(), [$child->getNode()], [self::SHARE_TYPES]); + $provider->expects(self::never())->method('preload'); + + $server->emit('preloadCollection', [new PropFind('/dir', [self::SHARE_TYPES], 1), $directory]); + } + public function testSearchResultsArePreloaded(): void { $result1 = $this->davNode(File::class, 31); $result2 = $this->davNode(File::class, 32); diff --git a/apps/dav/tests/unit/Connector/Sabre/NodeTest.php b/apps/dav/tests/unit/Connector/Sabre/NodeTest.php index 7bbb4b2d7c551..c05817d6d6309 100644 --- a/apps/dav/tests/unit/Connector/Sabre/NodeTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/NodeTest.php @@ -11,12 +11,9 @@ use OC\Files\FileInfo; use OC\Files\Mount\MountPoint; -use OC\Files\Node\Folder; use OC\Files\View; -use OC\Share20\ShareAttributes; use OCA\DAV\Connector\Sabre\File; use OCA\Files_Sharing\SharedMount; -use OCA\Files_Sharing\SharedStorage; use OCP\Constants; use OCP\Files\Cache\ICacheEntry; use OCP\Files\Mount\IMountPoint; @@ -24,7 +21,6 @@ use OCP\ICache; use OCP\Share\IManager; use OCP\Share\IShare; -use PHPUnit\Framework\MockObject\MockObject; /** * Class NodeTest @@ -187,70 +183,6 @@ public function testSharePermissions(string $type, ?string $user, int $permissio $this->assertEquals($expected, $node->getSharePermissions($user)); } - public function testShareAttributes(): void { - $storage = $this->getMockBuilder(SharedStorage::class) - ->disableOriginalConstructor() - ->onlyMethods(['getShare']) - ->getMock(); - - $shareManager = $this->createMock(IManager::class); - $share = $this->createMock(IShare::class); - - $storage->expects($this->once()) - ->method('getShare') - ->willReturn($share); - - $attributes = new ShareAttributes(); - $attributes->setAttribute('permissions', 'download', false); - - $share->expects($this->once())->method('getAttributes')->willReturn($attributes); - - /** @var Folder&MockObject $info */ - $info = $this->getMockBuilder(Folder::class) - ->disableOriginalConstructor() - ->onlyMethods(['getStorage', 'getType']) - ->getMock(); - - $info->method('getStorage')->willReturn($storage); - $info->method('getType')->willReturn(FileInfo::TYPE_FOLDER); - - /** @var View&MockObject $view */ - $view = $this->createMock(View::class); - $view - ->method('getRelativePath') - ->with(null) - ->willReturn(''); - - $node = new File($view, $info); - $this->invokePrivate($node, 'shareManager', [$shareManager]); - $this->assertEquals($attributes->toArray(), $node->getShareAttributes()); - } - - public function testShareAttributesNonShare(): void { - $storage = $this->createMock(IStorage::class); - $shareManager = $this->createMock(IManager::class); - - /** @var Folder&MockObject */ - $info = $this->getMockBuilder(Folder::class) - ->disableOriginalConstructor() - ->onlyMethods(['getStorage', 'getType']) - ->getMock(); - - $info->method('getStorage')->willReturn($storage); - $info->method('getType')->willReturn(FileInfo::TYPE_FOLDER); - - /** @var View&MockObject */ - $view = $this->createMock(View::class); - $view - ->method('getRelativePath') - ->with(null) - ->willReturn(''); - - $node = new File($view, $info); - $this->invokePrivate($node, 'shareManager', [$shareManager]); - $this->assertEquals([], $node->getShareAttributes()); - } - public static function sanitizeMtimeProvider(): array { return [ [123456789, 123456789], diff --git a/apps/dav/tests/unit/Connector/Sabre/SharesPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/SharesPluginTest.php index 3028de572244d..145d1c8d60144 100644 --- a/apps/dav/tests/unit/Connector/Sabre/SharesPluginTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/SharesPluginTest.php @@ -9,12 +9,9 @@ namespace OCA\DAV\Tests\unit\Connector\Sabre; -use OCA\DAV\Connector\Sabre\Directory; use OCA\DAV\Connector\Sabre\Exception\Forbidden; -use OCA\DAV\Connector\Sabre\File; use OCA\DAV\Connector\Sabre\Node; use OCA\DAV\Connector\Sabre\SharesPlugin; -use OCA\DAV\Upload\UploadFile; use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\IUserFolder; @@ -33,8 +30,6 @@ use Sabre\DAV\Tree; class SharesPluginTest extends \Test\TestCase { - public const SHARETYPES_PROPERTYNAME = SharesPlugin::SHARETYPES_PROPERTYNAME; - private const SOURCE = 'files/user1/somedir/source.txt'; private const TARGET = 'files/user1/othersdir/source.txt'; private const TARGET_PARENT = 'files/user1/othersdir'; @@ -74,231 +69,6 @@ protected function setUp(): void { $this->plugin->initialize($this->server); } - #[DataProvider(methodName: 'sharesGetPropertiesDataProvider')] - public function testGetProperties(array $shareTypes): void { - $sabreNode = $this->createMock(Node::class); - $sabreNode->expects($this->any()) - ->method('getId') - ->willReturn(123); - $sabreNode->expects($this->any()) - ->method('getPath') - ->willReturn('/subdir'); - - // node API nodes - $node = $this->createMock(Folder::class); - - $sabreNode->method('getNode') - ->willReturn($node); - - $this->shareManager->expects($this->any()) - ->method('getSharesBy') - ->with( - $this->equalTo('user1'), - $this->anything(), - $this->equalTo($node), - $this->equalTo(false), - $this->equalTo(-1) - ) - ->willReturnCallback(function ($userId, $requestedShareType, $node, $flag, $limit) use ($shareTypes) { - if (in_array($requestedShareType, $shareTypes)) { - $share = $this->createMock(IShare::class); - $share->method('getShareType') - ->willReturn($requestedShareType); - return [$share]; - } - return []; - }); - - $this->shareManager->expects($this->any()) - ->method('getSharedWith') - ->with( - $this->equalTo('user1'), - $this->anything(), - $this->equalTo($node), - $this->equalTo(-1) - ) - ->willReturn([]); - - $propFind = new \Sabre\DAV\PropFind( - '/dummyPath', - [self::SHARETYPES_PROPERTYNAME], - 0 - ); - - $this->plugin->handleGetProperties( - $propFind, - $sabreNode - ); - - $result = $propFind->getResultForMultiStatus(); - - $this->assertEmpty($result[404]); - unset($result[404]); - $this->assertEquals($shareTypes, $result[200][self::SHARETYPES_PROPERTYNAME]->getShareTypes()); - } - - #[DataProvider(methodName: 'sharesGetPropertiesDataProvider')] - public function testPreloadThenGetProperties(array $shareTypes): void { - $sabreNode1 = $this->createMock(File::class); - $sabreNode1->method('getId') - ->willReturn(111); - $sabreNode2 = $this->createMock(File::class); - $sabreNode2->method('getId') - ->willReturn(222); - $sabreNode2->method('getPath') - ->willReturn('/subdir/foo'); - - $sabreNode = $this->createMock(Directory::class); - $sabreNode->method('getId') - ->willReturn(123); - // never, because we use getDirectoryListing from the Node API instead - $sabreNode->expects($this->never()) - ->method('getChildren'); - $sabreNode->expects($this->any()) - ->method('getPath') - ->willReturn('/subdir'); - - // node API nodes - $node = $this->createMock(Folder::class); - $node->method('getId') - ->willReturn(123); - $node1 = $this->createMock(\OC\Files\Node\File::class); - $node1->method('getId') - ->willReturn(111); - $node2 = $this->createMock(\OC\Files\Node\File::class); - $node2->method('getId') - ->willReturn(222); - - $sabreNode->method('getNode') - ->willReturn($node); - $sabreNode1->method('getNode') - ->willReturn($node1); - $sabreNode2->method('getNode') - ->willReturn($node2); - - $dummyShares = array_map(function ($type) { - $share = $this->createMock(IShare::class); - $share->expects($this->any()) - ->method('getShareType') - ->willReturn($type); - return $share; - }, $shareTypes); - - $this->shareManager->expects($this->any()) - ->method('getSharesBy') - ->with( - $this->equalTo('user1'), - $this->anything(), - $this->anything(), - $this->equalTo(false), - $this->equalTo(-1) - ) - ->willReturnCallback(function ($userId, $requestedShareType, $node, $flag, $limit) use ($shareTypes, $dummyShares) { - if ($node->getId() === 111 && in_array($requestedShareType, $shareTypes)) { - foreach ($dummyShares as $dummyShare) { - if ($dummyShare->getShareType() === $requestedShareType) { - return [$dummyShare]; - } - } - } - - return []; - }); - - $this->shareManager->expects($this->any()) - ->method('getSharedWith') - ->with( - $this->equalTo('user1'), - $this->anything(), - $this->equalTo($node), - $this->equalTo(-1) - ) - ->willReturn([]); - - $this->shareManager->expects($this->any()) - ->method('getSharesInFolder') - ->with( - $this->equalTo('user1'), - $this->anything(), - $this->equalTo(true) - ) - ->willReturnCallback(function ($userId, $node, $flag) use ($shareTypes, $dummyShares) { - return [111 => $dummyShares]; - }); - - // simulate sabre recursive PROPFIND traversal - $propFindRoot = new \Sabre\DAV\PropFind( - '/subdir', - [self::SHARETYPES_PROPERTYNAME], - 1 - ); - $propFind1 = new \Sabre\DAV\PropFind( - '/subdir/test.txt', - [self::SHARETYPES_PROPERTYNAME], - 0 - ); - $propFind2 = new \Sabre\DAV\PropFind( - '/subdir/test2.txt', - [self::SHARETYPES_PROPERTYNAME], - 0 - ); - - $this->server->emit('preloadCollection', [$propFindRoot, $sabreNode]); - $this->plugin->handleGetProperties( - $propFindRoot, - $sabreNode - ); - $this->plugin->handleGetProperties( - $propFind1, - $sabreNode1 - ); - $this->plugin->handleGetProperties( - $propFind2, - $sabreNode2 - ); - - $result = $propFind1->getResultForMultiStatus(); - - $this->assertEmpty($result[404]); - unset($result[404]); - $this->assertEquals($shareTypes, $result[200][self::SHARETYPES_PROPERTYNAME]->getShareTypes()); - } - - public static function sharesGetPropertiesDataProvider(): array { - return [ - [[]], - [[IShare::TYPE_USER]], - [[IShare::TYPE_GROUP]], - [[IShare::TYPE_LINK]], - [[IShare::TYPE_REMOTE]], - [[IShare::TYPE_ROOM]], - [[IShare::TYPE_DECK]], - [[IShare::TYPE_USER, IShare::TYPE_GROUP]], - [[IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_LINK]], - [[IShare::TYPE_USER, IShare::TYPE_LINK]], - [[IShare::TYPE_GROUP, IShare::TYPE_LINK]], - [[IShare::TYPE_USER, IShare::TYPE_REMOTE]], - ]; - } - - public function testGetPropertiesSkipChunks(): void { - $sabreNode = $this->createMock(UploadFile::class); - - $propFind = new \Sabre\DAV\PropFind( - '/dummyPath', - [self::SHARETYPES_PROPERTYNAME], - 0 - ); - - $this->plugin->handleGetProperties( - $propFind, - $sabreNode - ); - - $result = $propFind->getResultForMultiStatus(); - $this->assertCount(1, $result[404]); - } - private function mockShare(string $id): IShare&MockObject { $share = $this->createMock(IShare::class); $share->method('getId') diff --git a/apps/dav/tests/unit/Files/NodeAttributes/ShareAttributesProviderTest.php b/apps/dav/tests/unit/Files/NodeAttributes/ShareAttributesProviderTest.php new file mode 100644 index 0000000000000..bdfb74032e8cc --- /dev/null +++ b/apps/dav/tests/unit/Files/NodeAttributes/ShareAttributesProviderTest.php @@ -0,0 +1,99 @@ +provider = new ShareAttributesProvider(); + $this->context = NodeAttributeContext::forUser($this->createMock(IUser::class)); + } + + private function sharedNode(?ShareAttributes $attributes): File { + $share = $this->createMock(IShare::class); + $share->method('getAttributes')->willReturn($attributes); + $storage = $this->createMock(SharedStorage::class); + $storage->method('instanceOfStorage')->willReturn(true); + $storage->method('getShare')->willReturn($share); + + $node = $this->createMock(File::class); + $node->method('getStorage')->willReturn($storage); + return $node; + } + + public function testAttributesOfTheReceivedShare(): void { + $attributes = new ShareAttributes(); + $attributes->setAttribute('permissions', 'download', false); + + self::assertSame( + [FilesPlugin::SHARE_ATTRIBUTES_PROPERTYNAME => '[{"scope":"permissions","key":"download","value":false}]'], + $this->provider->getAttributes($this->context, $this->sharedNode($attributes), self::ATTRIBUTES), + ); + } + + public function testReceivedShareWithoutAttributes(): void { + self::assertSame( + [FilesPlugin::SHARE_ATTRIBUTES_PROPERTYNAME => '[]'], + $this->provider->getAttributes($this->context, $this->sharedNode(null), self::ATTRIBUTES), + ); + } + + public function testNodeThatIsNotShared(): void { + $storage = $this->createMock(IStorage::class); + $storage->method('instanceOfStorage')->willReturn(false); + $node = $this->createMock(File::class); + $node->method('getStorage')->willReturn($storage); + + self::assertSame( + [FilesPlugin::SHARE_ATTRIBUTES_PROPERTYNAME => '[]'], + $this->provider->getAttributes($this->context, $node, self::ATTRIBUTES), + ); + } + + public function testNodeWithoutStorage(): void { + $node = $this->createMock(File::class); + $node->method('getStorage')->willThrowException(new NotFoundException()); + + self::assertSame( + [FilesPlugin::SHARE_ATTRIBUTES_PROPERTYNAME => '[]'], + $this->provider->getAttributes($this->context, $node, self::ATTRIBUTES), + ); + } + + public function testPublicShareVisitorsGetTheSameValue(): void { + $context = NodeAttributeContext::forShare($this->createMock(IShare::class)); + $attributes = new ShareAttributes(); + $attributes->setAttribute('permissions', 'download', true); + + self::assertSame( + [FilesPlugin::SHARE_ATTRIBUTES_PROPERTYNAME => '[{"scope":"permissions","key":"download","value":true}]'], + $this->provider->getAttributes($context, $this->sharedNode($attributes), self::ATTRIBUTES), + ); + } +} diff --git a/apps/dav/tests/unit/Files/NodeAttributes/SharesParityTest.php b/apps/dav/tests/unit/Files/NodeAttributes/SharesParityTest.php new file mode 100644 index 0000000000000..3bfc18ca46446 --- /dev/null +++ b/apps/dav/tests/unit/Files/NodeAttributes/SharesParityTest.php @@ -0,0 +1,189 @@ + [[], [], null], + 'outgoing user and link shares' => [[[IShare::TYPE_USER, 'bob', 'Bob'], [IShare::TYPE_LINK, null, null]], [], null], + 'received through a group' => [[], [[IShare::TYPE_GROUP, 'staff', 'Staff & Co <3>']], ['download' => false]], + 'received and reshared' => [[[IShare::TYPE_EMAIL, 'a@example.com', 'a@example.com']], [[IShare::TYPE_USER, 'alice', 'Alice']], ['download' => true]], + ]; + } + + /** + * @param list $outgoing + * @param list $incoming + * @param ?array{download: bool} $permissions attributes of the share the file was received through + */ + #[DataProvider('dataShares')] + public function testDavAndSerializerAgree(array $outgoing, array $incoming, ?array $permissions): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $context = NodeAttributeContext::forUser($user); + $node = $this->node($permissions); + + $registry = $this->createMock(NodeAttributeRegistry::class); + $registry->method('getAttributeNames')->willReturn(self::ATTRIBUTES); + $registry->method('getProvidersFor')->willReturn([ + ['provider' => new SharesProvider($this->shareManager($outgoing, $incoming)), 'attributes' => [SharesPlugin::SHARETYPES_PROPERTYNAME, SharesPlugin::SHAREES_PROPERTYNAME]], + ['provider' => new ShareAttributesProvider(), 'attributes' => [FilesPlugin::SHARE_ATTRIBUTES_PROPERTYNAME]], + ]); + + self::assertSame($this->serialize($registry, $context, $node), $this->propFind($registry, $context, $node)); + } + + /** + * @param list $outgoing + * @param list $incoming + */ + private function shareManager(array $outgoing, array $incoming): IManager { + $toShares = fn (array $shares, int $type): array => array_values(array_map(function (array $data): IShare { + $share = $this->createMock(IShare::class); + $share->method('getShareType')->willReturn($data[0]); + $share->method('getSharedWith')->willReturn($data[1]); + $share->method('getSharedWithDisplayName')->willReturn($data[2]); + return $share; + }, array_filter($shares, static fn (array $data): bool => $data[0] === $type))); + + $shareManager = $this->createMock(IManager::class); + $shareManager->method('getSharesBy')->willReturnCallback(static fn (string $userId, int $type): array => $toShares($outgoing, $type)); + $shareManager->method('getSharedWith')->willReturnCallback(static fn (string $userId, int $type): array => $toShares($incoming, $type)); + return $shareManager; + } + + private function node(?array $permissions): File { + $share = $this->createMock(IShare::class); + $attributes = null; + if ($permissions !== null) { + $attributes = new ShareAttributes(); + $attributes->setAttribute('permissions', 'download', $permissions['download']); + } + $share->method('getAttributes')->willReturn($attributes); + $storage = $this->createMock(SharedStorage::class); + $storage->method('instanceOfStorage')->willReturn($permissions !== null); + $storage->method('getShare')->willReturn($share); + + $node = $this->createMock(File::class); + $node->method('getId')->willReturn(42); + $node->method('getPath')->willReturn('/alice/files/report.md'); + $node->method('getName')->willReturn('report.md'); + $node->method('getType')->willReturn(FileInfo::TYPE_FILE); + $node->method('getStorage')->willReturn($storage); + return $node; + } + + /** + * The attributes of the PROPFIND response, read back as the WebDAV client does: a list per child element + * + * @return array + */ + private function propFind(NodeAttributeRegistry $registry, NodeAttributeContext $context, File $node): array { + $server = new Server(); + $server->addPlugin(new NodeAttributesPlugin($registry, static fn (): NodeAttributeContext => $context)); + $davNode = $this->createMock(DavFile::class); + $davNode->method('getNode')->willReturn($node); + + $propFind = new PropFind('/report.md', self::ATTRIBUTES, 0); + $server->emit('propFind', [$propFind, $davNode]); + + $document = new \DOMDocument(); + $document->loadXML((new Service())->write('{DAV:}prop', $propFind->getResultForMultiStatus()[200])); + $result = []; + foreach ($document->documentElement->childNodes as $property) { + if ($property instanceof \DOMElement) { + $result[$property->localName] = $property->localName === 'share-attributes' ? $property->textContent : $this->readList($property); + } + } + ksort($result); + return $result; + } + + /** + * @return array + */ + private function serialize(NodeAttributeRegistry $registry, NodeAttributeContext $context, File $node): array { + $userFolder = $this->createMock(IUserFolder::class); + $userFolder->method('getPath')->willReturn('/alice/files'); + $rootFolder = $this->createMock(IRootFolder::class); + $rootFolder->method('getUserFolder')->willReturn($userFolder); + $serializer = new NodeSerializer($registry, $rootFolder, $this->createMock(IURLGenerator::class), $this->createMock(LoggerInterface::class)); + + $attributes = $serializer->serialize($context, [$node], self::ATTRIBUTES)[0]['attributes']; + unset($attributes['etag']); + // An empty list has no child element to name + $attributes = array_map(static fn (mixed $value): mixed => is_array($value) && array_values($value) === [[]] ? [] : $value, $attributes); + ksort($attributes); + // XML only carries text + array_walk_recursive($attributes, static function (mixed &$value): void { + $value = is_bool($value) ? ($value ? 'true' : 'false') : (string)$value; + }); + return $attributes; + } + + /** + * A list attribute: one entry per child element, its text or the text of its fields + * + * @return array>> + */ + private function readList(\DOMElement $element): array { + $values = []; + foreach ($element->childNodes as $child) { + if (!($child instanceof \DOMElement)) { + continue; + } + $fields = []; + foreach ($child->childNodes as $field) { + if ($field instanceof \DOMElement) { + $fields[$field->localName] = $field->textContent; + } + } + $values[$child->localName][] = $fields === [] ? $child->textContent : $fields; + } + return $values; + } +} diff --git a/apps/dav/tests/unit/Files/NodeAttributes/SharesProviderTest.php b/apps/dav/tests/unit/Files/NodeAttributes/SharesProviderTest.php new file mode 100644 index 0000000000000..4532da43904c0 --- /dev/null +++ b/apps/dav/tests/unit/Files/NodeAttributes/SharesProviderTest.php @@ -0,0 +1,212 @@ +shareManager = $this->createMock(IManager::class); + $this->provider = new SharesProvider($this->shareManager); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->context = NodeAttributeContext::forUser($user); + } + + /** + * @param class-string $class + */ + private function node(string $class, int $id, string $path): File|Folder { + $node = $this->createMock($class); + $node->method('getId')->willReturn($id); + $node->method('getPath')->willReturn($path); + return $node; + } + + private function share(int $type, ?string $with = null, ?string $displayName = null): IShare&MockObject { + $share = $this->createMock(IShare::class); + $share->method('getShareType')->willReturn($type); + $share->method('getSharedWith')->willReturn($with); + $share->method('getSharedWithDisplayName')->willReturn($displayName); + return $share; + } + + /** + * @return array{list, list>} + */ + private function getValues(Node $node): array { + $values = $this->provider->getAttributes($this->context, $node, self::ATTRIBUTES); + $shareTypes = $values[SharesPlugin::SHARETYPES_PROPERTYNAME]; + $sharees = $values[SharesPlugin::SHAREES_PROPERTYNAME]; + self::assertInstanceOf(NodeAttributeList::class, $shareTypes); + self::assertInstanceOf(NodeAttributeList::class, $sharees); + self::assertSame('share-type', $shareTypes->getChildName()); + self::assertSame('sharee', $sharees->getChildName()); + return [$shareTypes->getValues(), $sharees->getValues()]; + } + + public static function dataShareTypes(): array { + return [ + [[]], + [[IShare::TYPE_USER]], + [[IShare::TYPE_GROUP]], + [[IShare::TYPE_LINK]], + [[IShare::TYPE_REMOTE]], + [[IShare::TYPE_ROOM]], + [[IShare::TYPE_DECK]], + [[IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_LINK]], + [[IShare::TYPE_USER, IShare::TYPE_REMOTE]], + ]; + } + + /** + * @param list $shareTypes + */ + #[DataProvider('dataShareTypes')] + public function testOutgoingSharesOfANode(array $shareTypes): void { + $node = $this->node(File::class, 11, '/alice/files/report.md'); + $this->shareManager->method('getSharesBy') + ->willReturnCallback(fn (string $userId, int $type, Node $sharedNode, bool $reshares, int $limit): array => $userId === 'alice' && $sharedNode === $node && !$reshares && $limit === -1 && in_array($type, $shareTypes, true) + ? [$this->share($type)] + : []); + $this->shareManager->method('getSharedWith')->willReturn([]); + + [$types] = $this->getValues($node); + self::assertSame($shareTypes, $types); + } + + public function testReceivedSharesAreIncludedForANode(): void { + $node = $this->node(Folder::class, 12, '/alice/files/Shared'); + $this->shareManager->method('getSharesBy') + ->willReturnCallback(fn (string $userId, int $type): array => $type === IShare::TYPE_LINK ? [$this->share(IShare::TYPE_LINK)] : []); + $this->shareManager->method('getSharedWith') + ->willReturnCallback(fn (string $userId, int $type, ?Node $sharedNode, int $limit): array => $userId === 'alice' && $sharedNode === $node && $limit === -1 && $type === IShare::TYPE_GROUP + ? [$this->share(IShare::TYPE_GROUP, 'staff', 'Staff')] + : []); + + [$types, $sharees] = $this->getValues($node); + self::assertSame([IShare::TYPE_GROUP, IShare::TYPE_LINK], $types); + self::assertSame([ + ['id' => 'staff', 'display-name' => 'Staff', 'type' => IShare::TYPE_GROUP], + ['id' => null, 'display-name' => null, 'type' => IShare::TYPE_LINK], + ], $sharees); + } + + public function testUnavailableShareProviderIsSkipped(): void { + $node = $this->node(File::class, 11, '/alice/files/report.md'); + $this->shareManager->method('getSharesBy') + ->willReturnCallback(fn (string $userId, int $type): array => $type === IShare::TYPE_USER ? [$this->share(IShare::TYPE_USER, 'bob', 'Bob')] : []); + $this->shareManager->method('getSharedWith') + ->willReturnCallback(static fn (string $userId, int $type): array => $type === IShare::TYPE_ROOM ? throw new BackendError() : []); + + [$types] = $this->getValues($node); + self::assertSame([IShare::TYPE_USER], $types); + } + + public function testShareTypesAreUnique(): void { + $node = $this->node(File::class, 11, '/alice/files/report.md'); + $this->shareManager->method('getSharesBy') + ->willReturnCallback(fn (string $userId, int $type): array => $type === IShare::TYPE_USER + ? [$this->share(IShare::TYPE_USER, 'bob', 'Bob'), $this->share(IShare::TYPE_USER, 'carol', 'Carol')] + : []); + $this->shareManager->method('getSharedWith')->willReturn([]); + + [$types, $sharees] = $this->getValues($node); + self::assertSame([IShare::TYPE_USER], $types); + self::assertSame(['bob', 'carol'], array_column($sharees, 'id')); + } + + public function testFolderListingLoadsTheChildrenAtOnce(): void { + $folder = $this->node(Folder::class, 20, '/alice/files/Projects'); + $shared = $this->node(File::class, 21, '/alice/files/Projects/plan.md'); + $notShared = $this->node(File::class, 22, '/alice/files/Projects/notes.md'); + + $this->shareManager->expects(self::once()) + ->method('getSharesInFolder') + ->with('alice', $folder, true) + ->willReturn([21 => [$this->share(IShare::TYPE_LINK)]]); + // The folder itself is looked up on its own, its children are not + $this->shareManager->expects(self::exactly(8)) + ->method('getSharesBy') + ->with('alice', self::anything(), $folder, false, -1) + ->willReturn([]); + $this->shareManager->expects(self::exactly(8)) + ->method('getSharedWith') + ->with('alice', self::anything(), $folder, -1) + ->willReturn([]); + + $this->provider->preloadListing($this->context, $folder, [$shared, $notShared], self::ATTRIBUTES); + + self::assertSame([], $this->getValues($folder)[0]); + self::assertSame([IShare::TYPE_LINK], $this->getValues($shared)[0]); + self::assertSame([], $this->getValues($notShared)[0]); + } + + public function testAFolderAndItsChildOutsideOfAListingGetTheirOwnShares(): void { + // Search results can hold a folder and its children, they are not a listing + $folder = $this->node(Folder::class, 40, '/alice/files/X'); + $received = $this->node(Folder::class, 41, '/alice/files/X/Y'); + + $this->shareManager->expects(self::never())->method('getSharesInFolder'); + $this->shareManager->method('getSharesBy')->willReturn([]); + $this->shareManager->method('getSharedWith') + ->willReturnCallback(fn (string $userId, int $type, Node $node): array => $node === $received && $type === IShare::TYPE_USER + ? [$this->share(IShare::TYPE_USER, 'alice', 'Alice')] + : []); + + $this->provider->preload($this->context, [$folder, $received], self::ATTRIBUTES); + + self::assertSame([], $this->getValues($folder)[0]); + self::assertSame([IShare::TYPE_USER], $this->getValues($received)[0]); + } + + public function testPreloadingTwiceOnlyLoadsNewNodes(): void { + $this->shareManager->expects(self::exactly(16)) + ->method('getSharesBy') + ->willReturn([]); + $this->shareManager->method('getSharedWith')->willReturn([]); + + $this->provider->preload($this->context, [$this->node(File::class, 1, '/alice/files/a')], self::ATTRIBUTES); + $this->provider->preload($this->context, [$this->node(File::class, 1, '/alice/files/a')], self::ATTRIBUTES); + $this->provider->preload($this->context, [$this->node(File::class, 2, '/alice/files/b')], self::ATTRIBUTES); + } + + public function testNothingOnPublicShares(): void { + $context = NodeAttributeContext::forShare($this->createMock(IShare::class)); + $node = $this->node(File::class, 11, '/bob/files/Shared/report.md'); + $this->shareManager->expects(self::never())->method('getSharesBy'); + + $this->provider->preload($context, [$node], self::ATTRIBUTES); + self::assertSame([], $this->provider->getAttributes($context, $node, self::ATTRIBUTES)); + } +} diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index 6f604a808c664..42eda9190cc0c 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -817,11 +817,6 @@ - - - getShareType()]]> - - From 36ce3a2d199dcf4c0da1c7a16cfe51de9af4ce1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:01:20 +0200 Subject: [PATCH 3/3] feat(files): get files by id through the nodes API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /ocs/v2.php/apps/files/api/v1/nodes?ids[]= returns up to 100 files by id with the attributes of the registered providers, the same data a PROPFIND gives. dir keeps only the paths within a folder of the user, a file reachable at several paths is returned once per path. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + .../lib/Controller/NodesApiController.php | 79 ++++++ apps/files/lib/ResponseDefinitions.php | 15 ++ apps/files/openapi.json | 233 ++++++++++++++++++ .../Controller/NodesApiControllerTest.php | 115 +++++++++ openapi.json | 233 ++++++++++++++++++ 7 files changed, 677 insertions(+) create mode 100644 apps/files/lib/Controller/NodesApiController.php create mode 100644 apps/files/tests/Controller/NodesApiControllerTest.php diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index a005578aa2428..e0fe618bc449c 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -60,6 +60,7 @@ 'OCA\\Files\\Controller\\DirectEditingController' => $baseDir . '/../lib/Controller/DirectEditingController.php', 'OCA\\Files\\Controller\\DirectEditingViewController' => $baseDir . '/../lib/Controller/DirectEditingViewController.php', 'OCA\\Files\\Controller\\FilenamesController' => $baseDir . '/../lib/Controller/FilenamesController.php', + 'OCA\\Files\\Controller\\NodesApiController' => $baseDir . '/../lib/Controller/NodesApiController.php', 'OCA\\Files\\Controller\\OpenLocalEditorController' => $baseDir . '/../lib/Controller/OpenLocalEditorController.php', 'OCA\\Files\\Controller\\TemplateController' => $baseDir . '/../lib/Controller/TemplateController.php', 'OCA\\Files\\Controller\\TransferOwnershipController' => $baseDir . '/../lib/Controller/TransferOwnershipController.php', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 6f0f0d4cc602f..86fd9dbf88967 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -75,6 +75,7 @@ class ComposerStaticInitFiles 'OCA\\Files\\Controller\\DirectEditingController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingController.php', 'OCA\\Files\\Controller\\DirectEditingViewController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingViewController.php', 'OCA\\Files\\Controller\\FilenamesController' => __DIR__ . '/..' . '/../lib/Controller/FilenamesController.php', + 'OCA\\Files\\Controller\\NodesApiController' => __DIR__ . '/..' . '/../lib/Controller/NodesApiController.php', 'OCA\\Files\\Controller\\OpenLocalEditorController' => __DIR__ . '/..' . '/../lib/Controller/OpenLocalEditorController.php', 'OCA\\Files\\Controller\\TemplateController' => __DIR__ . '/..' . '/../lib/Controller/TemplateController.php', 'OCA\\Files\\Controller\\TransferOwnershipController' => __DIR__ . '/..' . '/../lib/Controller/TransferOwnershipController.php', diff --git a/apps/files/lib/Controller/NodesApiController.php b/apps/files/lib/Controller/NodesApiController.php new file mode 100644 index 0000000000000..dd4dbab71b04b --- /dev/null +++ b/apps/files/lib/Controller/NodesApiController.php @@ -0,0 +1,79 @@ + $ids File ids, at most 100 + * @param string|null $dir Only return the paths within this folder of the user, the folder itself included + * @param list|null $attributes Attributes to include, in Clark notation like the WebDAV properties. All of them if null + * @return DataResponse, array{}> + * @throws OCSBadRequestException Too many ids + * + * 200: The files the user can access. A file reachable at several paths is listed once per path, unknown or inaccessible ids are left out + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/nodes')] + public function getNodes(array $ids, ?string $dir = null, ?array $attributes = null): DataResponse { + if (count($ids) > self::MAX_IDS) { + throw new OCSBadRequestException('At most ' . self::MAX_IDS . ' ids are allowed'); + } + /** @var IUser $user NoAdminRequired requires a logged in user */ + $user = $this->userSession->getUser(); + + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $scope = rtrim($userFolder->getPath() . '/' . trim($dir ?? '', '/'), '/'); + $nodes = []; + foreach (array_unique(array_map('intval', $ids)) as $id) { + // Only the user's own mounts are searched, so only files the user can access are found + foreach ($userFolder->getById($id) as $node) { + $path = $node->getPath(); + if ($path === $scope || str_starts_with($path, $scope . '/')) { + $nodes[] = $node; + } + } + } + + /** @var list $data */ + $data = $this->serializer->serialize(NodeAttributeContext::forUser($user), $nodes, $attributes); + return new DataResponse($data); + } +} diff --git a/apps/files/lib/ResponseDefinitions.php b/apps/files/lib/ResponseDefinitions.php index f5ed2ba8ce238..e2f9339d5a66b 100644 --- a/apps/files/lib/ResponseDefinitions.php +++ b/apps/files/lib/ResponseDefinitions.php @@ -71,6 +71,21 @@ * children: list, * }> * + * @psalm-type FilesNode = array{ + * id: int, + * source: string, + * root: string, + * owner: ?string, + * mime: string, + * mtime: int, + * crtime: ?int, + * size: int|float, + * permissions: int, + * displayname: string, + * type: 'file'|'folder', + * attributes: array, + * } + * */ class ResponseDefinitions { } diff --git a/apps/files/openapi.json b/apps/files/openapi.json index 808a0e07a41a1..2b437718a4541 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -177,6 +177,83 @@ } } }, + "Node": { + "type": "object", + "required": [ + "id", + "source", + "root", + "owner", + "mime", + "mtime", + "crtime", + "size", + "permissions", + "displayname", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "source": { + "type": "string" + }, + "root": { + "type": "string" + }, + "owner": { + "type": "string", + "nullable": true + }, + "mime": { + "type": "string" + }, + "mtime": { + "type": "integer", + "format": "int64" + }, + "crtime": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "size": { + "anyOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + } + ] + }, + "permissions": { + "type": "integer", + "format": "int64" + }, + "displayname": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "file", + "folder" + ] + }, + "attributes": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, "OCSMeta": { "type": "object", "required": [ @@ -2906,6 +2983,162 @@ } } } + }, + "/ocs/v2.php/apps/files/api/v1/nodes": { + "get": { + "operationId": "nodes_api-get-nodes", + "summary": "Get files and folders by id, with the same data as a PROPFIND", + "tags": [ + "nodes_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "ids[]", + "in": "query", + "description": "File ids, at most 100", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + { + "name": "dir", + "in": "query", + "description": "Only return the paths within this folder of the user, the folder itself included", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "attributes[]", + "in": "query", + "description": "Attributes to include, in Clark notation like the WebDAV properties. All of them if null", + "schema": { + "type": "array", + "nullable": true, + "default": null, + "items": { + "type": "string" + } + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "The files the user can access. A file reachable at several paths is listed once per path, unknown or inaccessible ids are left out", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Node" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Too many ids", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/apps/files/tests/Controller/NodesApiControllerTest.php b/apps/files/tests/Controller/NodesApiControllerTest.php new file mode 100644 index 0000000000000..33cc22de174d5 --- /dev/null +++ b/apps/files/tests/Controller/NodesApiControllerTest.php @@ -0,0 +1,115 @@ +> nodes by id */ + private array $nodes = []; + + #[\Override] + protected function setUp(): void { + parent::setUp(); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn($user); + + $this->userFolder = $this->createMock(IUserFolder::class); + $this->userFolder->method('getPath')->willReturn('/alice/files'); + $this->userFolder->method('getById')->willReturnCallback(fn (int $id): array => $this->nodes[$id] ?? []); + $rootFolder = $this->createMock(IRootFolder::class); + $rootFolder->method('getUserFolder')->with('alice')->willReturn($this->userFolder); + + $this->serializer = $this->createMock(INodeSerializer::class); + $this->controller = new NodesApiController('files', $this->createMock(IRequest::class), $rootFolder, $this->serializer, $userSession); + } + + /** + * @param class-string $class + */ + private function addNode(int $id, string $path, string $class = File::class): Node { + $node = $this->createMock($class); + $node->method('getId')->willReturn($id); + $node->method('getPath')->willReturn($path); + $this->nodes[$id][] = $node; + return $node; + } + + public function testNodesAreSerializedForTheUser(): void { + $file = $this->addNode(1, '/alice/files/report.md'); + $folder = $this->addNode(2, '/alice/files/Projects', Folder::class); + + $this->serializer->expects(self::once()) + ->method('serialize') + ->with( + self::callback(static fn (NodeAttributeContext $context): bool => $context->getUser()?->getUID() === 'alice'), + [$file, $folder], + ['{http://owncloud.org/ns}favorite'], + ) + ->willReturn([['id' => 1], ['id' => 2]]); + + $response = $this->controller->getNodes([1, 2, 404], null, ['{http://owncloud.org/ns}favorite']); + self::assertSame([['id' => 1], ['id' => 2]], $response->getData()); + } + + public function testOnlyPathsWithinTheFolderAreKept(): void { + $folder = $this->addNode(10, '/alice/files/Projects', Folder::class); + $inside = $this->addNode(11, '/alice/files/Projects/X/plan.md'); + // The same file reached through a second share + $this->addNode(11, '/alice/files/Y/plan.md'); + $this->addNode(12, '/alice/files/ProjectsOld/notes.md'); + $this->addNode(13, '/alice/files/notes.md'); + + $this->serializer->expects(self::once()) + ->method('serialize') + ->with(self::anything(), [$folder, $inside], null) + ->willReturn([]); + + $this->controller->getNodes([10, 11, 12, 13], '/Projects/'); + } + + public function testEveryPathOfAFileIsKeptWithoutFolder(): void { + $first = $this->addNode(11, '/alice/files/X/Y/plan.md'); + $second = $this->addNode(11, '/alice/files/Y/plan.md'); + + $this->serializer->expects(self::once()) + ->method('serialize') + ->with(self::anything(), [$first, $second], null) + ->willReturn([]); + + $this->controller->getNodes([11, 11]); + } + + public function testTooManyIds(): void { + $this->serializer->expects(self::never())->method('serialize'); + + $this->expectException(OCSBadRequestException::class); + $this->controller->getNodes(range(1, NodesApiController::MAX_IDS + 1)); + } +} diff --git a/openapi.json b/openapi.json index d2f20fb8d4b35..14a9f3c57ceef 100644 --- a/openapi.json +++ b/openapi.json @@ -1926,6 +1926,83 @@ } } }, + "FilesNode": { + "type": "object", + "required": [ + "id", + "source", + "root", + "owner", + "mime", + "mtime", + "crtime", + "size", + "permissions", + "displayname", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "source": { + "type": "string" + }, + "root": { + "type": "string" + }, + "owner": { + "type": "string", + "nullable": true + }, + "mime": { + "type": "string" + }, + "mtime": { + "type": "integer", + "format": "int64" + }, + "crtime": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "size": { + "anyOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + } + ] + }, + "permissions": { + "type": "integer", + "format": "int64" + }, + "displayname": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "file", + "folder" + ] + }, + "attributes": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, "FilesTemplate": { "type": "object", "required": [ @@ -26740,6 +26817,162 @@ } } }, + "/ocs/v2.php/apps/files/api/v1/nodes": { + "get": { + "operationId": "files-nodes_api-get-nodes", + "summary": "Get files and folders by id, with the same data as a PROPFIND", + "tags": [ + "files/nodes_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "ids[]", + "in": "query", + "description": "File ids, at most 100", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + { + "name": "dir", + "in": "query", + "description": "Only return the paths within this folder of the user, the folder itself included", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, + { + "name": "attributes[]", + "in": "query", + "description": "Attributes to include, in Clark notation like the WebDAV properties. All of them if null", + "schema": { + "type": "array", + "nullable": true, + "default": null, + "items": { + "type": "string" + } + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "The files the user can access. A file reachable at several paths is listed once per path, unknown or inaccessible ids are left out", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilesNode" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Too many ids", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/files_external/api/v1/mounts": { "get": { "operationId": "files_external-api-get-user-mounts",