Issues (2553)

apps/dav/lib/Files/FileSearchBackend.php (3 issues)

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