FilesReportPlugin.php 13 KB

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