FilesReportPlugin.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OCA\DAV\Connector\Sabre;
  8. use OC\Files\View;
  9. use OCP\App\IAppManager;
  10. use OCP\Files\Folder;
  11. use OCP\Files\Node as INode;
  12. use OCP\IGroupManager;
  13. use OCP\ITagManager;
  14. use OCP\IUserSession;
  15. use OCP\SystemTag\ISystemTagManager;
  16. use OCP\SystemTag\ISystemTagObjectMapper;
  17. use OCP\SystemTag\TagNotFoundException;
  18. use Sabre\DAV\Exception\BadRequest;
  19. use Sabre\DAV\Exception\PreconditionFailed;
  20. use Sabre\DAV\PropFind;
  21. use Sabre\DAV\ServerPlugin;
  22. use Sabre\DAV\Tree;
  23. use Sabre\DAV\Xml\Element\Response;
  24. use Sabre\DAV\Xml\Response\MultiStatus;
  25. class FilesReportPlugin extends ServerPlugin {
  26. // namespace
  27. public const NS_OWNCLOUD = 'http://owncloud.org/ns';
  28. public const NS_NEXTCLOUD = 'http://nextcloud.org/ns';
  29. public const REPORT_NAME = '{http://owncloud.org/ns}filter-files';
  30. public const SYSTEMTAG_PROPERTYNAME = '{http://owncloud.org/ns}systemtag';
  31. public const CIRCLE_PROPERTYNAME = '{http://owncloud.org/ns}circle';
  32. /**
  33. * Reference to main server object
  34. *
  35. * @var \Sabre\DAV\Server
  36. */
  37. private $server;
  38. /**
  39. * @var Tree
  40. */
  41. private $tree;
  42. /**
  43. * @var View
  44. */
  45. private $fileView;
  46. /**
  47. * @var ISystemTagManager
  48. */
  49. private $tagManager;
  50. /**
  51. * @var ISystemTagObjectMapper
  52. */
  53. private $tagMapper;
  54. /**
  55. * Manager for private tags
  56. *
  57. * @var ITagManager
  58. */
  59. private $fileTagger;
  60. /**
  61. * @var IUserSession
  62. */
  63. private $userSession;
  64. /**
  65. * @var IGroupManager
  66. */
  67. private $groupManager;
  68. /**
  69. * @var Folder
  70. */
  71. private $userFolder;
  72. /**
  73. * @var IAppManager
  74. */
  75. private $appManager;
  76. /**
  77. * @param Tree $tree
  78. * @param View $view
  79. * @param ISystemTagManager $tagManager
  80. * @param ISystemTagObjectMapper $tagMapper
  81. * @param ITagManager $fileTagger manager for private tags
  82. * @param IUserSession $userSession
  83. * @param IGroupManager $groupManager
  84. * @param Folder $userFolder
  85. * @param IAppManager $appManager
  86. */
  87. public function __construct(Tree $tree,
  88. View $view,
  89. ISystemTagManager $tagManager,
  90. ISystemTagObjectMapper $tagMapper,
  91. ITagManager $fileTagger,
  92. IUserSession $userSession,
  93. IGroupManager $groupManager,
  94. Folder $userFolder,
  95. IAppManager $appManager
  96. ) {
  97. $this->tree = $tree;
  98. $this->fileView = $view;
  99. $this->tagManager = $tagManager;
  100. $this->tagMapper = $tagMapper;
  101. $this->fileTagger = $fileTagger;
  102. $this->userSession = $userSession;
  103. $this->groupManager = $groupManager;
  104. $this->userFolder = $userFolder;
  105. $this->appManager = $appManager;
  106. }
  107. /**
  108. * This initializes the plugin.
  109. *
  110. * This function is called by \Sabre\DAV\Server, after
  111. * addPlugin is called.
  112. *
  113. * This method should set up the required event subscriptions.
  114. *
  115. * @param \Sabre\DAV\Server $server
  116. * @return void
  117. */
  118. public function initialize(\Sabre\DAV\Server $server) {
  119. $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc';
  120. $this->server = $server;
  121. $this->server->on('report', [$this, 'onReport']);
  122. }
  123. /**
  124. * Returns a list of reports this plugin supports.
  125. *
  126. * This will be used in the {DAV:}supported-report-set property.
  127. *
  128. * @param string $uri
  129. * @return array
  130. */
  131. public function getSupportedReportSet($uri) {
  132. return [self::REPORT_NAME];
  133. }
  134. /**
  135. * REPORT operations to look for files
  136. *
  137. * @param string $reportName
  138. * @param $report
  139. * @param string $uri
  140. * @return bool
  141. * @throws BadRequest
  142. * @throws PreconditionFailed
  143. * @internal param $ [] $report
  144. */
  145. public function onReport($reportName, $report, $uri) {
  146. $reportTargetNode = $this->server->tree->getNodeForPath($uri);
  147. if (!$reportTargetNode instanceof Directory || $reportName !== self::REPORT_NAME) {
  148. return;
  149. }
  150. $ns = '{' . $this::NS_OWNCLOUD . '}';
  151. $ncns = '{' . $this::NS_NEXTCLOUD . '}';
  152. $requestedProps = [];
  153. $filterRules = [];
  154. // parse report properties and gather filter info
  155. foreach ($report as $reportProps) {
  156. $name = $reportProps['name'];
  157. if ($name === $ns . 'filter-rules') {
  158. $filterRules = $reportProps['value'];
  159. } elseif ($name === '{DAV:}prop') {
  160. // propfind properties
  161. foreach ($reportProps['value'] as $propVal) {
  162. $requestedProps[] = $propVal['name'];
  163. }
  164. } elseif ($name === '{DAV:}limit') {
  165. foreach ($reportProps['value'] as $propVal) {
  166. if ($propVal['name'] === '{DAV:}nresults') {
  167. $limit = (int)$propVal['value'];
  168. } elseif ($propVal['name'] === $ncns . 'firstresult') {
  169. $offset = (int)$propVal['value'];
  170. }
  171. }
  172. }
  173. }
  174. if (empty($filterRules)) {
  175. // an empty filter would return all existing files which would be slow
  176. throw new BadRequest('Missing filter-rule block in request');
  177. }
  178. // gather all file ids matching filter
  179. try {
  180. $resultFileIds = $this->processFilterRulesForFileIDs($filterRules);
  181. // no logic in circles and favorites for paging, we always have all results, and slice later on
  182. $resultFileIds = array_slice($resultFileIds, $offset ?? 0, $limit ?? null);
  183. // fetching nodes has paging on DB level – therefore we cannot mix and slice the results, similar
  184. // to user backends. I.e. the final result may return more results than requested.
  185. $resultNodes = $this->processFilterRulesForFileNodes($filterRules, $limit ?? null, $offset ?? null);
  186. } catch (TagNotFoundException $e) {
  187. throw new PreconditionFailed('Cannot filter by non-existing tag', 0, $e);
  188. }
  189. $results = [];
  190. foreach ($resultNodes as $entry) {
  191. if ($entry) {
  192. $results[] = $this->wrapNode($entry);
  193. }
  194. }
  195. // find sabre nodes by file id, restricted to the root node path
  196. $additionalNodes = $this->findNodesByFileIds($reportTargetNode, $resultFileIds);
  197. if ($additionalNodes && $results) {
  198. $results = array_uintersect($results, $additionalNodes, function (Node $a, Node $b): int {
  199. return $a->getId() - $b->getId();
  200. });
  201. } elseif (!$results && $additionalNodes) {
  202. $results = $additionalNodes;
  203. }
  204. $filesUri = $this->getFilesBaseUri($uri, $reportTargetNode->getPath());
  205. $responses = $this->prepareResponses($filesUri, $requestedProps, $results);
  206. $xml = $this->server->xml->write(
  207. '{DAV:}multistatus',
  208. new MultiStatus($responses)
  209. );
  210. $this->server->httpResponse->setStatus(207);
  211. $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8');
  212. $this->server->httpResponse->setBody($xml);
  213. return false;
  214. }
  215. /**
  216. * Returns the base uri of the files root by removing
  217. * the subpath from the URI
  218. *
  219. * @param string $uri URI from this request
  220. * @param string $subPath subpath to remove from the URI
  221. *
  222. * @return string files base uri
  223. */
  224. private function getFilesBaseUri(string $uri, string $subPath): string {
  225. $uri = trim($uri, '/');
  226. $subPath = trim($subPath, '/');
  227. if (empty($subPath)) {
  228. $filesUri = $uri;
  229. } else {
  230. $filesUri = substr($uri, 0, strlen($uri) - strlen($subPath));
  231. }
  232. $filesUri = trim($filesUri, '/');
  233. if (empty($filesUri)) {
  234. return '';
  235. }
  236. return '/' . $filesUri;
  237. }
  238. /**
  239. * Find file ids matching the given filter rules
  240. *
  241. * @param array $filterRules
  242. * @return array array of unique file id results
  243. */
  244. protected function processFilterRulesForFileIDs(array $filterRules): array {
  245. $ns = '{' . $this::NS_OWNCLOUD . '}';
  246. $resultFileIds = [];
  247. $circlesIds = [];
  248. $favoriteFilter = null;
  249. foreach ($filterRules as $filterRule) {
  250. if ($filterRule['name'] === self::CIRCLE_PROPERTYNAME) {
  251. $circlesIds[] = $filterRule['value'];
  252. }
  253. if ($filterRule['name'] === $ns . 'favorite') {
  254. $favoriteFilter = true;
  255. }
  256. }
  257. if ($favoriteFilter !== null) {
  258. $resultFileIds = $this->fileTagger->load('files')->getFavorites();
  259. if (empty($resultFileIds)) {
  260. return [];
  261. }
  262. }
  263. if (!empty($circlesIds)) {
  264. $fileIds = $this->getCirclesFileIds($circlesIds);
  265. if (empty($resultFileIds)) {
  266. $resultFileIds = $fileIds;
  267. } else {
  268. $resultFileIds = array_intersect($fileIds, $resultFileIds);
  269. }
  270. }
  271. return $resultFileIds;
  272. }
  273. protected function processFilterRulesForFileNodes(array $filterRules, ?int $limit, ?int $offset): array {
  274. $systemTagIds = [];
  275. foreach ($filterRules as $filterRule) {
  276. if ($filterRule['name'] === self::SYSTEMTAG_PROPERTYNAME) {
  277. $systemTagIds[] = $filterRule['value'];
  278. }
  279. }
  280. $nodes = [];
  281. if (!empty($systemTagIds)) {
  282. $tags = $this->tagManager->getTagsByIds($systemTagIds, $this->userSession->getUser());
  283. // For we run DB queries per tag and require intersection, we cannot apply limit and offset for DB queries on multi tag search.
  284. $oneTagSearch = count($tags) === 1;
  285. $dbLimit = $oneTagSearch ? $limit ?? 0 : 0;
  286. $dbOffset = $oneTagSearch ? $offset ?? 0 : 0;
  287. foreach ($tags as $tag) {
  288. $tagName = $tag->getName();
  289. $tmpNodes = $this->userFolder->searchBySystemTag($tagName, $this->userSession->getUser()->getUID(), $dbLimit, $dbOffset);
  290. if (count($nodes) === 0) {
  291. $nodes = $tmpNodes;
  292. } else {
  293. $nodes = array_uintersect($nodes, $tmpNodes, function (INode $a, INode $b): int {
  294. return $a->getId() - $b->getId();
  295. });
  296. }
  297. if ($nodes === []) {
  298. // there cannot be a common match when nodes are empty early.
  299. return $nodes;
  300. }
  301. }
  302. if (!$oneTagSearch && ($limit !== null || $offset !== null)) {
  303. $nodes = array_slice($nodes, $offset, $limit);
  304. }
  305. }
  306. return $nodes;
  307. }
  308. /**
  309. * @suppress PhanUndeclaredClassMethod
  310. * @param array $circlesIds
  311. * @return array
  312. */
  313. private function getCirclesFileIds(array $circlesIds) {
  314. if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) {
  315. return [];
  316. }
  317. return \OCA\Circles\Api\v1\Circles::getFilesForCircles($circlesIds);
  318. }
  319. /**
  320. * Prepare propfind response for the given nodes
  321. *
  322. * @param string $filesUri $filesUri URI leading to root of the files URI,
  323. * with a leading slash but no trailing slash
  324. * @param string[] $requestedProps requested properties
  325. * @param Node[] nodes nodes for which to fetch and prepare responses
  326. * @return Response[]
  327. */
  328. public function prepareResponses($filesUri, $requestedProps, $nodes) {
  329. $responses = [];
  330. foreach ($nodes as $node) {
  331. $propFind = new PropFind($filesUri . $node->getPath(), $requestedProps);
  332. $this->server->getPropertiesByNode($propFind, $node);
  333. // copied from Sabre Server's getPropertiesForPath
  334. $result = $propFind->getResultForMultiStatus();
  335. $result['href'] = $propFind->getPath();
  336. $resourceType = $this->server->getResourceTypeForNode($node);
  337. if (in_array('{DAV:}collection', $resourceType) || in_array('{DAV:}principal', $resourceType)) {
  338. $result['href'] .= '/';
  339. }
  340. $responses[] = new Response(
  341. rtrim($this->server->getBaseUri(), '/') . $filesUri . $node->getPath(),
  342. $result,
  343. );
  344. }
  345. return $responses;
  346. }
  347. /**
  348. * Find Sabre nodes by file ids
  349. *
  350. * @param Node $rootNode root node for search
  351. * @param array $fileIds file ids
  352. * @return Node[] array of Sabre nodes
  353. */
  354. public function findNodesByFileIds(Node $rootNode, array $fileIds): array {
  355. if (empty($fileIds)) {
  356. return [];
  357. }
  358. $folder = $this->userFolder;
  359. if (trim($rootNode->getPath(), '/') !== '') {
  360. /** @var Folder $folder */
  361. $folder = $folder->get($rootNode->getPath());
  362. }
  363. $results = [];
  364. foreach ($fileIds as $fileId) {
  365. $entry = $folder->getFirstNodeById($fileId);
  366. if ($entry) {
  367. $results[] = $this->wrapNode($entry);
  368. }
  369. }
  370. return $results;
  371. }
  372. protected function wrapNode(\OCP\Files\Node $node): File|Directory {
  373. if ($node instanceof \OCP\Files\File) {
  374. return new File($this->fileView, $node);
  375. } else {
  376. return new Directory($this->fileView, $node);
  377. }
  378. }
  379. /**
  380. * Returns whether the currently logged in user is an administrator
  381. */
  382. private function isAdmin() {
  383. $user = $this->userSession->getUser();
  384. if ($user !== null) {
  385. return $this->groupManager->isAdmin($user->getUID());
  386. }
  387. return false;
  388. }
  389. }