You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

508 lines
18 KiB

  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 OC\Metadata\IMetadataManager;
  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\ISearchOperator;
  43. use OCP\Files\Search\ISearchOrder;
  44. use OCP\Files\Search\ISearchQuery;
  45. use OCP\FilesMetadata\IFilesMetadataManager;
  46. use OCP\FilesMetadata\Model\IMetadataQuery;
  47. use OCP\FilesMetadata\Model\IMetadataValueWrapper;
  48. use OCP\IUser;
  49. use OCP\Share\IManager;
  50. use Sabre\DAV\Exception\NotFound;
  51. use Sabre\DAV\INode;
  52. use SearchDAV\Backend\ISearchBackend;
  53. use SearchDAV\Backend\SearchPropertyDefinition;
  54. use SearchDAV\Backend\SearchResult;
  55. use SearchDAV\Query\Literal;
  56. use SearchDAV\Query\Operator;
  57. use SearchDAV\Query\Order;
  58. use SearchDAV\Query\Query;
  59. class FileSearchBackend implements ISearchBackend {
  60. public const OPERATOR_LIMIT = 100;
  61. public function __construct(
  62. private CachingTree $tree,
  63. private IUser $user,
  64. private IRootFolder $rootFolder,
  65. private IManager $shareManager,
  66. private View $view,
  67. private IFilesMetadataManager $filesMetadataManager,
  68. ) {
  69. }
  70. /**
  71. * Search endpoint will be remote.php/dav
  72. */
  73. public function getArbiterPath(): string {
  74. return '';
  75. }
  76. public function isValidScope(string $href, $depth, ?string $path): bool {
  77. // only allow scopes inside the dav server
  78. if (is_null($path)) {
  79. return false;
  80. }
  81. try {
  82. $node = $this->tree->getNodeForPath($path);
  83. return $node instanceof Directory;
  84. } catch (NotFound $e) {
  85. return false;
  86. }
  87. }
  88. public function getPropertyDefinitionsForScope(string $href, ?string $path): array {
  89. // all valid scopes support the same schema
  90. //todo dynamically load all propfind properties that are supported
  91. $props = [
  92. // queryable properties
  93. new SearchPropertyDefinition('{DAV:}displayname', true, true, true),
  94. new SearchPropertyDefinition('{DAV:}getcontenttype', true, true, true),
  95. new SearchPropertyDefinition('{DAV:}getlastmodified', true, true, true, SearchPropertyDefinition::DATATYPE_DATETIME),
  96. new SearchPropertyDefinition(FilesPlugin::SIZE_PROPERTYNAME, true, true, true, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER),
  97. new SearchPropertyDefinition(TagsPlugin::FAVORITE_PROPERTYNAME, true, true, true, SearchPropertyDefinition::DATATYPE_BOOLEAN),
  98. new SearchPropertyDefinition(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, true, true, false, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER),
  99. new SearchPropertyDefinition(FilesPlugin::OWNER_ID_PROPERTYNAME, true, true, false),
  100. // select only properties
  101. new SearchPropertyDefinition('{DAV:}resourcetype', true, false, false),
  102. new SearchPropertyDefinition('{DAV:}getcontentlength', true, false, false),
  103. new SearchPropertyDefinition(FilesPlugin::CHECKSUMS_PROPERTYNAME, true, false, false),
  104. new SearchPropertyDefinition(FilesPlugin::PERMISSIONS_PROPERTYNAME, true, false, false),
  105. new SearchPropertyDefinition(FilesPlugin::GETETAG_PROPERTYNAME, true, false, false),
  106. new SearchPropertyDefinition(FilesPlugin::OWNER_DISPLAY_NAME_PROPERTYNAME, true, false, false),
  107. new SearchPropertyDefinition(FilesPlugin::DATA_FINGERPRINT_PROPERTYNAME, true, false, false),
  108. new SearchPropertyDefinition(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, true, false, false, SearchPropertyDefinition::DATATYPE_BOOLEAN),
  109. new SearchPropertyDefinition(FilesPlugin::FILE_METADATA_SIZE, true, false, false, SearchPropertyDefinition::DATATYPE_STRING),
  110. new SearchPropertyDefinition(FilesPlugin::FILEID_PROPERTYNAME, true, false, false, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER),
  111. ];
  112. return array_merge($props, $this->getPropertyDefinitionsForMetadata());
  113. }
  114. private function getPropertyDefinitionsForMetadata(): array {
  115. $metadataProps = [];
  116. $metadata = $this->filesMetadataManager->getKnownMetadata();
  117. $indexes = $metadata->getIndexes();
  118. foreach ($metadata->getKeys() as $key) {
  119. $isIndex = in_array($key, $indexes);
  120. $type = match ($metadata->getType($key)) {
  121. IMetadataValueWrapper::TYPE_INT => SearchPropertyDefinition::DATATYPE_INTEGER,
  122. IMetadataValueWrapper::TYPE_FLOAT => SearchPropertyDefinition::DATATYPE_DECIMAL,
  123. IMetadataValueWrapper::TYPE_BOOL => SearchPropertyDefinition::DATATYPE_BOOLEAN,
  124. default => SearchPropertyDefinition::DATATYPE_STRING
  125. };
  126. $metadataProps[] = new SearchPropertyDefinition(
  127. FilesPlugin::FILE_METADATA_PREFIX . $key,
  128. true,
  129. $isIndex,
  130. $isIndex,
  131. $type
  132. );
  133. }
  134. return $metadataProps;
  135. }
  136. /**
  137. * @param INode[] $nodes
  138. * @param string[] $requestProperties
  139. */
  140. public function preloadPropertyFor(array $nodes, array $requestProperties): void {
  141. if (in_array(FilesPlugin::FILE_METADATA_SIZE, $requestProperties, true)) {
  142. // Preloading of the metadata
  143. $fileIds = [];
  144. foreach ($nodes as $node) {
  145. /** @var \OCP\Files\Node|\OCA\DAV\Connector\Sabre\Node $node */
  146. if (str_starts_with($node->getFileInfo()->getMimeType(), 'image/')) {
  147. /** @var \OCA\DAV\Connector\Sabre\File $node */
  148. $fileIds[] = $node->getFileInfo()->getId();
  149. }
  150. }
  151. /** @var IMetaDataManager $metadataManager */
  152. $metadataManager = \OC::$server->get(IMetadataManager::class);
  153. $preloadedMetadata = $metadataManager->fetchMetadataFor('size', $fileIds);
  154. foreach ($nodes as $node) {
  155. /** @var \OCP\Files\Node|\OCA\DAV\Connector\Sabre\Node $node */
  156. if (str_starts_with($node->getFileInfo()->getMimeType(), 'image/')) {
  157. /** @var \OCA\DAV\Connector\Sabre\File $node */
  158. $node->setMetadata('size', $preloadedMetadata[$node->getFileInfo()->getId()]);
  159. }
  160. }
  161. }
  162. }
  163. /**
  164. * @param Query $search
  165. * @return SearchResult[]
  166. */
  167. public function search(Query $search): array {
  168. if (count($search->from) !== 1) {
  169. throw new \InvalidArgumentException('Searching more than one folder is not supported');
  170. }
  171. $query = $this->transformQuery($search);
  172. $scope = $search->from[0];
  173. if ($scope->path === null) {
  174. throw new \InvalidArgumentException('Using uri\'s as scope is not supported, please use a path relative to the search arbiter instead');
  175. }
  176. $node = $this->tree->getNodeForPath($scope->path);
  177. if (!$node instanceof Directory) {
  178. throw new \InvalidArgumentException('Search is only supported on directories');
  179. }
  180. $fileInfo = $node->getFileInfo();
  181. $folder = $this->rootFolder->get($fileInfo->getPath());
  182. /** @var Folder $folder $results */
  183. $results = $folder->search($query);
  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): 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. return new SearchQuery(
  308. $this->transformSearchOperation($query->where),
  309. (int)$limit->maxResults,
  310. $offset,
  311. $orders,
  312. $this->user,
  313. $limitHome
  314. );
  315. }
  316. private function countSearchOperators(Operator $operator): int {
  317. switch ($operator->type) {
  318. case Operator::OPERATION_AND:
  319. case Operator::OPERATION_OR:
  320. case Operator::OPERATION_NOT:
  321. /** @var Operator[] $arguments */
  322. $arguments = $operator->arguments;
  323. return array_sum(array_map([$this, 'countSearchOperators'], $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. case Operator::OPERATION_IS_COLLECTION:
  331. default:
  332. return 1;
  333. }
  334. }
  335. /**
  336. * @param Operator $operator
  337. * @return ISearchOperator
  338. */
  339. private function transformSearchOperation(Operator $operator) {
  340. [, $trimmedType] = explode('}', $operator->type);
  341. switch ($operator->type) {
  342. case Operator::OPERATION_AND:
  343. case Operator::OPERATION_OR:
  344. case Operator::OPERATION_NOT:
  345. $arguments = array_map([$this, 'transformSearchOperation'], $operator->arguments);
  346. return new SearchBinaryOperator($trimmedType, $arguments);
  347. case Operator::OPERATION_EQUAL:
  348. case Operator::OPERATION_GREATER_OR_EQUAL_THAN:
  349. case Operator::OPERATION_GREATER_THAN:
  350. case Operator::OPERATION_LESS_OR_EQUAL_THAN:
  351. case Operator::OPERATION_LESS_THAN:
  352. case Operator::OPERATION_IS_LIKE:
  353. if (count($operator->arguments) !== 2) {
  354. throw new \InvalidArgumentException('Invalid number of arguments for ' . $trimmedType . ' operation');
  355. }
  356. if (!($operator->arguments[0] instanceof SearchPropertyDefinition)) {
  357. throw new \InvalidArgumentException('Invalid argument 1 for ' . $trimmedType . ' operation, expected property');
  358. }
  359. if (!($operator->arguments[1] instanceof Literal)) {
  360. throw new \InvalidArgumentException('Invalid argument 2 for ' . $trimmedType . ' operation, expected literal');
  361. }
  362. $property = $operator->arguments[0];
  363. $value = $this->castValue($property, $operator->arguments[1]->value);
  364. if (str_starts_with($property->name, FilesPlugin::FILE_METADATA_PREFIX)) {
  365. return new SearchComparison($trimmedType, substr($property->name, strlen(FilesPlugin::FILE_METADATA_PREFIX)), $value, IMetadataQuery::EXTRA);
  366. } else {
  367. return new SearchComparison($trimmedType, $this->mapPropertyNameToColumn($property), $value);
  368. }
  369. // no break
  370. case Operator::OPERATION_IS_COLLECTION:
  371. return new SearchComparison('eq', 'mimetype', ICacheEntry::DIRECTORY_MIMETYPE);
  372. default:
  373. throw new \InvalidArgumentException('Unsupported operation ' . $trimmedType . ' (' . $operator->type . ')');
  374. }
  375. }
  376. /**
  377. * @param SearchPropertyDefinition $property
  378. * @return string
  379. */
  380. private function mapPropertyNameToColumn(SearchPropertyDefinition $property) {
  381. switch ($property->name) {
  382. case '{DAV:}displayname':
  383. return 'name';
  384. case '{DAV:}getcontenttype':
  385. return 'mimetype';
  386. case '{DAV:}getlastmodified':
  387. return 'mtime';
  388. case FilesPlugin::SIZE_PROPERTYNAME:
  389. return 'size';
  390. case TagsPlugin::FAVORITE_PROPERTYNAME:
  391. return 'favorite';
  392. case TagsPlugin::TAGS_PROPERTYNAME:
  393. return 'tagname';
  394. case FilesPlugin::INTERNAL_FILEID_PROPERTYNAME:
  395. return 'fileid';
  396. default:
  397. throw new \InvalidArgumentException('Unsupported property for search or order: ' . $property->name);
  398. }
  399. }
  400. private function castValue(SearchPropertyDefinition $property, $value) {
  401. switch ($property->dataType) {
  402. case SearchPropertyDefinition::DATATYPE_BOOLEAN:
  403. return $value === 'yes';
  404. case SearchPropertyDefinition::DATATYPE_DECIMAL:
  405. case SearchPropertyDefinition::DATATYPE_INTEGER:
  406. case SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER:
  407. return 0 + $value;
  408. case SearchPropertyDefinition::DATATYPE_DATETIME:
  409. if (is_numeric($value)) {
  410. return max(0, 0 + $value);
  411. }
  412. $date = \DateTime::createFromFormat(\DateTimeInterface::ATOM, (string)$value);
  413. return ($date instanceof \DateTime && $date->getTimestamp() !== false) ? $date->getTimestamp() : 0;
  414. default:
  415. return $value;
  416. }
  417. }
  418. /**
  419. * Get a specific property from the were clause
  420. */
  421. private function extractWhereValue(Operator &$operator, string $propertyName, string $comparison, bool $acceptableLocation = true): ?string {
  422. switch ($operator->type) {
  423. case Operator::OPERATION_AND:
  424. case Operator::OPERATION_OR:
  425. case Operator::OPERATION_NOT:
  426. foreach ($operator->arguments as &$argument) {
  427. $value = $this->extractWhereValue($argument, $propertyName, $comparison, $acceptableLocation && $operator->type === Operator::OPERATION_AND);
  428. if ($value !== null) {
  429. return $value;
  430. }
  431. }
  432. return null;
  433. case Operator::OPERATION_EQUAL:
  434. case Operator::OPERATION_GREATER_OR_EQUAL_THAN:
  435. case Operator::OPERATION_GREATER_THAN:
  436. case Operator::OPERATION_LESS_OR_EQUAL_THAN:
  437. case Operator::OPERATION_LESS_THAN:
  438. case Operator::OPERATION_IS_LIKE:
  439. if ($operator->arguments[0]->name === $propertyName) {
  440. if ($operator->type === $comparison) {
  441. if ($acceptableLocation) {
  442. if ($operator->arguments[1] instanceof Literal) {
  443. $value = $operator->arguments[1]->value;
  444. // to remove the comparison from the query, we replace it with an empty AND
  445. $operator = new Operator(Operator::OPERATION_AND);
  446. return $value;
  447. } else {
  448. throw new \InvalidArgumentException("searching by '$propertyName' is only allowed with a literal value");
  449. }
  450. } else {
  451. throw new \InvalidArgumentException("searching by '$propertyName' is not allowed inside a '{DAV:}or' or '{DAV:}not'");
  452. }
  453. } else {
  454. throw new \InvalidArgumentException("searching by '$propertyName' is only allowed inside a '$comparison'");
  455. }
  456. } else {
  457. return null;
  458. }
  459. // no break
  460. default:
  461. return null;
  462. }
  463. }
  464. }