aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav/lib/Connector/Sabre/FilesReportPlugin.php')
-rw-r--r--apps/dav/lib/Connector/Sabre/FilesReportPlugin.php390
1 files changed, 390 insertions, 0 deletions
diff --git a/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php b/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php
new file mode 100644
index 00000000000..b59d1373af5
--- /dev/null
+++ b/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php
@@ -0,0 +1,390 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\DAV\Connector\Sabre;
+
+use OC\Files\View;
+use OCA\Circles\Api\v1\Circles;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Http;
+use OCP\Files\Folder;
+use OCP\Files\Node as INode;
+use OCP\IGroupManager;
+use OCP\ITagManager;
+use OCP\IUserSession;
+use OCP\SystemTag\ISystemTagManager;
+use OCP\SystemTag\ISystemTagObjectMapper;
+use OCP\SystemTag\TagNotFoundException;
+use Sabre\DAV\Exception\BadRequest;
+use Sabre\DAV\Exception\PreconditionFailed;
+use Sabre\DAV\PropFind;
+use Sabre\DAV\ServerPlugin;
+use Sabre\DAV\Tree;
+use Sabre\DAV\Xml\Element\Response;
+use Sabre\DAV\Xml\Response\MultiStatus;
+
+class FilesReportPlugin extends ServerPlugin {
+ // namespace
+ public const NS_OWNCLOUD = 'http://owncloud.org/ns';
+ public const NS_NEXTCLOUD = 'http://nextcloud.org/ns';
+ public const REPORT_NAME = '{http://owncloud.org/ns}filter-files';
+ public const SYSTEMTAG_PROPERTYNAME = '{http://owncloud.org/ns}systemtag';
+ public const CIRCLE_PROPERTYNAME = '{http://owncloud.org/ns}circle';
+
+ /**
+ * Reference to main server object
+ *
+ * @var \Sabre\DAV\Server
+ */
+ private $server;
+
+ /**
+ * @param Tree $tree
+ * @param View $fileView
+ * @param ISystemTagManager $tagManager
+ * @param ISystemTagObjectMapper $tagMapper
+ * @param ITagManager $fileTagger manager for private tags
+ * @param IUserSession $userSession
+ * @param IGroupManager $groupManager
+ * @param Folder $userFolder
+ * @param IAppManager $appManager
+ */
+ public function __construct(
+ private Tree $tree,
+ private View $fileView,
+ private ISystemTagManager $tagManager,
+ private ISystemTagObjectMapper $tagMapper,
+ /**
+ * Manager for private tags
+ */
+ private ITagManager $fileTagger,
+ private IUserSession $userSession,
+ private IGroupManager $groupManager,
+ private Folder $userFolder,
+ private IAppManager $appManager,
+ ) {
+ }
+
+ /**
+ * This initializes the plugin.
+ *
+ * This function is called by \Sabre\DAV\Server, after
+ * addPlugin is called.
+ *
+ * This method should set up the required event subscriptions.
+ *
+ * @param \Sabre\DAV\Server $server
+ * @return void
+ */
+ public function initialize(\Sabre\DAV\Server $server) {
+ $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc';
+
+ $this->server = $server;
+ $this->server->on('report', [$this, 'onReport']);
+ }
+
+ /**
+ * Returns a list of reports this plugin supports.
+ *
+ * This will be used in the {DAV:}supported-report-set property.
+ *
+ * @param string $uri
+ * @return array
+ */
+ public function getSupportedReportSet($uri) {
+ return [self::REPORT_NAME];
+ }
+
+ /**
+ * REPORT operations to look for files
+ *
+ * @param string $reportName
+ * @param $report
+ * @param string $uri
+ * @return bool
+ * @throws BadRequest
+ * @throws PreconditionFailed
+ * @internal param $ [] $report
+ */
+ public function onReport($reportName, $report, $uri) {
+ $reportTargetNode = $this->server->tree->getNodeForPath($uri);
+ if (!$reportTargetNode instanceof Directory || $reportName !== self::REPORT_NAME) {
+ return;
+ }
+
+ $ns = '{' . $this::NS_OWNCLOUD . '}';
+ $ncns = '{' . $this::NS_NEXTCLOUD . '}';
+ $requestedProps = [];
+ $filterRules = [];
+
+ // parse report properties and gather filter info
+ foreach ($report as $reportProps) {
+ $name = $reportProps['name'];
+ if ($name === $ns . 'filter-rules') {
+ $filterRules = $reportProps['value'];
+ } elseif ($name === '{DAV:}prop') {
+ // propfind properties
+ foreach ($reportProps['value'] as $propVal) {
+ $requestedProps[] = $propVal['name'];
+ }
+ } elseif ($name === '{DAV:}limit') {
+ foreach ($reportProps['value'] as $propVal) {
+ if ($propVal['name'] === '{DAV:}nresults') {
+ $limit = (int)$propVal['value'];
+ } elseif ($propVal['name'] === $ncns . 'firstresult') {
+ $offset = (int)$propVal['value'];
+ }
+ }
+ }
+ }
+
+ if (empty($filterRules)) {
+ // an empty filter would return all existing files which would be slow
+ throw new BadRequest('Missing filter-rule block in request');
+ }
+
+ // gather all file ids matching filter
+ try {
+ $resultFileIds = $this->processFilterRulesForFileIDs($filterRules);
+ // no logic in circles and favorites for paging, we always have all results, and slice later on
+ $resultFileIds = array_slice($resultFileIds, $offset ?? 0, $limit ?? null);
+ // fetching nodes has paging on DB level – therefore we cannot mix and slice the results, similar
+ // to user backends. I.e. the final result may return more results than requested.
+ $resultNodes = $this->processFilterRulesForFileNodes($filterRules, $limit ?? null, $offset ?? null);
+ } catch (TagNotFoundException $e) {
+ throw new PreconditionFailed('Cannot filter by non-existing tag');
+ }
+
+ $results = [];
+ foreach ($resultNodes as $entry) {
+ if ($entry) {
+ $results[] = $this->wrapNode($entry);
+ }
+ }
+
+ // find sabre nodes by file id, restricted to the root node path
+ $additionalNodes = $this->findNodesByFileIds($reportTargetNode, $resultFileIds);
+ if ($additionalNodes && $results) {
+ $results = array_uintersect($results, $additionalNodes, function (Node $a, Node $b): int {
+ return $a->getId() - $b->getId();
+ });
+ } elseif (!$results && $additionalNodes) {
+ $results = $additionalNodes;
+ }
+
+ $filesUri = $this->getFilesBaseUri($uri, $reportTargetNode->getPath());
+ $responses = $this->prepareResponses($filesUri, $requestedProps, $results);
+
+ $xml = $this->server->xml->write(
+ '{DAV:}multistatus',
+ new MultiStatus($responses)
+ );
+
+ $this->server->httpResponse->setStatus(Http::STATUS_MULTI_STATUS);
+ $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8');
+ $this->server->httpResponse->setBody($xml);
+
+ return false;
+ }
+
+ /**
+ * Returns the base uri of the files root by removing
+ * the subpath from the URI
+ *
+ * @param string $uri URI from this request
+ * @param string $subPath subpath to remove from the URI
+ *
+ * @return string files base uri
+ */
+ private function getFilesBaseUri(string $uri, string $subPath): string {
+ $uri = trim($uri, '/');
+ $subPath = trim($subPath, '/');
+ if (empty($subPath)) {
+ $filesUri = $uri;
+ } else {
+ $filesUri = substr($uri, 0, strlen($uri) - strlen($subPath));
+ }
+ $filesUri = trim($filesUri, '/');
+ if (empty($filesUri)) {
+ return '';
+ }
+ return '/' . $filesUri;
+ }
+
+ /**
+ * Find file ids matching the given filter rules
+ *
+ * @param array $filterRules
+ * @return array array of unique file id results
+ */
+ protected function processFilterRulesForFileIDs(array $filterRules): array {
+ $ns = '{' . $this::NS_OWNCLOUD . '}';
+ $resultFileIds = [];
+ $circlesIds = [];
+ $favoriteFilter = null;
+ foreach ($filterRules as $filterRule) {
+ if ($filterRule['name'] === self::CIRCLE_PROPERTYNAME) {
+ $circlesIds[] = $filterRule['value'];
+ }
+ if ($filterRule['name'] === $ns . 'favorite') {
+ $favoriteFilter = true;
+ }
+ }
+
+ if ($favoriteFilter !== null) {
+ $resultFileIds = $this->fileTagger->load('files')->getFavorites();
+ if (empty($resultFileIds)) {
+ return [];
+ }
+ }
+
+ if (!empty($circlesIds)) {
+ $fileIds = $this->getCirclesFileIds($circlesIds);
+ if (empty($resultFileIds)) {
+ $resultFileIds = $fileIds;
+ } else {
+ $resultFileIds = array_intersect($fileIds, $resultFileIds);
+ }
+ }
+
+ return $resultFileIds;
+ }
+
+ protected function processFilterRulesForFileNodes(array $filterRules, ?int $limit, ?int $offset): array {
+ $systemTagIds = [];
+ foreach ($filterRules as $filterRule) {
+ if ($filterRule['name'] === self::SYSTEMTAG_PROPERTYNAME) {
+ $systemTagIds[] = $filterRule['value'];
+ }
+ }
+
+ $nodes = [];
+
+ if (!empty($systemTagIds)) {
+ $tags = $this->tagManager->getTagsByIds($systemTagIds, $this->userSession->getUser());
+
+ // For we run DB queries per tag and require intersection, we cannot apply limit and offset for DB queries on multi tag search.
+ $oneTagSearch = count($tags) === 1;
+ $dbLimit = $oneTagSearch ? $limit ?? 0 : 0;
+ $dbOffset = $oneTagSearch ? $offset ?? 0 : 0;
+
+ foreach ($tags as $tag) {
+ $tagName = $tag->getName();
+ $tmpNodes = $this->userFolder->searchBySystemTag($tagName, $this->userSession->getUser()->getUID(), $dbLimit, $dbOffset);
+ if (count($nodes) === 0) {
+ $nodes = $tmpNodes;
+ } else {
+ $nodes = array_uintersect($nodes, $tmpNodes, function (INode $a, INode $b): int {
+ return $a->getId() - $b->getId();
+ });
+ }
+ if ($nodes === []) {
+ // there cannot be a common match when nodes are empty early.
+ return $nodes;
+ }
+ }
+
+ if (!$oneTagSearch && ($limit !== null || $offset !== null)) {
+ $nodes = array_slice($nodes, $offset, $limit);
+ }
+ }
+
+ return $nodes;
+ }
+
+ /**
+ * @suppress PhanUndeclaredClassMethod
+ * @param array $circlesIds
+ * @return array
+ */
+ private function getCirclesFileIds(array $circlesIds) {
+ if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) {
+ return [];
+ }
+ return Circles::getFilesForCircles($circlesIds);
+ }
+
+
+ /**
+ * Prepare propfind response for the given nodes
+ *
+ * @param string $filesUri $filesUri URI leading to root of the files URI,
+ * with a leading slash but no trailing slash
+ * @param string[] $requestedProps requested properties
+ * @param Node[] nodes nodes for which to fetch and prepare responses
+ * @return Response[]
+ */
+ public function prepareResponses($filesUri, $requestedProps, $nodes) {
+ $responses = [];
+ foreach ($nodes as $node) {
+ $propFind = new PropFind($filesUri . $node->getPath(), $requestedProps);
+
+ $this->server->getPropertiesByNode($propFind, $node);
+ // copied from Sabre Server's getPropertiesForPath
+ $result = $propFind->getResultForMultiStatus();
+ $result['href'] = $propFind->getPath();
+
+ $resourceType = $this->server->getResourceTypeForNode($node);
+ if (in_array('{DAV:}collection', $resourceType) || in_array('{DAV:}principal', $resourceType)) {
+ $result['href'] .= '/';
+ }
+
+ $responses[] = new Response(
+ rtrim($this->server->getBaseUri(), '/') . $filesUri . $node->getPath(),
+ $result,
+ );
+ }
+ return $responses;
+ }
+
+ /**
+ * Find Sabre nodes by file ids
+ *
+ * @param Node $rootNode root node for search
+ * @param array $fileIds file ids
+ * @return Node[] array of Sabre nodes
+ */
+ public function findNodesByFileIds(Node $rootNode, array $fileIds): array {
+ if (empty($fileIds)) {
+ return [];
+ }
+ $folder = $this->userFolder;
+ if (trim($rootNode->getPath(), '/') !== '') {
+ /** @var Folder $folder */
+ $folder = $folder->get($rootNode->getPath());
+ }
+
+ $results = [];
+ foreach ($fileIds as $fileId) {
+ $entry = $folder->getFirstNodeById((int)$fileId);
+ if ($entry) {
+ $results[] = $this->wrapNode($entry);
+ }
+ }
+
+ return $results;
+ }
+
+ protected function wrapNode(INode $node): File|Directory {
+ if ($node instanceof \OCP\Files\File) {
+ return new File($this->fileView, $node);
+ } else {
+ return new Directory($this->fileView, $node);
+ }
+ }
+
+ /**
+ * Returns whether the currently logged in user is an administrator
+ */
+ private function isAdmin() {
+ $user = $this->userSession->getUser();
+ if ($user !== null) {
+ return $this->groupManager->isAdmin($user->getUID());
+ }
+ return false;
+ }
+}