Completed
Push — master ( 9e0e53...db078e )
by Robin
27:45 queued 15s
created
apps/dav/lib/DAV/CustomPropertiesBackend.php 1 patch
Indentation   +569 added lines, -569 removed lines patch added patch discarded remove patch
@@ -31,573 +31,573 @@
 block discarded – undo
31 31
 
32 32
 class CustomPropertiesBackend implements BackendInterface {
33 33
 
34
-	/** @var string */
35
-	private const TABLE_NAME = 'properties';
36
-
37
-	/**
38
-	 * Value is stored as string.
39
-	 */
40
-	public const PROPERTY_TYPE_STRING = 1;
41
-
42
-	/**
43
-	 * Value is stored as XML fragment.
44
-	 */
45
-	public const PROPERTY_TYPE_XML = 2;
46
-
47
-	/**
48
-	 * Value is stored as a property object.
49
-	 */
50
-	public const PROPERTY_TYPE_OBJECT = 3;
51
-
52
-	/**
53
-	 * Value is stored as a {DAV:}href string.
54
-	 */
55
-	public const PROPERTY_TYPE_HREF = 4;
56
-
57
-	/**
58
-	 * Ignored properties
59
-	 *
60
-	 * @var string[]
61
-	 */
62
-	private const IGNORED_PROPERTIES = [
63
-		'{DAV:}getcontentlength',
64
-		'{DAV:}getcontenttype',
65
-		'{DAV:}getetag',
66
-		'{DAV:}quota-used-bytes',
67
-		'{DAV:}quota-available-bytes',
68
-		'{http://owncloud.org/ns}permissions',
69
-		'{http://owncloud.org/ns}downloadURL',
70
-		'{http://owncloud.org/ns}dDC',
71
-		'{http://owncloud.org/ns}size',
72
-		'{http://nextcloud.org/ns}is-encrypted',
73
-
74
-		// Currently, returning null from any propfind handler would still trigger the backend,
75
-		// so we add all known Nextcloud custom properties in here to avoid that
76
-
77
-		// text app
78
-		'{http://nextcloud.org/ns}rich-workspace',
79
-		'{http://nextcloud.org/ns}rich-workspace-file',
80
-		// groupfolders
81
-		'{http://nextcloud.org/ns}acl-enabled',
82
-		'{http://nextcloud.org/ns}acl-can-manage',
83
-		'{http://nextcloud.org/ns}acl-list',
84
-		'{http://nextcloud.org/ns}inherited-acl-list',
85
-		'{http://nextcloud.org/ns}group-folder-id',
86
-		// files_lock
87
-		'{http://nextcloud.org/ns}lock',
88
-		'{http://nextcloud.org/ns}lock-owner-type',
89
-		'{http://nextcloud.org/ns}lock-owner',
90
-		'{http://nextcloud.org/ns}lock-owner-displayname',
91
-		'{http://nextcloud.org/ns}lock-owner-editor',
92
-		'{http://nextcloud.org/ns}lock-time',
93
-		'{http://nextcloud.org/ns}lock-timeout',
94
-		'{http://nextcloud.org/ns}lock-token',
95
-	];
96
-
97
-	/**
98
-	 * Properties set by one user, readable by all others
99
-	 *
100
-	 * @var string[]
101
-	 */
102
-	private const PUBLISHED_READ_ONLY_PROPERTIES = [
103
-		'{urn:ietf:params:xml:ns:caldav}calendar-availability',
104
-		'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
105
-	];
106
-
107
-	/**
108
-	 * Map of custom XML elements to parse when trying to deserialize an instance of
109
-	 * \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_*
110
-	 */
111
-	private const COMPLEX_XML_ELEMENT_MAP = [
112
-		'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class,
113
-	];
114
-
115
-	/**
116
-	 * Properties cache
117
-	 *
118
-	 * @var array
119
-	 */
120
-	private $userCache = [];
121
-	private XmlService $xmlService;
122
-
123
-	/**
124
-	 * @param Tree $tree node tree
125
-	 * @param IDBConnection $connection database connection
126
-	 * @param IUser $user owner of the tree and properties
127
-	 */
128
-	public function __construct(
129
-		private Server $server,
130
-		private Tree $tree,
131
-		private IDBConnection $connection,
132
-		private IUser $user,
133
-		private DefaultCalendarValidator $defaultCalendarValidator,
134
-	) {
135
-		$this->xmlService = new XmlService();
136
-		$this->xmlService->elementMap = array_merge(
137
-			$this->xmlService->elementMap,
138
-			self::COMPLEX_XML_ELEMENT_MAP,
139
-		);
140
-	}
141
-
142
-	/**
143
-	 * Fetches properties for a path.
144
-	 *
145
-	 * @param string $path
146
-	 * @param PropFind $propFind
147
-	 * @return void
148
-	 */
149
-	public function propFind($path, PropFind $propFind) {
150
-		$requestedProps = $propFind->get404Properties();
151
-
152
-		// these might appear
153
-		$requestedProps = array_diff(
154
-			$requestedProps,
155
-			self::IGNORED_PROPERTIES,
156
-		);
157
-		$requestedProps = array_filter(
158
-			$requestedProps,
159
-			fn ($prop) => !str_starts_with($prop, FilesPlugin::FILE_METADATA_PREFIX),
160
-		);
161
-
162
-		// substr of calendars/ => path is inside the CalDAV component
163
-		// two '/' => this a calendar (no calendar-home nor calendar object)
164
-		if (str_starts_with($path, 'calendars/') && substr_count($path, '/') === 2) {
165
-			$allRequestedProps = $propFind->getRequestedProperties();
166
-			$customPropertiesForShares = [
167
-				'{DAV:}displayname',
168
-				'{urn:ietf:params:xml:ns:caldav}calendar-description',
169
-				'{urn:ietf:params:xml:ns:caldav}calendar-timezone',
170
-				'{http://apple.com/ns/ical/}calendar-order',
171
-				'{http://apple.com/ns/ical/}calendar-color',
172
-				'{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp',
173
-			];
174
-
175
-			foreach ($customPropertiesForShares as $customPropertyForShares) {
176
-				if (in_array($customPropertyForShares, $allRequestedProps)) {
177
-					$requestedProps[] = $customPropertyForShares;
178
-				}
179
-			}
180
-		}
181
-
182
-		// substr of addressbooks/ => path is inside the CardDAV component
183
-		// three '/' => this a addressbook (no addressbook-home nor contact object)
184
-		if (str_starts_with($path, 'addressbooks/') && substr_count($path, '/') === 3) {
185
-			$allRequestedProps = $propFind->getRequestedProperties();
186
-			$customPropertiesForShares = [
187
-				'{DAV:}displayname',
188
-			];
189
-
190
-			foreach ($customPropertiesForShares as $customPropertyForShares) {
191
-				if (in_array($customPropertyForShares, $allRequestedProps, true)) {
192
-					$requestedProps[] = $customPropertyForShares;
193
-				}
194
-			}
195
-		}
196
-
197
-		// substr of principals/users/ => path is a user principal
198
-		// two '/' => this a principal collection (and not some child object)
199
-		if (str_starts_with($path, 'principals/users/') && substr_count($path, '/') === 2) {
200
-			$allRequestedProps = $propFind->getRequestedProperties();
201
-			$customProperties = [
202
-				'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
203
-			];
204
-
205
-			foreach ($customProperties as $customProperty) {
206
-				if (in_array($customProperty, $allRequestedProps, true)) {
207
-					$requestedProps[] = $customProperty;
208
-				}
209
-			}
210
-		}
211
-
212
-		if (empty($requestedProps)) {
213
-			return;
214
-		}
215
-
216
-		$node = $this->tree->getNodeForPath($path);
217
-		if ($node instanceof Directory && $propFind->getDepth() !== 0) {
218
-			$this->cacheDirectory($path, $node);
219
-		}
220
-
221
-		// First fetch the published properties (set by another user), then get the ones set by
222
-		// the current user. If both are set then the latter as priority.
223
-		foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
224
-			try {
225
-				$this->validateProperty($path, $propName, $propValue);
226
-			} catch (DavException $e) {
227
-				continue;
228
-			}
229
-			$propFind->set($propName, $propValue);
230
-		}
231
-		foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) {
232
-			try {
233
-				$this->validateProperty($path, $propName, $propValue);
234
-			} catch (DavException $e) {
235
-				continue;
236
-			}
237
-			$propFind->set($propName, $propValue);
238
-		}
239
-	}
240
-
241
-	/**
242
-	 * Updates properties for a path
243
-	 *
244
-	 * @param string $path
245
-	 * @param PropPatch $propPatch
246
-	 *
247
-	 * @return void
248
-	 */
249
-	public function propPatch($path, PropPatch $propPatch) {
250
-		$propPatch->handleRemaining(function ($changedProps) use ($path) {
251
-			return $this->updateProperties($path, $changedProps);
252
-		});
253
-	}
254
-
255
-	/**
256
-	 * This method is called after a node is deleted.
257
-	 *
258
-	 * @param string $path path of node for which to delete properties
259
-	 */
260
-	public function delete($path) {
261
-		$statement = $this->connection->prepare(
262
-			'DELETE FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?'
263
-		);
264
-		$statement->execute([$this->user->getUID(), $this->formatPath($path)]);
265
-		$statement->closeCursor();
266
-
267
-		unset($this->userCache[$path]);
268
-	}
269
-
270
-	/**
271
-	 * This method is called after a successful MOVE
272
-	 *
273
-	 * @param string $source
274
-	 * @param string $destination
275
-	 *
276
-	 * @return void
277
-	 */
278
-	public function move($source, $destination) {
279
-		$statement = $this->connection->prepare(
280
-			'UPDATE `*PREFIX*properties` SET `propertypath` = ?' .
281
-			' WHERE `userid` = ? AND `propertypath` = ?'
282
-		);
283
-		$statement->execute([$this->formatPath($destination), $this->user->getUID(), $this->formatPath($source)]);
284
-		$statement->closeCursor();
285
-	}
286
-
287
-	/**
288
-	 * Validate the value of a property. Will throw if a value is invalid.
289
-	 *
290
-	 * @throws DavException The value of the property is invalid
291
-	 */
292
-	private function validateProperty(string $path, string $propName, mixed $propValue): void {
293
-		switch ($propName) {
294
-			case '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL':
295
-				/** @var Href $propValue */
296
-				$href = $propValue->getHref();
297
-				if ($href === null) {
298
-					throw new DavException('Href is empty');
299
-				}
300
-
301
-				// $path is the principal here as this prop is only set on principals
302
-				$node = $this->tree->getNodeForPath($href);
303
-				if (!($node instanceof Calendar) || $node->getOwner() !== $path) {
304
-					throw new DavException('No such calendar');
305
-				}
306
-
307
-				$this->defaultCalendarValidator->validateScheduleDefaultCalendar($node);
308
-				break;
309
-		}
310
-	}
311
-
312
-	/**
313
-	 * @param string $path
314
-	 * @param string[] $requestedProperties
315
-	 *
316
-	 * @return array
317
-	 */
318
-	private function getPublishedProperties(string $path, array $requestedProperties): array {
319
-		$allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
320
-
321
-		if (empty($allowedProps)) {
322
-			return [];
323
-		}
324
-
325
-		$qb = $this->connection->getQueryBuilder();
326
-		$qb->select('*')
327
-			->from(self::TABLE_NAME)
328
-			->where($qb->expr()->eq('propertypath', $qb->createNamedParameter($path)));
329
-		$result = $qb->executeQuery();
330
-		$props = [];
331
-		while ($row = $result->fetch()) {
332
-			$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
333
-		}
334
-		$result->closeCursor();
335
-		return $props;
336
-	}
337
-
338
-	/**
339
-	 * prefetch all user properties in a directory
340
-	 */
341
-	private function cacheDirectory(string $path, Directory $node): void {
342
-		$prefix = ltrim($path . '/', '/');
343
-		$query = $this->connection->getQueryBuilder();
344
-		$query->select('name', 'p.propertypath', 'p.propertyname', 'p.propertyvalue', 'p.valuetype')
345
-			->from('filecache', 'f')
346
-			->hintShardKey('storage', $node->getNode()->getMountPoint()->getNumericStorageId())
347
-			->leftJoin('f', 'properties', 'p', $query->expr()->eq('p.propertypath', $query->func()->concat(
348
-				$query->createNamedParameter($prefix),
349
-				'f.name'
350
-			)),
351
-			)
352
-			->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT)))
353
-			->andWhere($query->expr()->orX(
354
-				$query->expr()->eq('p.userid', $query->createNamedParameter($this->user->getUID())),
355
-				$query->expr()->isNull('p.userid'),
356
-			));
357
-		$result = $query->executeQuery();
358
-
359
-		$propsByPath = [];
360
-
361
-		while ($row = $result->fetch()) {
362
-			$childPath = $prefix . $row['name'];
363
-			if (!isset($propsByPath[$childPath])) {
364
-				$propsByPath[$childPath] = [];
365
-			}
366
-			if (isset($row['propertyname'])) {
367
-				$propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
368
-			}
369
-		}
370
-		$this->userCache = array_merge($this->userCache, $propsByPath);
371
-	}
372
-
373
-	/**
374
-	 * Returns a list of properties for the given path and current user
375
-	 *
376
-	 * @param string $path
377
-	 * @param array $requestedProperties requested properties or empty array for "all"
378
-	 * @return array
379
-	 * @note The properties list is a list of propertynames the client
380
-	 * requested, encoded as xmlnamespace#tagName, for example:
381
-	 * http://www.example.org/namespace#author If the array is empty, all
382
-	 * properties should be returned
383
-	 */
384
-	private function getUserProperties(string $path, array $requestedProperties) {
385
-		if (isset($this->userCache[$path])) {
386
-			return $this->userCache[$path];
387
-		}
388
-
389
-		// TODO: chunking if more than 1000 properties
390
-		$sql = 'SELECT * FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?';
391
-
392
-		$whereValues = [$this->user->getUID(), $this->formatPath($path)];
393
-		$whereTypes = [null, null];
394
-
395
-		if (!empty($requestedProperties)) {
396
-			// request only a subset
397
-			$sql .= ' AND `propertyname` in (?)';
398
-			$whereValues[] = $requestedProperties;
399
-			$whereTypes[] = IQueryBuilder::PARAM_STR_ARRAY;
400
-		}
401
-
402
-		$result = $this->connection->executeQuery(
403
-			$sql,
404
-			$whereValues,
405
-			$whereTypes
406
-		);
407
-
408
-		$props = [];
409
-		while ($row = $result->fetch()) {
410
-			$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
411
-		}
412
-
413
-		$result->closeCursor();
414
-
415
-		$this->userCache[$path] = $props;
416
-		return $props;
417
-	}
418
-
419
-	/**
420
-	 * @throws Exception
421
-	 */
422
-	private function updateProperties(string $path, array $properties): bool {
423
-		// TODO: use "insert or update" strategy ?
424
-		$existing = $this->getUserProperties($path, []);
425
-		try {
426
-			$this->connection->beginTransaction();
427
-			foreach ($properties as $propertyName => $propertyValue) {
428
-				// common parameters for all queries
429
-				$dbParameters = [
430
-					'userid' => $this->user->getUID(),
431
-					'propertyPath' => $this->formatPath($path),
432
-					'propertyName' => $propertyName,
433
-				];
434
-
435
-				// If it was null, we need to delete the property
436
-				if (is_null($propertyValue)) {
437
-					if (array_key_exists($propertyName, $existing)) {
438
-						$deleteQuery = $deleteQuery ?? $this->createDeleteQuery();
439
-						$deleteQuery
440
-							->setParameters($dbParameters)
441
-							->executeStatement();
442
-					}
443
-				} else {
444
-					[$value, $valueType] = $this->encodeValueForDatabase(
445
-						$path,
446
-						$propertyName,
447
-						$propertyValue,
448
-					);
449
-					$dbParameters['propertyValue'] = $value;
450
-					$dbParameters['valueType'] = $valueType;
451
-
452
-					if (!array_key_exists($propertyName, $existing)) {
453
-						$insertQuery = $insertQuery ?? $this->createInsertQuery();
454
-						$insertQuery
455
-							->setParameters($dbParameters)
456
-							->executeStatement();
457
-					} else {
458
-						$updateQuery = $updateQuery ?? $this->createUpdateQuery();
459
-						$updateQuery
460
-							->setParameters($dbParameters)
461
-							->executeStatement();
462
-					}
463
-				}
464
-			}
465
-
466
-			$this->connection->commit();
467
-			unset($this->userCache[$path]);
468
-		} catch (Exception $e) {
469
-			$this->connection->rollBack();
470
-			throw $e;
471
-		}
472
-
473
-		return true;
474
-	}
475
-
476
-	/**
477
-	 * long paths are hashed to ensure they fit in the database
478
-	 *
479
-	 * @param string $path
480
-	 * @return string
481
-	 */
482
-	private function formatPath(string $path): string {
483
-		if (strlen($path) > 250) {
484
-			return sha1($path);
485
-		}
486
-
487
-		return $path;
488
-	}
489
-
490
-	/**
491
-	 * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails
492
-	 * @throws DavException If the property value is invalid
493
-	 */
494
-	private function encodeValueForDatabase(string $path, string $name, mixed $value): array {
495
-		// Try to parse a more specialized property type first
496
-		if ($value instanceof Complex) {
497
-			$xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri());
498
-			$value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value;
499
-		}
500
-
501
-		if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') {
502
-			$value = $this->encodeDefaultCalendarUrl($value);
503
-		}
504
-
505
-		try {
506
-			$this->validateProperty($path, $name, $value);
507
-		} catch (DavException $e) {
508
-			throw new DavException(
509
-				"Property \"$name\" has an invalid value: " . $e->getMessage(),
510
-				0,
511
-				$e,
512
-			);
513
-		}
514
-
515
-		if (is_scalar($value)) {
516
-			$valueType = self::PROPERTY_TYPE_STRING;
517
-		} elseif ($value instanceof Complex) {
518
-			$valueType = self::PROPERTY_TYPE_XML;
519
-			$value = $value->getXml();
520
-		} elseif ($value instanceof Href) {
521
-			$valueType = self::PROPERTY_TYPE_HREF;
522
-			$value = $value->getHref();
523
-		} else {
524
-			$valueType = self::PROPERTY_TYPE_OBJECT;
525
-			// serialize produces null character
526
-			// these can not be properly stored in some databases and need to be replaced
527
-			$value = str_replace(chr(0), '\x00', serialize($value));
528
-		}
529
-		return [$value, $valueType];
530
-	}
531
-
532
-	/**
533
-	 * @return mixed|Complex|string
534
-	 */
535
-	private function decodeValueFromDatabase(string $value, int $valueType) {
536
-		switch ($valueType) {
537
-			case self::PROPERTY_TYPE_XML:
538
-				return new Complex($value);
539
-			case self::PROPERTY_TYPE_HREF:
540
-				return new Href($value);
541
-			case self::PROPERTY_TYPE_OBJECT:
542
-				// some databases can not handel null characters, these are custom encoded during serialization
543
-				// this custom encoding needs to be first reversed before unserializing
544
-				return unserialize(str_replace('\x00', chr(0), $value));
545
-			case self::PROPERTY_TYPE_STRING:
546
-			default:
547
-				return $value;
548
-		}
549
-	}
550
-
551
-	private function encodeDefaultCalendarUrl(Href $value): Href {
552
-		$href = $value->getHref();
553
-		if ($href === null) {
554
-			return $value;
555
-		}
556
-
557
-		if (!str_starts_with($href, '/')) {
558
-			return $value;
559
-		}
560
-
561
-		try {
562
-			// Build path relative to the dav base URI to be used later to find the node
563
-			$value = new LocalHref($this->server->calculateUri($href) . '/');
564
-		} catch (DavException\Forbidden) {
565
-			// Not existing calendars will be handled later when the value is validated
566
-		}
567
-
568
-		return $value;
569
-	}
570
-
571
-	private function createDeleteQuery(): IQueryBuilder {
572
-		$deleteQuery = $this->connection->getQueryBuilder();
573
-		$deleteQuery->delete('properties')
574
-			->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
575
-			->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
576
-			->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
577
-		return $deleteQuery;
578
-	}
579
-
580
-	private function createInsertQuery(): IQueryBuilder {
581
-		$insertQuery = $this->connection->getQueryBuilder();
582
-		$insertQuery->insert('properties')
583
-			->values([
584
-				'userid' => $insertQuery->createParameter('userid'),
585
-				'propertypath' => $insertQuery->createParameter('propertyPath'),
586
-				'propertyname' => $insertQuery->createParameter('propertyName'),
587
-				'propertyvalue' => $insertQuery->createParameter('propertyValue'),
588
-				'valuetype' => $insertQuery->createParameter('valueType'),
589
-			]);
590
-		return $insertQuery;
591
-	}
592
-
593
-	private function createUpdateQuery(): IQueryBuilder {
594
-		$updateQuery = $this->connection->getQueryBuilder();
595
-		$updateQuery->update('properties')
596
-			->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
597
-			->set('valuetype', $updateQuery->createParameter('valueType'))
598
-			->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
599
-			->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
600
-			->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
601
-		return $updateQuery;
602
-	}
34
+    /** @var string */
35
+    private const TABLE_NAME = 'properties';
36
+
37
+    /**
38
+     * Value is stored as string.
39
+     */
40
+    public const PROPERTY_TYPE_STRING = 1;
41
+
42
+    /**
43
+     * Value is stored as XML fragment.
44
+     */
45
+    public const PROPERTY_TYPE_XML = 2;
46
+
47
+    /**
48
+     * Value is stored as a property object.
49
+     */
50
+    public const PROPERTY_TYPE_OBJECT = 3;
51
+
52
+    /**
53
+     * Value is stored as a {DAV:}href string.
54
+     */
55
+    public const PROPERTY_TYPE_HREF = 4;
56
+
57
+    /**
58
+     * Ignored properties
59
+     *
60
+     * @var string[]
61
+     */
62
+    private const IGNORED_PROPERTIES = [
63
+        '{DAV:}getcontentlength',
64
+        '{DAV:}getcontenttype',
65
+        '{DAV:}getetag',
66
+        '{DAV:}quota-used-bytes',
67
+        '{DAV:}quota-available-bytes',
68
+        '{http://owncloud.org/ns}permissions',
69
+        '{http://owncloud.org/ns}downloadURL',
70
+        '{http://owncloud.org/ns}dDC',
71
+        '{http://owncloud.org/ns}size',
72
+        '{http://nextcloud.org/ns}is-encrypted',
73
+
74
+        // Currently, returning null from any propfind handler would still trigger the backend,
75
+        // so we add all known Nextcloud custom properties in here to avoid that
76
+
77
+        // text app
78
+        '{http://nextcloud.org/ns}rich-workspace',
79
+        '{http://nextcloud.org/ns}rich-workspace-file',
80
+        // groupfolders
81
+        '{http://nextcloud.org/ns}acl-enabled',
82
+        '{http://nextcloud.org/ns}acl-can-manage',
83
+        '{http://nextcloud.org/ns}acl-list',
84
+        '{http://nextcloud.org/ns}inherited-acl-list',
85
+        '{http://nextcloud.org/ns}group-folder-id',
86
+        // files_lock
87
+        '{http://nextcloud.org/ns}lock',
88
+        '{http://nextcloud.org/ns}lock-owner-type',
89
+        '{http://nextcloud.org/ns}lock-owner',
90
+        '{http://nextcloud.org/ns}lock-owner-displayname',
91
+        '{http://nextcloud.org/ns}lock-owner-editor',
92
+        '{http://nextcloud.org/ns}lock-time',
93
+        '{http://nextcloud.org/ns}lock-timeout',
94
+        '{http://nextcloud.org/ns}lock-token',
95
+    ];
96
+
97
+    /**
98
+     * Properties set by one user, readable by all others
99
+     *
100
+     * @var string[]
101
+     */
102
+    private const PUBLISHED_READ_ONLY_PROPERTIES = [
103
+        '{urn:ietf:params:xml:ns:caldav}calendar-availability',
104
+        '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
105
+    ];
106
+
107
+    /**
108
+     * Map of custom XML elements to parse when trying to deserialize an instance of
109
+     * \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_*
110
+     */
111
+    private const COMPLEX_XML_ELEMENT_MAP = [
112
+        '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class,
113
+    ];
114
+
115
+    /**
116
+     * Properties cache
117
+     *
118
+     * @var array
119
+     */
120
+    private $userCache = [];
121
+    private XmlService $xmlService;
122
+
123
+    /**
124
+     * @param Tree $tree node tree
125
+     * @param IDBConnection $connection database connection
126
+     * @param IUser $user owner of the tree and properties
127
+     */
128
+    public function __construct(
129
+        private Server $server,
130
+        private Tree $tree,
131
+        private IDBConnection $connection,
132
+        private IUser $user,
133
+        private DefaultCalendarValidator $defaultCalendarValidator,
134
+    ) {
135
+        $this->xmlService = new XmlService();
136
+        $this->xmlService->elementMap = array_merge(
137
+            $this->xmlService->elementMap,
138
+            self::COMPLEX_XML_ELEMENT_MAP,
139
+        );
140
+    }
141
+
142
+    /**
143
+     * Fetches properties for a path.
144
+     *
145
+     * @param string $path
146
+     * @param PropFind $propFind
147
+     * @return void
148
+     */
149
+    public function propFind($path, PropFind $propFind) {
150
+        $requestedProps = $propFind->get404Properties();
151
+
152
+        // these might appear
153
+        $requestedProps = array_diff(
154
+            $requestedProps,
155
+            self::IGNORED_PROPERTIES,
156
+        );
157
+        $requestedProps = array_filter(
158
+            $requestedProps,
159
+            fn ($prop) => !str_starts_with($prop, FilesPlugin::FILE_METADATA_PREFIX),
160
+        );
161
+
162
+        // substr of calendars/ => path is inside the CalDAV component
163
+        // two '/' => this a calendar (no calendar-home nor calendar object)
164
+        if (str_starts_with($path, 'calendars/') && substr_count($path, '/') === 2) {
165
+            $allRequestedProps = $propFind->getRequestedProperties();
166
+            $customPropertiesForShares = [
167
+                '{DAV:}displayname',
168
+                '{urn:ietf:params:xml:ns:caldav}calendar-description',
169
+                '{urn:ietf:params:xml:ns:caldav}calendar-timezone',
170
+                '{http://apple.com/ns/ical/}calendar-order',
171
+                '{http://apple.com/ns/ical/}calendar-color',
172
+                '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp',
173
+            ];
174
+
175
+            foreach ($customPropertiesForShares as $customPropertyForShares) {
176
+                if (in_array($customPropertyForShares, $allRequestedProps)) {
177
+                    $requestedProps[] = $customPropertyForShares;
178
+                }
179
+            }
180
+        }
181
+
182
+        // substr of addressbooks/ => path is inside the CardDAV component
183
+        // three '/' => this a addressbook (no addressbook-home nor contact object)
184
+        if (str_starts_with($path, 'addressbooks/') && substr_count($path, '/') === 3) {
185
+            $allRequestedProps = $propFind->getRequestedProperties();
186
+            $customPropertiesForShares = [
187
+                '{DAV:}displayname',
188
+            ];
189
+
190
+            foreach ($customPropertiesForShares as $customPropertyForShares) {
191
+                if (in_array($customPropertyForShares, $allRequestedProps, true)) {
192
+                    $requestedProps[] = $customPropertyForShares;
193
+                }
194
+            }
195
+        }
196
+
197
+        // substr of principals/users/ => path is a user principal
198
+        // two '/' => this a principal collection (and not some child object)
199
+        if (str_starts_with($path, 'principals/users/') && substr_count($path, '/') === 2) {
200
+            $allRequestedProps = $propFind->getRequestedProperties();
201
+            $customProperties = [
202
+                '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
203
+            ];
204
+
205
+            foreach ($customProperties as $customProperty) {
206
+                if (in_array($customProperty, $allRequestedProps, true)) {
207
+                    $requestedProps[] = $customProperty;
208
+                }
209
+            }
210
+        }
211
+
212
+        if (empty($requestedProps)) {
213
+            return;
214
+        }
215
+
216
+        $node = $this->tree->getNodeForPath($path);
217
+        if ($node instanceof Directory && $propFind->getDepth() !== 0) {
218
+            $this->cacheDirectory($path, $node);
219
+        }
220
+
221
+        // First fetch the published properties (set by another user), then get the ones set by
222
+        // the current user. If both are set then the latter as priority.
223
+        foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
224
+            try {
225
+                $this->validateProperty($path, $propName, $propValue);
226
+            } catch (DavException $e) {
227
+                continue;
228
+            }
229
+            $propFind->set($propName, $propValue);
230
+        }
231
+        foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) {
232
+            try {
233
+                $this->validateProperty($path, $propName, $propValue);
234
+            } catch (DavException $e) {
235
+                continue;
236
+            }
237
+            $propFind->set($propName, $propValue);
238
+        }
239
+    }
240
+
241
+    /**
242
+     * Updates properties for a path
243
+     *
244
+     * @param string $path
245
+     * @param PropPatch $propPatch
246
+     *
247
+     * @return void
248
+     */
249
+    public function propPatch($path, PropPatch $propPatch) {
250
+        $propPatch->handleRemaining(function ($changedProps) use ($path) {
251
+            return $this->updateProperties($path, $changedProps);
252
+        });
253
+    }
254
+
255
+    /**
256
+     * This method is called after a node is deleted.
257
+     *
258
+     * @param string $path path of node for which to delete properties
259
+     */
260
+    public function delete($path) {
261
+        $statement = $this->connection->prepare(
262
+            'DELETE FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?'
263
+        );
264
+        $statement->execute([$this->user->getUID(), $this->formatPath($path)]);
265
+        $statement->closeCursor();
266
+
267
+        unset($this->userCache[$path]);
268
+    }
269
+
270
+    /**
271
+     * This method is called after a successful MOVE
272
+     *
273
+     * @param string $source
274
+     * @param string $destination
275
+     *
276
+     * @return void
277
+     */
278
+    public function move($source, $destination) {
279
+        $statement = $this->connection->prepare(
280
+            'UPDATE `*PREFIX*properties` SET `propertypath` = ?' .
281
+            ' WHERE `userid` = ? AND `propertypath` = ?'
282
+        );
283
+        $statement->execute([$this->formatPath($destination), $this->user->getUID(), $this->formatPath($source)]);
284
+        $statement->closeCursor();
285
+    }
286
+
287
+    /**
288
+     * Validate the value of a property. Will throw if a value is invalid.
289
+     *
290
+     * @throws DavException The value of the property is invalid
291
+     */
292
+    private function validateProperty(string $path, string $propName, mixed $propValue): void {
293
+        switch ($propName) {
294
+            case '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL':
295
+                /** @var Href $propValue */
296
+                $href = $propValue->getHref();
297
+                if ($href === null) {
298
+                    throw new DavException('Href is empty');
299
+                }
300
+
301
+                // $path is the principal here as this prop is only set on principals
302
+                $node = $this->tree->getNodeForPath($href);
303
+                if (!($node instanceof Calendar) || $node->getOwner() !== $path) {
304
+                    throw new DavException('No such calendar');
305
+                }
306
+
307
+                $this->defaultCalendarValidator->validateScheduleDefaultCalendar($node);
308
+                break;
309
+        }
310
+    }
311
+
312
+    /**
313
+     * @param string $path
314
+     * @param string[] $requestedProperties
315
+     *
316
+     * @return array
317
+     */
318
+    private function getPublishedProperties(string $path, array $requestedProperties): array {
319
+        $allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
320
+
321
+        if (empty($allowedProps)) {
322
+            return [];
323
+        }
324
+
325
+        $qb = $this->connection->getQueryBuilder();
326
+        $qb->select('*')
327
+            ->from(self::TABLE_NAME)
328
+            ->where($qb->expr()->eq('propertypath', $qb->createNamedParameter($path)));
329
+        $result = $qb->executeQuery();
330
+        $props = [];
331
+        while ($row = $result->fetch()) {
332
+            $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
333
+        }
334
+        $result->closeCursor();
335
+        return $props;
336
+    }
337
+
338
+    /**
339
+     * prefetch all user properties in a directory
340
+     */
341
+    private function cacheDirectory(string $path, Directory $node): void {
342
+        $prefix = ltrim($path . '/', '/');
343
+        $query = $this->connection->getQueryBuilder();
344
+        $query->select('name', 'p.propertypath', 'p.propertyname', 'p.propertyvalue', 'p.valuetype')
345
+            ->from('filecache', 'f')
346
+            ->hintShardKey('storage', $node->getNode()->getMountPoint()->getNumericStorageId())
347
+            ->leftJoin('f', 'properties', 'p', $query->expr()->eq('p.propertypath', $query->func()->concat(
348
+                $query->createNamedParameter($prefix),
349
+                'f.name'
350
+            )),
351
+            )
352
+            ->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT)))
353
+            ->andWhere($query->expr()->orX(
354
+                $query->expr()->eq('p.userid', $query->createNamedParameter($this->user->getUID())),
355
+                $query->expr()->isNull('p.userid'),
356
+            ));
357
+        $result = $query->executeQuery();
358
+
359
+        $propsByPath = [];
360
+
361
+        while ($row = $result->fetch()) {
362
+            $childPath = $prefix . $row['name'];
363
+            if (!isset($propsByPath[$childPath])) {
364
+                $propsByPath[$childPath] = [];
365
+            }
366
+            if (isset($row['propertyname'])) {
367
+                $propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
368
+            }
369
+        }
370
+        $this->userCache = array_merge($this->userCache, $propsByPath);
371
+    }
372
+
373
+    /**
374
+     * Returns a list of properties for the given path and current user
375
+     *
376
+     * @param string $path
377
+     * @param array $requestedProperties requested properties or empty array for "all"
378
+     * @return array
379
+     * @note The properties list is a list of propertynames the client
380
+     * requested, encoded as xmlnamespace#tagName, for example:
381
+     * http://www.example.org/namespace#author If the array is empty, all
382
+     * properties should be returned
383
+     */
384
+    private function getUserProperties(string $path, array $requestedProperties) {
385
+        if (isset($this->userCache[$path])) {
386
+            return $this->userCache[$path];
387
+        }
388
+
389
+        // TODO: chunking if more than 1000 properties
390
+        $sql = 'SELECT * FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?';
391
+
392
+        $whereValues = [$this->user->getUID(), $this->formatPath($path)];
393
+        $whereTypes = [null, null];
394
+
395
+        if (!empty($requestedProperties)) {
396
+            // request only a subset
397
+            $sql .= ' AND `propertyname` in (?)';
398
+            $whereValues[] = $requestedProperties;
399
+            $whereTypes[] = IQueryBuilder::PARAM_STR_ARRAY;
400
+        }
401
+
402
+        $result = $this->connection->executeQuery(
403
+            $sql,
404
+            $whereValues,
405
+            $whereTypes
406
+        );
407
+
408
+        $props = [];
409
+        while ($row = $result->fetch()) {
410
+            $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
411
+        }
412
+
413
+        $result->closeCursor();
414
+
415
+        $this->userCache[$path] = $props;
416
+        return $props;
417
+    }
418
+
419
+    /**
420
+     * @throws Exception
421
+     */
422
+    private function updateProperties(string $path, array $properties): bool {
423
+        // TODO: use "insert or update" strategy ?
424
+        $existing = $this->getUserProperties($path, []);
425
+        try {
426
+            $this->connection->beginTransaction();
427
+            foreach ($properties as $propertyName => $propertyValue) {
428
+                // common parameters for all queries
429
+                $dbParameters = [
430
+                    'userid' => $this->user->getUID(),
431
+                    'propertyPath' => $this->formatPath($path),
432
+                    'propertyName' => $propertyName,
433
+                ];
434
+
435
+                // If it was null, we need to delete the property
436
+                if (is_null($propertyValue)) {
437
+                    if (array_key_exists($propertyName, $existing)) {
438
+                        $deleteQuery = $deleteQuery ?? $this->createDeleteQuery();
439
+                        $deleteQuery
440
+                            ->setParameters($dbParameters)
441
+                            ->executeStatement();
442
+                    }
443
+                } else {
444
+                    [$value, $valueType] = $this->encodeValueForDatabase(
445
+                        $path,
446
+                        $propertyName,
447
+                        $propertyValue,
448
+                    );
449
+                    $dbParameters['propertyValue'] = $value;
450
+                    $dbParameters['valueType'] = $valueType;
451
+
452
+                    if (!array_key_exists($propertyName, $existing)) {
453
+                        $insertQuery = $insertQuery ?? $this->createInsertQuery();
454
+                        $insertQuery
455
+                            ->setParameters($dbParameters)
456
+                            ->executeStatement();
457
+                    } else {
458
+                        $updateQuery = $updateQuery ?? $this->createUpdateQuery();
459
+                        $updateQuery
460
+                            ->setParameters($dbParameters)
461
+                            ->executeStatement();
462
+                    }
463
+                }
464
+            }
465
+
466
+            $this->connection->commit();
467
+            unset($this->userCache[$path]);
468
+        } catch (Exception $e) {
469
+            $this->connection->rollBack();
470
+            throw $e;
471
+        }
472
+
473
+        return true;
474
+    }
475
+
476
+    /**
477
+     * long paths are hashed to ensure they fit in the database
478
+     *
479
+     * @param string $path
480
+     * @return string
481
+     */
482
+    private function formatPath(string $path): string {
483
+        if (strlen($path) > 250) {
484
+            return sha1($path);
485
+        }
486
+
487
+        return $path;
488
+    }
489
+
490
+    /**
491
+     * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails
492
+     * @throws DavException If the property value is invalid
493
+     */
494
+    private function encodeValueForDatabase(string $path, string $name, mixed $value): array {
495
+        // Try to parse a more specialized property type first
496
+        if ($value instanceof Complex) {
497
+            $xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri());
498
+            $value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value;
499
+        }
500
+
501
+        if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') {
502
+            $value = $this->encodeDefaultCalendarUrl($value);
503
+        }
504
+
505
+        try {
506
+            $this->validateProperty($path, $name, $value);
507
+        } catch (DavException $e) {
508
+            throw new DavException(
509
+                "Property \"$name\" has an invalid value: " . $e->getMessage(),
510
+                0,
511
+                $e,
512
+            );
513
+        }
514
+
515
+        if (is_scalar($value)) {
516
+            $valueType = self::PROPERTY_TYPE_STRING;
517
+        } elseif ($value instanceof Complex) {
518
+            $valueType = self::PROPERTY_TYPE_XML;
519
+            $value = $value->getXml();
520
+        } elseif ($value instanceof Href) {
521
+            $valueType = self::PROPERTY_TYPE_HREF;
522
+            $value = $value->getHref();
523
+        } else {
524
+            $valueType = self::PROPERTY_TYPE_OBJECT;
525
+            // serialize produces null character
526
+            // these can not be properly stored in some databases and need to be replaced
527
+            $value = str_replace(chr(0), '\x00', serialize($value));
528
+        }
529
+        return [$value, $valueType];
530
+    }
531
+
532
+    /**
533
+     * @return mixed|Complex|string
534
+     */
535
+    private function decodeValueFromDatabase(string $value, int $valueType) {
536
+        switch ($valueType) {
537
+            case self::PROPERTY_TYPE_XML:
538
+                return new Complex($value);
539
+            case self::PROPERTY_TYPE_HREF:
540
+                return new Href($value);
541
+            case self::PROPERTY_TYPE_OBJECT:
542
+                // some databases can not handel null characters, these are custom encoded during serialization
543
+                // this custom encoding needs to be first reversed before unserializing
544
+                return unserialize(str_replace('\x00', chr(0), $value));
545
+            case self::PROPERTY_TYPE_STRING:
546
+            default:
547
+                return $value;
548
+        }
549
+    }
550
+
551
+    private function encodeDefaultCalendarUrl(Href $value): Href {
552
+        $href = $value->getHref();
553
+        if ($href === null) {
554
+            return $value;
555
+        }
556
+
557
+        if (!str_starts_with($href, '/')) {
558
+            return $value;
559
+        }
560
+
561
+        try {
562
+            // Build path relative to the dav base URI to be used later to find the node
563
+            $value = new LocalHref($this->server->calculateUri($href) . '/');
564
+        } catch (DavException\Forbidden) {
565
+            // Not existing calendars will be handled later when the value is validated
566
+        }
567
+
568
+        return $value;
569
+    }
570
+
571
+    private function createDeleteQuery(): IQueryBuilder {
572
+        $deleteQuery = $this->connection->getQueryBuilder();
573
+        $deleteQuery->delete('properties')
574
+            ->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
575
+            ->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
576
+            ->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
577
+        return $deleteQuery;
578
+    }
579
+
580
+    private function createInsertQuery(): IQueryBuilder {
581
+        $insertQuery = $this->connection->getQueryBuilder();
582
+        $insertQuery->insert('properties')
583
+            ->values([
584
+                'userid' => $insertQuery->createParameter('userid'),
585
+                'propertypath' => $insertQuery->createParameter('propertyPath'),
586
+                'propertyname' => $insertQuery->createParameter('propertyName'),
587
+                'propertyvalue' => $insertQuery->createParameter('propertyValue'),
588
+                'valuetype' => $insertQuery->createParameter('valueType'),
589
+            ]);
590
+        return $insertQuery;
591
+    }
592
+
593
+    private function createUpdateQuery(): IQueryBuilder {
594
+        $updateQuery = $this->connection->getQueryBuilder();
595
+        $updateQuery->update('properties')
596
+            ->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
597
+            ->set('valuetype', $updateQuery->createParameter('valueType'))
598
+            ->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
599
+            ->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
600
+            ->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
601
+        return $updateQuery;
602
+    }
603 603
 }
Please login to merge, or discard this patch.