FileSearchBackend.php 17 KB

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