FileSearchBackend.php 17 KB

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