FileSearchBackend.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-License-Identifier: AGPL-3.0-or-later
  5. */
  6. namespace OCA\DAV\Files;
  7. use OC\Files\Search\SearchBinaryOperator;
  8. use OC\Files\Search\SearchComparison;
  9. use OC\Files\Search\SearchOrder;
  10. use OC\Files\Search\SearchQuery;
  11. use OC\Files\Storage\Wrapper\Jail;
  12. use OC\Files\View;
  13. use OCA\DAV\Connector\Sabre\CachingTree;
  14. use OCA\DAV\Connector\Sabre\Directory;
  15. use OCA\DAV\Connector\Sabre\FilesPlugin;
  16. use OCA\DAV\Connector\Sabre\TagsPlugin;
  17. use OCP\Files\Cache\ICacheEntry;
  18. use OCP\Files\Folder;
  19. use OCP\Files\IRootFolder;
  20. use OCP\Files\Node;
  21. use OCP\Files\Search\ISearchBinaryOperator;
  22. use OCP\Files\Search\ISearchComparison;
  23. use OCP\Files\Search\ISearchOperator;
  24. use OCP\Files\Search\ISearchOrder;
  25. use OCP\Files\Search\ISearchQuery;
  26. use OCP\FilesMetadata\IFilesMetadataManager;
  27. use OCP\FilesMetadata\IMetadataQuery;
  28. use OCP\FilesMetadata\Model\IMetadataValueWrapper;
  29. use OCP\IUser;
  30. use OCP\Share\IManager;
  31. use Sabre\DAV\Exception\NotFound;
  32. use Sabre\DAV\INode;
  33. use SearchDAV\Backend\ISearchBackend;
  34. use SearchDAV\Backend\SearchPropertyDefinition;
  35. use SearchDAV\Backend\SearchResult;
  36. use SearchDAV\Query\Literal;
  37. use SearchDAV\Query\Operator;
  38. use SearchDAV\Query\Order;
  39. use SearchDAV\Query\Query;
  40. class FileSearchBackend implements ISearchBackend {
  41. public const OPERATOR_LIMIT = 100;
  42. public function __construct(
  43. private CachingTree $tree,
  44. private IUser $user,
  45. private IRootFolder $rootFolder,
  46. private IManager $shareManager,
  47. private View $view,
  48. private IFilesMetadataManager $filesMetadataManager,
  49. ) {
  50. }
  51. /**
  52. * Search endpoint will be remote.php/dav
  53. */
  54. public function getArbiterPath(): string {
  55. return '';
  56. }
  57. public function isValidScope(string $href, $depth, ?string $path): bool {
  58. // only allow scopes inside the dav server
  59. if (is_null($path)) {
  60. return false;
  61. }
  62. try {
  63. $node = $this->tree->getNodeForPath($path);
  64. return $node instanceof Directory;
  65. } catch (NotFound $e) {
  66. return false;
  67. }
  68. }
  69. public function getPropertyDefinitionsForScope(string $href, ?string $path): array {
  70. // all valid scopes support the same schema
  71. //todo dynamically load all propfind properties that are supported
  72. $props = [
  73. // queryable properties
  74. new SearchPropertyDefinition('{DAV:}displayname', true, true, true),
  75. new SearchPropertyDefinition('{DAV:}getcontenttype', true, true, true),
  76. new SearchPropertyDefinition('{DAV:}getlastmodified', true, true, true, SearchPropertyDefinition::DATATYPE_DATETIME),
  77. new SearchPropertyDefinition('{DAV:}parentid', true, true, true, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER),
  78. new SearchPropertyDefinition(FilesPlugin::SIZE_PROPERTYNAME, true, true, true, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER),
  79. new SearchPropertyDefinition(TagsPlugin::FAVORITE_PROPERTYNAME, true, true, true, SearchPropertyDefinition::DATATYPE_BOOLEAN),
  80. new SearchPropertyDefinition(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, true, true, false, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER),
  81. new SearchPropertyDefinition(FilesPlugin::OWNER_ID_PROPERTYNAME, true, true, false),
  82. // select only properties
  83. new SearchPropertyDefinition('{DAV:}resourcetype', true, false, false),
  84. new SearchPropertyDefinition('{DAV:}getcontentlength', true, false, false),
  85. new SearchPropertyDefinition(FilesPlugin::CHECKSUMS_PROPERTYNAME, true, false, false),
  86. new SearchPropertyDefinition(FilesPlugin::PERMISSIONS_PROPERTYNAME, true, false, false),
  87. new SearchPropertyDefinition(FilesPlugin::GETETAG_PROPERTYNAME, true, false, false),
  88. new SearchPropertyDefinition(FilesPlugin::OWNER_DISPLAY_NAME_PROPERTYNAME, true, false, false),
  89. new SearchPropertyDefinition(FilesPlugin::DATA_FINGERPRINT_PROPERTYNAME, true, false, false),
  90. new SearchPropertyDefinition(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, true, false, false, SearchPropertyDefinition::DATATYPE_BOOLEAN),
  91. new SearchPropertyDefinition(FilesPlugin::FILEID_PROPERTYNAME, true, false, false, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER),
  92. ];
  93. return array_merge($props, $this->getPropertyDefinitionsForMetadata());
  94. }
  95. private function getPropertyDefinitionsForMetadata(): array {
  96. $metadataProps = [];
  97. $metadata = $this->filesMetadataManager->getKnownMetadata();
  98. $indexes = $metadata->getIndexes();
  99. foreach ($metadata->getKeys() as $key) {
  100. $isIndex = in_array($key, $indexes);
  101. $type = match ($metadata->getType($key)) {
  102. IMetadataValueWrapper::TYPE_INT => SearchPropertyDefinition::DATATYPE_INTEGER,
  103. IMetadataValueWrapper::TYPE_FLOAT => SearchPropertyDefinition::DATATYPE_DECIMAL,
  104. IMetadataValueWrapper::TYPE_BOOL => SearchPropertyDefinition::DATATYPE_BOOLEAN,
  105. default => SearchPropertyDefinition::DATATYPE_STRING
  106. };
  107. $metadataProps[] = new SearchPropertyDefinition(
  108. FilesPlugin::FILE_METADATA_PREFIX . $key,
  109. true,
  110. $isIndex,
  111. $isIndex,
  112. $type
  113. );
  114. }
  115. return $metadataProps;
  116. }
  117. /**
  118. * @param INode[] $nodes
  119. * @param string[] $requestProperties
  120. */
  121. public function preloadPropertyFor(array $nodes, array $requestProperties): void {
  122. }
  123. private function getFolderForPath(?string $path = null): Folder {
  124. if ($path === null) {
  125. throw new \InvalidArgumentException('Using uri\'s as scope is not supported, please use a path relative to the search arbiter instead');
  126. }
  127. $node = $this->tree->getNodeForPath($path);
  128. if (!$node instanceof Directory) {
  129. throw new \InvalidArgumentException('Search is only supported on directories');
  130. }
  131. $fileInfo = $node->getFileInfo();
  132. /** @var Folder */
  133. return $this->rootFolder->get($fileInfo->getPath());
  134. }
  135. /**
  136. * @param Query $search
  137. * @return SearchResult[]
  138. */
  139. public function search(Query $search): array {
  140. switch (count($search->from)) {
  141. case 0:
  142. throw new \InvalidArgumentException('You need to specify a scope for the search.');
  143. break;
  144. case 1:
  145. $scope = $search->from[0];
  146. $folder = $this->getFolderForPath($scope->path);
  147. $query = $this->transformQuery($search);
  148. $results = $folder->search($query);
  149. break;
  150. default:
  151. $scopes = [];
  152. foreach ($search->from as $scope) {
  153. $folder = $this->getFolderForPath($scope->path);
  154. $folderStorage = $folder->getStorage();
  155. if ($folderStorage->instanceOfStorage(Jail::class)) {
  156. /** @var Jail $folderStorage */
  157. $internalPath = $folderStorage->getUnjailedPath($folder->getInternalPath());
  158. } else {
  159. $internalPath = $folder->getInternalPath();
  160. }
  161. $scopes[] = new SearchBinaryOperator(
  162. ISearchBinaryOperator::OPERATOR_AND,
  163. [
  164. new SearchComparison(
  165. ISearchComparison::COMPARE_EQUAL,
  166. 'storage',
  167. $folderStorage->getCache()->getNumericStorageId(),
  168. ''
  169. ),
  170. new SearchComparison(
  171. ISearchComparison::COMPARE_LIKE,
  172. 'path',
  173. $internalPath . '/%',
  174. ''
  175. ),
  176. ]
  177. );
  178. }
  179. $scopeOperators = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $scopes);
  180. $query = $this->transformQuery($search, $scopeOperators);
  181. $userFolder = $this->rootFolder->getUserFolder($this->user->getUID());
  182. $results = $userFolder->search($query);
  183. }
  184. /** @var SearchResult[] $nodes */
  185. $nodes = array_map(function (Node $node) {
  186. if ($node instanceof Folder) {
  187. $davNode = new \OCA\DAV\Connector\Sabre\Directory($this->view, $node, $this->tree, $this->shareManager);
  188. } else {
  189. $davNode = new \OCA\DAV\Connector\Sabre\File($this->view, $node, $this->shareManager);
  190. }
  191. $path = $this->getHrefForNode($node);
  192. $this->tree->cacheNode($davNode, $path);
  193. return new SearchResult($davNode, $path);
  194. }, $results);
  195. if (!$query->limitToHome()) {
  196. // Sort again, since the result from multiple storages is appended and not sorted
  197. usort($nodes, function (SearchResult $a, SearchResult $b) use ($search) {
  198. return $this->sort($a, $b, $search->orderBy);
  199. });
  200. }
  201. // If a limit is provided use only return that number of files
  202. if ($search->limit->maxResults !== 0) {
  203. $nodes = \array_slice($nodes, 0, $search->limit->maxResults);
  204. }
  205. return $nodes;
  206. }
  207. private function sort(SearchResult $a, SearchResult $b, array $orders) {
  208. /** @var Order $order */
  209. foreach ($orders as $order) {
  210. $v1 = $this->getSearchResultProperty($a, $order->property);
  211. $v2 = $this->getSearchResultProperty($b, $order->property);
  212. if ($v1 === null && $v2 === null) {
  213. continue;
  214. }
  215. if ($v1 === null) {
  216. return $order->order === Order::ASC ? 1 : -1;
  217. }
  218. if ($v2 === null) {
  219. return $order->order === Order::ASC ? -1 : 1;
  220. }
  221. $s = $this->compareProperties($v1, $v2, $order);
  222. if ($s === 0) {
  223. continue;
  224. }
  225. if ($order->order === Order::DESC) {
  226. $s = -$s;
  227. }
  228. return $s;
  229. }
  230. return 0;
  231. }
  232. private function compareProperties($a, $b, Order $order) {
  233. switch ($order->property->dataType) {
  234. case SearchPropertyDefinition::DATATYPE_STRING:
  235. return strcmp($a, $b);
  236. case SearchPropertyDefinition::DATATYPE_BOOLEAN:
  237. if ($a === $b) {
  238. return 0;
  239. }
  240. if ($a === false) {
  241. return -1;
  242. }
  243. return 1;
  244. default:
  245. if ($a === $b) {
  246. return 0;
  247. }
  248. if ($a < $b) {
  249. return -1;
  250. }
  251. return 1;
  252. }
  253. }
  254. private function getSearchResultProperty(SearchResult $result, SearchPropertyDefinition $property) {
  255. /** @var \OCA\DAV\Connector\Sabre\Node $node */
  256. $node = $result->node;
  257. switch ($property->name) {
  258. case '{DAV:}displayname':
  259. return $node->getName();
  260. case '{DAV:}getlastmodified':
  261. return $node->getLastModified();
  262. case FilesPlugin::SIZE_PROPERTYNAME:
  263. return $node->getSize();
  264. case FilesPlugin::INTERNAL_FILEID_PROPERTYNAME:
  265. return $node->getInternalFileId();
  266. default:
  267. return null;
  268. }
  269. }
  270. /**
  271. * @param Node $node
  272. * @return string
  273. */
  274. private function getHrefForNode(Node $node) {
  275. $base = '/files/' . $this->user->getUID();
  276. return $base . $this->view->getRelativePath($node->getPath());
  277. }
  278. /**
  279. * @param Query $query
  280. *
  281. * @return ISearchQuery
  282. */
  283. private function transformQuery(Query $query, ?SearchBinaryOperator $scopeOperators = null): ISearchQuery {
  284. $orders = array_map(function (Order $order): ISearchOrder {
  285. $direction = $order->order === Order::ASC ? ISearchOrder::DIRECTION_ASCENDING : ISearchOrder::DIRECTION_DESCENDING;
  286. if (str_starts_with($order->property->name, FilesPlugin::FILE_METADATA_PREFIX)) {
  287. return new SearchOrder($direction, substr($order->property->name, strlen(FilesPlugin::FILE_METADATA_PREFIX)), IMetadataQuery::EXTRA);
  288. } else {
  289. return new SearchOrder($direction, $this->mapPropertyNameToColumn($order->property));
  290. }
  291. }, $query->orderBy);
  292. $limit = $query->limit;
  293. $offset = $limit->firstResult;
  294. $limitHome = false;
  295. $ownerProp = $this->extractWhereValue($query->where, FilesPlugin::OWNER_ID_PROPERTYNAME, Operator::OPERATION_EQUAL);
  296. if ($ownerProp !== null) {
  297. if ($ownerProp === $this->user->getUID()) {
  298. $limitHome = true;
  299. } else {
  300. throw new \InvalidArgumentException("Invalid search value for '{http://owncloud.org/ns}owner-id', only the current user id is allowed");
  301. }
  302. }
  303. $operatorCount = $this->countSearchOperators($query->where);
  304. if ($operatorCount > self::OPERATOR_LIMIT) {
  305. throw new \InvalidArgumentException('Invalid search query, maximum operator limit of ' . self::OPERATOR_LIMIT . ' exceeded, got ' . $operatorCount . ' operators');
  306. }
  307. /** @var SearchBinaryOperator|SearchComparison */
  308. $queryOperators = $this->transformSearchOperation($query->where);
  309. if ($scopeOperators === null) {
  310. $operators = $queryOperators;
  311. } else {
  312. $operators = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [$queryOperators, $scopeOperators]);
  313. }
  314. return new SearchQuery(
  315. $operators,
  316. (int)$limit->maxResults,
  317. $offset,
  318. $orders,
  319. $this->user,
  320. $limitHome
  321. );
  322. }
  323. private function countSearchOperators(Operator $operator): int {
  324. switch ($operator->type) {
  325. case Operator::OPERATION_AND:
  326. case Operator::OPERATION_OR:
  327. case Operator::OPERATION_NOT:
  328. /** @var Operator[] $arguments */
  329. $arguments = $operator->arguments;
  330. return array_sum(array_map([$this, 'countSearchOperators'], $arguments));
  331. case Operator::OPERATION_EQUAL:
  332. case Operator::OPERATION_GREATER_OR_EQUAL_THAN:
  333. case Operator::OPERATION_GREATER_THAN:
  334. case Operator::OPERATION_LESS_OR_EQUAL_THAN:
  335. case Operator::OPERATION_LESS_THAN:
  336. case Operator::OPERATION_IS_LIKE:
  337. case Operator::OPERATION_IS_COLLECTION:
  338. default:
  339. return 1;
  340. }
  341. }
  342. /**
  343. * @param Operator $operator
  344. * @return ISearchOperator
  345. */
  346. private function transformSearchOperation(Operator $operator) {
  347. [, $trimmedType] = explode('}', $operator->type);
  348. switch ($operator->type) {
  349. case Operator::OPERATION_AND:
  350. case Operator::OPERATION_OR:
  351. case Operator::OPERATION_NOT:
  352. $arguments = array_map([$this, 'transformSearchOperation'], $operator->arguments);
  353. return new SearchBinaryOperator($trimmedType, $arguments);
  354. case Operator::OPERATION_EQUAL:
  355. case Operator::OPERATION_GREATER_OR_EQUAL_THAN:
  356. case Operator::OPERATION_GREATER_THAN:
  357. case Operator::OPERATION_LESS_OR_EQUAL_THAN:
  358. case Operator::OPERATION_LESS_THAN:
  359. case Operator::OPERATION_IS_LIKE:
  360. if (count($operator->arguments) !== 2) {
  361. throw new \InvalidArgumentException('Invalid number of arguments for ' . $trimmedType . ' operation');
  362. }
  363. if (!($operator->arguments[1] instanceof Literal)) {
  364. throw new \InvalidArgumentException('Invalid argument 2 for ' . $trimmedType . ' operation, expected literal');
  365. }
  366. $value = $operator->arguments[1]->value;
  367. // no break
  368. case Operator::OPERATION_IS_DEFINED:
  369. if (!($operator->arguments[0] instanceof SearchPropertyDefinition)) {
  370. throw new \InvalidArgumentException('Invalid argument 1 for ' . $trimmedType . ' operation, expected property');
  371. }
  372. $property = $operator->arguments[0];
  373. if (str_starts_with($property->name, FilesPlugin::FILE_METADATA_PREFIX)) {
  374. $field = substr($property->name, strlen(FilesPlugin::FILE_METADATA_PREFIX));
  375. $extra = IMetadataQuery::EXTRA;
  376. } else {
  377. $field = $this->mapPropertyNameToColumn($property);
  378. }
  379. return new SearchComparison(
  380. $trimmedType,
  381. $field,
  382. $this->castValue($property, $value ?? ''),
  383. $extra ?? ''
  384. );
  385. case Operator::OPERATION_IS_COLLECTION:
  386. return new SearchComparison('eq', 'mimetype', ICacheEntry::DIRECTORY_MIMETYPE);
  387. default:
  388. throw new \InvalidArgumentException('Unsupported operation ' . $trimmedType . ' (' . $operator->type . ')');
  389. }
  390. }
  391. /**
  392. * @param SearchPropertyDefinition $property
  393. * @return string
  394. */
  395. private function mapPropertyNameToColumn(SearchPropertyDefinition $property) {
  396. switch ($property->name) {
  397. case '{DAV:}displayname':
  398. return 'name';
  399. case '{DAV:}parentid':
  400. return 'parentid';
  401. case '{DAV:}getcontenttype':
  402. return 'mimetype';
  403. case '{DAV:}getlastmodified':
  404. return 'mtime';
  405. case FilesPlugin::SIZE_PROPERTYNAME:
  406. return 'size';
  407. case TagsPlugin::FAVORITE_PROPERTYNAME:
  408. return 'favorite';
  409. case TagsPlugin::TAGS_PROPERTYNAME:
  410. return 'tagname';
  411. case FilesPlugin::INTERNAL_FILEID_PROPERTYNAME:
  412. return 'fileid';
  413. default:
  414. throw new \InvalidArgumentException('Unsupported property for search or order: ' . $property->name);
  415. }
  416. }
  417. private function castValue(SearchPropertyDefinition $property, $value) {
  418. if ($value === '') {
  419. return '';
  420. }
  421. switch ($property->dataType) {
  422. case SearchPropertyDefinition::DATATYPE_BOOLEAN:
  423. return $value === 'yes';
  424. case SearchPropertyDefinition::DATATYPE_DECIMAL:
  425. case SearchPropertyDefinition::DATATYPE_INTEGER:
  426. case SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER:
  427. return 0 + $value;
  428. case SearchPropertyDefinition::DATATYPE_DATETIME:
  429. if (is_numeric($value)) {
  430. return max(0, 0 + $value);
  431. }
  432. $date = \DateTime::createFromFormat(\DateTimeInterface::ATOM, (string)$value);
  433. return ($date instanceof \DateTime && $date->getTimestamp() !== false) ? $date->getTimestamp() : 0;
  434. default:
  435. return $value;
  436. }
  437. }
  438. /**
  439. * Get a specific property from the were clause
  440. */
  441. private function extractWhereValue(Operator &$operator, string $propertyName, string $comparison, bool $acceptableLocation = true): ?string {
  442. switch ($operator->type) {
  443. case Operator::OPERATION_AND:
  444. case Operator::OPERATION_OR:
  445. case Operator::OPERATION_NOT:
  446. foreach ($operator->arguments as &$argument) {
  447. $value = $this->extractWhereValue($argument, $propertyName, $comparison, $acceptableLocation && $operator->type === Operator::OPERATION_AND);
  448. if ($value !== null) {
  449. return $value;
  450. }
  451. }
  452. return null;
  453. case Operator::OPERATION_EQUAL:
  454. case Operator::OPERATION_GREATER_OR_EQUAL_THAN:
  455. case Operator::OPERATION_GREATER_THAN:
  456. case Operator::OPERATION_LESS_OR_EQUAL_THAN:
  457. case Operator::OPERATION_LESS_THAN:
  458. case Operator::OPERATION_IS_LIKE:
  459. if ($operator->arguments[0]->name === $propertyName) {
  460. if ($operator->type === $comparison) {
  461. if ($acceptableLocation) {
  462. if ($operator->arguments[1] instanceof Literal) {
  463. $value = $operator->arguments[1]->value;
  464. // to remove the comparison from the query, we replace it with an empty AND
  465. $operator = new Operator(Operator::OPERATION_AND);
  466. return $value;
  467. } else {
  468. throw new \InvalidArgumentException("searching by '$propertyName' is only allowed with a literal value");
  469. }
  470. } else {
  471. throw new \InvalidArgumentException("searching by '$propertyName' is not allowed inside a '{DAV:}or' or '{DAV:}not'");
  472. }
  473. } else {
  474. throw new \InvalidArgumentException("searching by '$propertyName' is only allowed inside a '$comparison'");
  475. }
  476. } else {
  477. return null;
  478. }
  479. // no break
  480. default:
  481. return null;
  482. }
  483. }
  484. }