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