FileSearchBackend.php 18 KB

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