1
0

FileSearchBackend.php 17 KB

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