Completed
Push — master ( 1686bb...35f9d6 )
by
unknown
24:12
created
apps/dav/lib/DAV/CustomPropertiesBackend.php 2 patches
Indentation   +674 added lines, -674 removed lines patch added patch discarded remove patch
@@ -40,678 +40,678 @@
 block discarded – undo
40 40
 
41 41
 class CustomPropertiesBackend implements BackendInterface {
42 42
 
43
-	/** @var string */
44
-	private const TABLE_NAME = 'properties';
45
-
46
-	/**
47
-	 * Value is stored as string.
48
-	 */
49
-	public const PROPERTY_TYPE_STRING = 1;
50
-
51
-	/**
52
-	 * Value is stored as XML fragment.
53
-	 */
54
-	public const PROPERTY_TYPE_XML = 2;
55
-
56
-	/**
57
-	 * Value is stored as a property object.
58
-	 */
59
-	public const PROPERTY_TYPE_OBJECT = 3;
60
-
61
-	/**
62
-	 * Value is stored as a {DAV:}href string.
63
-	 */
64
-	public const PROPERTY_TYPE_HREF = 4;
65
-
66
-	/**
67
-	 * Ignored properties
68
-	 *
69
-	 * @var string[]
70
-	 */
71
-	private const IGNORED_PROPERTIES = [
72
-		'{DAV:}getcontentlength',
73
-		'{DAV:}getcontenttype',
74
-		'{DAV:}getetag',
75
-		'{DAV:}quota-used-bytes',
76
-		'{DAV:}quota-available-bytes',
77
-	];
78
-
79
-	/**
80
-	 * Allowed properties for the oc/nc namespace, all other properties in the namespace are ignored
81
-	 *
82
-	 * @var string[]
83
-	 */
84
-	private const ALLOWED_NC_PROPERTIES = [
85
-		'{http://owncloud.org/ns}calendar-enabled',
86
-		'{http://owncloud.org/ns}enabled',
87
-	];
88
-
89
-	/**
90
-	 * Properties set by one user, readable by all others
91
-	 *
92
-	 * @var string[]
93
-	 */
94
-	private const PUBLISHED_READ_ONLY_PROPERTIES = [
95
-		'{urn:ietf:params:xml:ns:caldav}calendar-availability',
96
-		'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
97
-	];
98
-
99
-	/**
100
-	 * Map of custom XML elements to parse when trying to deserialize an instance of
101
-	 * \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_*
102
-	 * @var array<string, class-string>
103
-	 */
104
-	private const COMPLEX_XML_ELEMENT_MAP = [
105
-		'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class,
106
-	];
107
-
108
-	/**
109
-	 * Map of well-known property names to default values
110
-	 * @var array<string, string>
111
-	 */
112
-	private const PROPERTY_DEFAULT_VALUES = [
113
-		'{http://owncloud.org/ns}calendar-enabled' => '1',
114
-	];
115
-
116
-	/**
117
-	 * Properties cache
118
-	 */
119
-	private array $userCache = [];
120
-	private array $publishedCache = [];
121
-	private XmlService $xmlService;
122
-
123
-	/**
124
-	 * @param IUser $user owner of the tree and properties
125
-	 */
126
-	public function __construct(
127
-		private readonly Server $server,
128
-		private readonly Tree $tree,
129
-		private readonly IDBConnection $connection,
130
-		private readonly IUser $user,
131
-		private readonly PropertyMapper $propertyMapper,
132
-		private readonly DefaultCalendarValidator $defaultCalendarValidator,
133
-	) {
134
-		$this->xmlService = new XmlService();
135
-		$this->xmlService->elementMap = array_merge(
136
-			$this->xmlService->elementMap,
137
-			self::COMPLEX_XML_ELEMENT_MAP,
138
-		);
139
-	}
140
-
141
-	/**
142
-	 * Fetches properties for a path.
143
-	 *
144
-	 * @param string $path
145
-	 * @param PropFind $propFind
146
-	 */
147
-	#[Override]
148
-	public function propFind($path, PropFind $propFind): void {
149
-		$requestedProps = $propFind->get404Properties();
150
-
151
-		$requestedProps = array_filter(
152
-			$requestedProps,
153
-			$this->isPropertyAllowed(...),
154
-		);
155
-
156
-		// substr of calendars/ => path is inside the CalDAV component
157
-		// two '/' => this a calendar (no calendar-home nor calendar object)
158
-		if (str_starts_with($path, 'calendars/') && substr_count($path, '/') === 2) {
159
-			$allRequestedProps = $propFind->getRequestedProperties();
160
-			$customPropertiesForShares = [
161
-				'{DAV:}displayname',
162
-				'{urn:ietf:params:xml:ns:caldav}calendar-description',
163
-				'{urn:ietf:params:xml:ns:caldav}calendar-timezone',
164
-				'{http://apple.com/ns/ical/}calendar-order',
165
-				'{http://apple.com/ns/ical/}calendar-color',
166
-				'{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp',
167
-			];
168
-
169
-			foreach ($customPropertiesForShares as $customPropertyForShares) {
170
-				if (in_array($customPropertyForShares, $allRequestedProps)) {
171
-					$requestedProps[] = $customPropertyForShares;
172
-				}
173
-			}
174
-		}
175
-
176
-		// substr of addressbooks/ => path is inside the CardDAV component
177
-		// three '/' => this a addressbook (no addressbook-home nor contact object)
178
-		if (str_starts_with($path, 'addressbooks/') && substr_count($path, '/') === 3) {
179
-			$allRequestedProps = $propFind->getRequestedProperties();
180
-			$customPropertiesForShares = [
181
-				'{DAV:}displayname',
182
-			];
183
-
184
-			foreach ($customPropertiesForShares as $customPropertyForShares) {
185
-				if (in_array($customPropertyForShares, $allRequestedProps, true)) {
186
-					$requestedProps[] = $customPropertyForShares;
187
-				}
188
-			}
189
-		}
190
-
191
-		// substr of principals/users/ => path is a user principal
192
-		// two '/' => this a principal collection (and not some child object)
193
-		if (str_starts_with($path, 'principals/users/') && substr_count($path, '/') === 2) {
194
-			$allRequestedProps = $propFind->getRequestedProperties();
195
-			$customProperties = [
196
-				'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
197
-			];
198
-
199
-			foreach ($customProperties as $customProperty) {
200
-				if (in_array($customProperty, $allRequestedProps, true)) {
201
-					$requestedProps[] = $customProperty;
202
-				}
203
-			}
204
-		}
205
-
206
-		if (empty($requestedProps)) {
207
-			return;
208
-		}
209
-
210
-		$node = $this->tree->getNodeForPath($path);
211
-		if ($node instanceof Directory && $propFind->getDepth() !== 0) {
212
-			$this->cacheDirectory($path, $node);
213
-		}
214
-
215
-		if ($node instanceof CalendarHome && $propFind->getDepth() !== 0) {
216
-			$backend = $node->getCalDAVBackend();
217
-			if ($backend instanceof CalDavBackend) {
218
-				$this->cacheCalendars($node, $requestedProps);
219
-			}
220
-		}
221
-
222
-		if ($node instanceof CalendarObject) {
223
-			// No custom properties supported on individual events
224
-			return;
225
-		}
226
-
227
-		// First fetch the published properties (set by another user), then get the ones set by
228
-		// the current user. If both are set then the latter as priority.
229
-		foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
230
-			try {
231
-				$this->validateProperty($path, $propName, $propValue);
232
-			} catch (DavException $e) {
233
-				continue;
234
-			}
235
-			$propFind->set($propName, $propValue);
236
-		}
237
-		foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) {
238
-			try {
239
-				$this->validateProperty($path, $propName, $propValue);
240
-			} catch (DavException $e) {
241
-				continue;
242
-			}
243
-			$propFind->set($propName, $propValue);
244
-		}
245
-	}
246
-
247
-	private function isPropertyAllowed(string $property): bool {
248
-		if (in_array($property, self::IGNORED_PROPERTIES)) {
249
-			return false;
250
-		}
251
-		if (str_starts_with($property, '{http://owncloud.org/ns}') || str_starts_with($property, '{http://nextcloud.org/ns}')) {
252
-			return in_array($property, self::ALLOWED_NC_PROPERTIES);
253
-		}
254
-		return true;
255
-	}
256
-
257
-	/**
258
-	 * Updates properties for a path
259
-	 *
260
-	 * @param string $path
261
-	 */
262
-	#[Override]
263
-	public function propPatch($path, PropPatch $propPatch): void {
264
-		$propPatch->handleRemaining(function (array $changedProps) use ($path) {
265
-			return $this->updateProperties($path, $changedProps);
266
-		});
267
-	}
268
-
269
-	/**
270
-	 * This method is called after a node is deleted.
271
-	 *
272
-	 * @param string $path path of node for which to delete properties
273
-	 */
274
-	#[Override]
275
-	public function delete($path): void {
276
-		$qb = $this->connection->getQueryBuilder();
277
-		$qb->delete('properties')
278
-			->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID())))
279
-			->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($path))));
280
-		$qb->executeStatement();
281
-		unset($this->userCache[$path]);
282
-	}
283
-
284
-	/**
285
-	 * This method is called after a successful MOVE
286
-	 *
287
-	 * @param string $source
288
-	 * @param string $destination
289
-	 */
290
-	#[Override]
291
-	public function move($source, $destination): void {
292
-		$qb = $this->connection->getQueryBuilder();
293
-		$qb->update('properties')
294
-			->set('propertypath', $qb->createNamedParameter($this->formatPath($destination)))
295
-			->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID())))
296
-			->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($source))));
297
-		$qb->executeStatement();
298
-	}
299
-
300
-	/**
301
-	 * Validate the value of a property. Will throw if a value is invalid.
302
-	 *
303
-	 * @throws DavException The value of the property is invalid
304
-	 */
305
-	private function validateProperty(string $path, string $propName, mixed $propValue): void {
306
-		switch ($propName) {
307
-			case '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL':
308
-				/** @var Href $propValue */
309
-				$href = $propValue->getHref();
310
-				if ($href === null) {
311
-					throw new DavException('Href is empty');
312
-				}
313
-
314
-				// $path is the principal here as this prop is only set on principals
315
-				$node = $this->tree->getNodeForPath($href);
316
-				if (!($node instanceof Calendar) || $node->getOwner() !== $path) {
317
-					throw new DavException('No such calendar');
318
-				}
319
-
320
-				$this->defaultCalendarValidator->validateScheduleDefaultCalendar($node);
321
-				break;
322
-		}
323
-	}
324
-
325
-	/**
326
-	 * @param string[] $requestedProperties
327
-	 *
328
-	 * @return array<string, mixed|Complex|Href|string>
329
-	 * @throws \OCP\DB\Exception
330
-	 */
331
-	private function getPublishedProperties(string $path, array $requestedProperties): array {
332
-		$allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
333
-
334
-		if (empty($allowedProps)) {
335
-			return [];
336
-		}
337
-
338
-		if (isset($this->publishedCache[$path])) {
339
-			return $this->publishedCache[$path];
340
-		}
341
-
342
-		$qb = $this->connection->getQueryBuilder();
343
-		$qb->select('*')
344
-			->from(self::TABLE_NAME)
345
-			->where($qb->expr()->eq('propertypath', $qb->createNamedParameter($path)));
346
-		$result = $qb->executeQuery();
347
-		$props = [];
348
-		while ($row = $result->fetch()) {
349
-			$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
350
-		}
351
-		$result->closeCursor();
352
-		$this->publishedCache[$path] = $props;
353
-		return $props;
354
-	}
355
-
356
-	/**
357
-	 * Prefetch all user properties in a directory
358
-	 */
359
-	private function cacheDirectory(string $path, Directory $node): void {
360
-		$prefix = ltrim($path . '/', '/');
361
-		$query = $this->connection->getQueryBuilder();
362
-		$query->select('name', 'p.propertypath', 'p.propertyname', 'p.propertyvalue', 'p.valuetype')
363
-			->from('filecache', 'f')
364
-			->hintShardKey('storage', $node->getNode()->getMountPoint()->getNumericStorageId())
365
-			->leftJoin('f', 'properties', 'p', $query->expr()->eq('p.propertypath', $query->func()->concat(
366
-				$query->createNamedParameter($prefix),
367
-				'f.name'
368
-			)),
369
-			)
370
-			->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT)))
371
-			->andWhere($query->expr()->orX(
372
-				$query->expr()->eq('p.userid', $query->createNamedParameter($this->user->getUID())),
373
-				$query->expr()->isNull('p.userid'),
374
-			));
375
-		$result = $query->executeQuery();
376
-
377
-		$propsByPath = [];
378
-
379
-		while ($row = $result->fetch()) {
380
-			$childPath = $prefix . $row['name'];
381
-			if (!isset($propsByPath[$childPath])) {
382
-				$propsByPath[$childPath] = [];
383
-			}
384
-			if (isset($row['propertyname'])) {
385
-				$propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
386
-			}
387
-		}
388
-		$this->userCache = array_merge($this->userCache, $propsByPath);
389
-	}
390
-
391
-	private function cacheCalendars(CalendarHome $node, array $requestedProperties): void {
392
-		$calendars = $node->getChildren();
393
-
394
-		$users = [];
395
-		foreach ($calendars as $calendar) {
396
-			if ($calendar instanceof Calendar) {
397
-				$user = str_replace('principals/users/', '', $calendar->getPrincipalURI());
398
-				if (!isset($users[$user])) {
399
-					$users[$user] = ['calendars/' . $user];
400
-				}
401
-				$users[$user][] = 'calendars/' . $user . '/' . $calendar->getUri();
402
-			} elseif ($calendar instanceof Inbox || $calendar instanceof Outbox || $calendar instanceof TrashbinHome || $calendar instanceof ExternalCalendar) {
403
-				if ($calendar->getOwner()) {
404
-					$user = str_replace('principals/users/', '', $calendar->getOwner());
405
-					if (!isset($users[$user])) {
406
-						$users[$user] = ['calendars/' . $user];
407
-					}
408
-					$users[$user][] = 'calendars/' . $user . '/' . $calendar->getName();
409
-				}
410
-			}
411
-		}
412
-
413
-		// user properties
414
-		$properties = $this->propertyMapper->findPropertiesByPathsAndUsers($users);
415
-
416
-		$propsByPath = [];
417
-		foreach ($users as $paths) {
418
-			foreach ($paths as $path) {
419
-				$propsByPath[$path] = [];
420
-			}
421
-		}
422
-
423
-		foreach ($properties as $property) {
424
-			$propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype());
425
-		}
426
-		$this->userCache = array_merge($this->userCache, $propsByPath);
427
-
428
-		// published properties
429
-		$allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
430
-		if (empty($allowedProps)) {
431
-			return;
432
-		}
433
-		$paths = [];
434
-		foreach ($users as $nestedPaths) {
435
-			$paths = array_merge($paths, $nestedPaths);
436
-		}
437
-		$paths = array_unique($paths);
438
-
439
-		$propsByPath = array_fill_keys(array_values($paths), []);
440
-		$properties = $this->propertyMapper->findPropertiesByPaths($paths, $allowedProps);
441
-		foreach ($properties as $property) {
442
-			$propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype());
443
-		}
444
-		$this->publishedCache = array_merge($this->publishedCache, $propsByPath);
445
-	}
446
-
447
-	/**
448
-	 * Returns a list of properties for the given path and current user
449
-	 *
450
-	 * @param array $requestedProperties requested properties or empty array for "all"
451
-	 * @return array<string, mixed>
452
-	 * @note The properties list is a list of propertynames the client
453
-	 * requested, encoded as xmlnamespace#tagName, for example:
454
-	 * http://www.example.org/namespace#author If the array is empty, all
455
-	 * properties should be returned
456
-	 */
457
-	private function getUserProperties(string $path, array $requestedProperties): array {
458
-		if (isset($this->userCache[$path])) {
459
-			return $this->userCache[$path];
460
-		}
461
-
462
-		$props = [];
463
-
464
-		$qb = $this->connection->getQueryBuilder();
465
-		$qb->select('*')
466
-			->from('properties')
467
-			->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID(), IQueryBuilder::PARAM_STR)))
468
-			->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($path), IQueryBuilder::PARAM_STR)));
469
-
470
-		if (!empty($requestedProperties)) {
471
-			// request only a subset
472
-			$qb->andWhere($qb->expr()->in('propertyname', $qb->createParameter('requestedProperties')));
473
-			$chunks = array_chunk($requestedProperties, 1000);
474
-			foreach ($chunks as $chunk) {
475
-				$qb->setParameter('requestedProperties', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
476
-				$result = $qb->executeQuery();
477
-				while ($row = $result->fetch()) {
478
-					$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
479
-				}
480
-			}
481
-		} else {
482
-			$result = $qb->executeQuery();
483
-			while ($row = $result->fetch()) {
484
-				$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
485
-			}
486
-		}
487
-
488
-		$this->userCache[$path] = $props;
489
-		return $props;
490
-	}
491
-
492
-	private function isPropertyDefaultValue(string $name, mixed $value): bool {
493
-		if (!isset(self::PROPERTY_DEFAULT_VALUES[$name])) {
494
-			return false;
495
-		}
496
-
497
-		return self::PROPERTY_DEFAULT_VALUES[$name] === $value;
498
-	}
499
-
500
-	/**
501
-	 * @param array<string, string> $properties
502
-	 * @throws Exception
503
-	 */
504
-	private function updateProperties(string $path, array $properties): bool {
505
-		// TODO: use "insert or update" strategy ?
506
-		$existing = $this->getUserProperties($path, []);
507
-		try {
508
-			$this->connection->beginTransaction();
509
-			foreach ($properties as $propertyName => $propertyValue) {
510
-				// common parameters for all queries
511
-				$dbParameters = [
512
-					'userid' => $this->user->getUID(),
513
-					'propertyPath' => $this->formatPath($path),
514
-					'propertyName' => $propertyName,
515
-				];
516
-
517
-				// If it was null or set to the default value, we need to delete the property
518
-				if (is_null($propertyValue) || $this->isPropertyDefaultValue($propertyName, $propertyValue)) {
519
-					if (array_key_exists($propertyName, $existing)) {
520
-						$deleteQuery = $deleteQuery ?? $this->createDeleteQuery();
521
-						$deleteQuery
522
-							->setParameters($dbParameters)
523
-							->executeStatement();
524
-					}
525
-				} else {
526
-					[$value, $valueType] = $this->encodeValueForDatabase(
527
-						$path,
528
-						$propertyName,
529
-						$propertyValue,
530
-					);
531
-					$dbParameters['propertyValue'] = $value;
532
-					$dbParameters['valueType'] = $valueType;
533
-
534
-					if (!array_key_exists($propertyName, $existing)) {
535
-						$insertQuery = $insertQuery ?? $this->createInsertQuery();
536
-						$insertQuery
537
-							->setParameters($dbParameters)
538
-							->executeStatement();
539
-					} else {
540
-						$updateQuery = $updateQuery ?? $this->createUpdateQuery();
541
-						$updateQuery
542
-							->setParameters($dbParameters)
543
-							->executeStatement();
544
-					}
545
-				}
546
-			}
547
-
548
-			$this->connection->commit();
549
-			unset($this->userCache[$path]);
550
-		} catch (Exception $e) {
551
-			$this->connection->rollBack();
552
-			throw $e;
553
-		}
554
-
555
-		return true;
556
-	}
557
-
558
-	/**
559
-	 * Long paths are hashed to ensure they fit in the database
560
-	 */
561
-	private function formatPath(string $path): string {
562
-		if (strlen($path) > 250) {
563
-			return sha1($path);
564
-		}
565
-
566
-		return $path;
567
-	}
568
-
569
-	private static function checkIsArrayOfScalar(string $name, array $array): void {
570
-		foreach ($array as $item) {
571
-			if (is_array($item)) {
572
-				self::checkIsArrayOfScalar($name, $item);
573
-			} elseif ($item !== null && !is_scalar($item)) {
574
-				throw new DavException(
575
-					"Property \"$name\" has an invalid value of array containing " . gettype($item),
576
-				);
577
-			}
578
-		}
579
-	}
580
-
581
-	/**
582
-	 * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails
583
-	 * @throws DavException If the property value is invalid
584
-	 */
585
-	private function encodeValueForDatabase(string $path, string $name, mixed $value): array {
586
-		// Try to parse a more specialized property type first
587
-		if ($value instanceof Complex) {
588
-			$xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri());
589
-			$value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value;
590
-		}
591
-
592
-		if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') {
593
-			$value = $this->encodeDefaultCalendarUrl($value);
594
-		}
595
-
596
-		try {
597
-			$this->validateProperty($path, $name, $value);
598
-		} catch (DavException $e) {
599
-			throw new DavException(
600
-				"Property \"$name\" has an invalid value: " . $e->getMessage(),
601
-				0,
602
-				$e,
603
-			);
604
-		}
605
-
606
-		if (is_scalar($value)) {
607
-			$valueType = self::PROPERTY_TYPE_STRING;
608
-		} elseif ($value instanceof Complex) {
609
-			$valueType = self::PROPERTY_TYPE_XML;
610
-			$value = $value->getXml();
611
-		} elseif ($value instanceof Href) {
612
-			$valueType = self::PROPERTY_TYPE_HREF;
613
-			$value = $value->getHref();
614
-		} else {
615
-			if (is_array($value)) {
616
-				// For array only allow scalar values
617
-				self::checkIsArrayOfScalar($name, $value);
618
-			} elseif (!is_object($value)) {
619
-				throw new DavException(
620
-					"Property \"$name\" has an invalid value of type " . gettype($value),
621
-				);
622
-			} else {
623
-				if (!str_starts_with($value::class, 'Sabre\\DAV\\Xml\\Property\\')
624
-					&& !str_starts_with($value::class, 'Sabre\\CalDAV\\Xml\\Property\\')
625
-					&& !str_starts_with($value::class, 'Sabre\\CardDAV\\Xml\\Property\\')
626
-					&& !str_starts_with($value::class, 'OCA\\DAV\\')) {
627
-					throw new DavException(
628
-						"Property \"$name\" has an invalid value of class " . $value::class,
629
-					);
630
-				}
631
-			}
632
-			$valueType = self::PROPERTY_TYPE_OBJECT;
633
-			// serialize produces null character
634
-			// these can not be properly stored in some databases and need to be replaced
635
-			$value = str_replace(chr(0), '\x00', serialize($value));
636
-		}
637
-		return [$value, $valueType];
638
-	}
639
-
640
-	/**
641
-	 * @return mixed|Complex|string
642
-	 */
643
-	private function decodeValueFromDatabase(string $value, int $valueType): mixed {
644
-		switch ($valueType) {
645
-			case self::PROPERTY_TYPE_XML:
646
-				return new Complex($value);
647
-			case self::PROPERTY_TYPE_HREF:
648
-				return new Href($value);
649
-			case self::PROPERTY_TYPE_OBJECT:
650
-				if (preg_match('/^a:/', $value)) {
651
-					// Array, unserialize only scalar values
652
-					return unserialize(str_replace('\x00', chr(0), $value), ['allowed_classes' => false]);
653
-				}
654
-				if (!preg_match('/^O\:\d+\:\"(OCA\\\\DAV\\\\|Sabre\\\\(Cal|Card)?DAV\\\\Xml\\\\Property\\\\)/', $value)) {
655
-					throw new \LogicException('Found an object class serialized in DB that is not allowed');
656
-				}
657
-				// some databases can not handel null characters, these are custom encoded during serialization
658
-				// this custom encoding needs to be first reversed before unserializing
659
-				return unserialize(str_replace('\x00', chr(0), $value));
660
-			default:
661
-				return $value;
662
-		};
663
-	}
664
-
665
-	private function encodeDefaultCalendarUrl(Href $value): Href {
666
-		$href = $value->getHref();
667
-		if ($href === null) {
668
-			return $value;
669
-		}
670
-
671
-		if (!str_starts_with($href, '/')) {
672
-			return $value;
673
-		}
674
-
675
-		try {
676
-			// Build path relative to the dav base URI to be used later to find the node
677
-			$value = new LocalHref($this->server->calculateUri($href) . '/');
678
-		} catch (DavException\Forbidden) {
679
-			// Not existing calendars will be handled later when the value is validated
680
-		}
681
-
682
-		return $value;
683
-	}
684
-
685
-	private function createDeleteQuery(): IQueryBuilder {
686
-		$deleteQuery = $this->connection->getQueryBuilder();
687
-		$deleteQuery->delete('properties')
688
-			->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
689
-			->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
690
-			->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
691
-		return $deleteQuery;
692
-	}
693
-
694
-	private function createInsertQuery(): IQueryBuilder {
695
-		$insertQuery = $this->connection->getQueryBuilder();
696
-		$insertQuery->insert('properties')
697
-			->values([
698
-				'userid' => $insertQuery->createParameter('userid'),
699
-				'propertypath' => $insertQuery->createParameter('propertyPath'),
700
-				'propertyname' => $insertQuery->createParameter('propertyName'),
701
-				'propertyvalue' => $insertQuery->createParameter('propertyValue'),
702
-				'valuetype' => $insertQuery->createParameter('valueType'),
703
-			]);
704
-		return $insertQuery;
705
-	}
706
-
707
-	private function createUpdateQuery(): IQueryBuilder {
708
-		$updateQuery = $this->connection->getQueryBuilder();
709
-		$updateQuery->update('properties')
710
-			->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
711
-			->set('valuetype', $updateQuery->createParameter('valueType'))
712
-			->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
713
-			->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
714
-			->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
715
-		return $updateQuery;
716
-	}
43
+    /** @var string */
44
+    private const TABLE_NAME = 'properties';
45
+
46
+    /**
47
+     * Value is stored as string.
48
+     */
49
+    public const PROPERTY_TYPE_STRING = 1;
50
+
51
+    /**
52
+     * Value is stored as XML fragment.
53
+     */
54
+    public const PROPERTY_TYPE_XML = 2;
55
+
56
+    /**
57
+     * Value is stored as a property object.
58
+     */
59
+    public const PROPERTY_TYPE_OBJECT = 3;
60
+
61
+    /**
62
+     * Value is stored as a {DAV:}href string.
63
+     */
64
+    public const PROPERTY_TYPE_HREF = 4;
65
+
66
+    /**
67
+     * Ignored properties
68
+     *
69
+     * @var string[]
70
+     */
71
+    private const IGNORED_PROPERTIES = [
72
+        '{DAV:}getcontentlength',
73
+        '{DAV:}getcontenttype',
74
+        '{DAV:}getetag',
75
+        '{DAV:}quota-used-bytes',
76
+        '{DAV:}quota-available-bytes',
77
+    ];
78
+
79
+    /**
80
+     * Allowed properties for the oc/nc namespace, all other properties in the namespace are ignored
81
+     *
82
+     * @var string[]
83
+     */
84
+    private const ALLOWED_NC_PROPERTIES = [
85
+        '{http://owncloud.org/ns}calendar-enabled',
86
+        '{http://owncloud.org/ns}enabled',
87
+    ];
88
+
89
+    /**
90
+     * Properties set by one user, readable by all others
91
+     *
92
+     * @var string[]
93
+     */
94
+    private const PUBLISHED_READ_ONLY_PROPERTIES = [
95
+        '{urn:ietf:params:xml:ns:caldav}calendar-availability',
96
+        '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
97
+    ];
98
+
99
+    /**
100
+     * Map of custom XML elements to parse when trying to deserialize an instance of
101
+     * \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_*
102
+     * @var array<string, class-string>
103
+     */
104
+    private const COMPLEX_XML_ELEMENT_MAP = [
105
+        '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class,
106
+    ];
107
+
108
+    /**
109
+     * Map of well-known property names to default values
110
+     * @var array<string, string>
111
+     */
112
+    private const PROPERTY_DEFAULT_VALUES = [
113
+        '{http://owncloud.org/ns}calendar-enabled' => '1',
114
+    ];
115
+
116
+    /**
117
+     * Properties cache
118
+     */
119
+    private array $userCache = [];
120
+    private array $publishedCache = [];
121
+    private XmlService $xmlService;
122
+
123
+    /**
124
+     * @param IUser $user owner of the tree and properties
125
+     */
126
+    public function __construct(
127
+        private readonly Server $server,
128
+        private readonly Tree $tree,
129
+        private readonly IDBConnection $connection,
130
+        private readonly IUser $user,
131
+        private readonly PropertyMapper $propertyMapper,
132
+        private readonly DefaultCalendarValidator $defaultCalendarValidator,
133
+    ) {
134
+        $this->xmlService = new XmlService();
135
+        $this->xmlService->elementMap = array_merge(
136
+            $this->xmlService->elementMap,
137
+            self::COMPLEX_XML_ELEMENT_MAP,
138
+        );
139
+    }
140
+
141
+    /**
142
+     * Fetches properties for a path.
143
+     *
144
+     * @param string $path
145
+     * @param PropFind $propFind
146
+     */
147
+    #[Override]
148
+    public function propFind($path, PropFind $propFind): void {
149
+        $requestedProps = $propFind->get404Properties();
150
+
151
+        $requestedProps = array_filter(
152
+            $requestedProps,
153
+            $this->isPropertyAllowed(...),
154
+        );
155
+
156
+        // substr of calendars/ => path is inside the CalDAV component
157
+        // two '/' => this a calendar (no calendar-home nor calendar object)
158
+        if (str_starts_with($path, 'calendars/') && substr_count($path, '/') === 2) {
159
+            $allRequestedProps = $propFind->getRequestedProperties();
160
+            $customPropertiesForShares = [
161
+                '{DAV:}displayname',
162
+                '{urn:ietf:params:xml:ns:caldav}calendar-description',
163
+                '{urn:ietf:params:xml:ns:caldav}calendar-timezone',
164
+                '{http://apple.com/ns/ical/}calendar-order',
165
+                '{http://apple.com/ns/ical/}calendar-color',
166
+                '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp',
167
+            ];
168
+
169
+            foreach ($customPropertiesForShares as $customPropertyForShares) {
170
+                if (in_array($customPropertyForShares, $allRequestedProps)) {
171
+                    $requestedProps[] = $customPropertyForShares;
172
+                }
173
+            }
174
+        }
175
+
176
+        // substr of addressbooks/ => path is inside the CardDAV component
177
+        // three '/' => this a addressbook (no addressbook-home nor contact object)
178
+        if (str_starts_with($path, 'addressbooks/') && substr_count($path, '/') === 3) {
179
+            $allRequestedProps = $propFind->getRequestedProperties();
180
+            $customPropertiesForShares = [
181
+                '{DAV:}displayname',
182
+            ];
183
+
184
+            foreach ($customPropertiesForShares as $customPropertyForShares) {
185
+                if (in_array($customPropertyForShares, $allRequestedProps, true)) {
186
+                    $requestedProps[] = $customPropertyForShares;
187
+                }
188
+            }
189
+        }
190
+
191
+        // substr of principals/users/ => path is a user principal
192
+        // two '/' => this a principal collection (and not some child object)
193
+        if (str_starts_with($path, 'principals/users/') && substr_count($path, '/') === 2) {
194
+            $allRequestedProps = $propFind->getRequestedProperties();
195
+            $customProperties = [
196
+                '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
197
+            ];
198
+
199
+            foreach ($customProperties as $customProperty) {
200
+                if (in_array($customProperty, $allRequestedProps, true)) {
201
+                    $requestedProps[] = $customProperty;
202
+                }
203
+            }
204
+        }
205
+
206
+        if (empty($requestedProps)) {
207
+            return;
208
+        }
209
+
210
+        $node = $this->tree->getNodeForPath($path);
211
+        if ($node instanceof Directory && $propFind->getDepth() !== 0) {
212
+            $this->cacheDirectory($path, $node);
213
+        }
214
+
215
+        if ($node instanceof CalendarHome && $propFind->getDepth() !== 0) {
216
+            $backend = $node->getCalDAVBackend();
217
+            if ($backend instanceof CalDavBackend) {
218
+                $this->cacheCalendars($node, $requestedProps);
219
+            }
220
+        }
221
+
222
+        if ($node instanceof CalendarObject) {
223
+            // No custom properties supported on individual events
224
+            return;
225
+        }
226
+
227
+        // First fetch the published properties (set by another user), then get the ones set by
228
+        // the current user. If both are set then the latter as priority.
229
+        foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
230
+            try {
231
+                $this->validateProperty($path, $propName, $propValue);
232
+            } catch (DavException $e) {
233
+                continue;
234
+            }
235
+            $propFind->set($propName, $propValue);
236
+        }
237
+        foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) {
238
+            try {
239
+                $this->validateProperty($path, $propName, $propValue);
240
+            } catch (DavException $e) {
241
+                continue;
242
+            }
243
+            $propFind->set($propName, $propValue);
244
+        }
245
+    }
246
+
247
+    private function isPropertyAllowed(string $property): bool {
248
+        if (in_array($property, self::IGNORED_PROPERTIES)) {
249
+            return false;
250
+        }
251
+        if (str_starts_with($property, '{http://owncloud.org/ns}') || str_starts_with($property, '{http://nextcloud.org/ns}')) {
252
+            return in_array($property, self::ALLOWED_NC_PROPERTIES);
253
+        }
254
+        return true;
255
+    }
256
+
257
+    /**
258
+     * Updates properties for a path
259
+     *
260
+     * @param string $path
261
+     */
262
+    #[Override]
263
+    public function propPatch($path, PropPatch $propPatch): void {
264
+        $propPatch->handleRemaining(function (array $changedProps) use ($path) {
265
+            return $this->updateProperties($path, $changedProps);
266
+        });
267
+    }
268
+
269
+    /**
270
+     * This method is called after a node is deleted.
271
+     *
272
+     * @param string $path path of node for which to delete properties
273
+     */
274
+    #[Override]
275
+    public function delete($path): void {
276
+        $qb = $this->connection->getQueryBuilder();
277
+        $qb->delete('properties')
278
+            ->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID())))
279
+            ->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($path))));
280
+        $qb->executeStatement();
281
+        unset($this->userCache[$path]);
282
+    }
283
+
284
+    /**
285
+     * This method is called after a successful MOVE
286
+     *
287
+     * @param string $source
288
+     * @param string $destination
289
+     */
290
+    #[Override]
291
+    public function move($source, $destination): void {
292
+        $qb = $this->connection->getQueryBuilder();
293
+        $qb->update('properties')
294
+            ->set('propertypath', $qb->createNamedParameter($this->formatPath($destination)))
295
+            ->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID())))
296
+            ->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($source))));
297
+        $qb->executeStatement();
298
+    }
299
+
300
+    /**
301
+     * Validate the value of a property. Will throw if a value is invalid.
302
+     *
303
+     * @throws DavException The value of the property is invalid
304
+     */
305
+    private function validateProperty(string $path, string $propName, mixed $propValue): void {
306
+        switch ($propName) {
307
+            case '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL':
308
+                /** @var Href $propValue */
309
+                $href = $propValue->getHref();
310
+                if ($href === null) {
311
+                    throw new DavException('Href is empty');
312
+                }
313
+
314
+                // $path is the principal here as this prop is only set on principals
315
+                $node = $this->tree->getNodeForPath($href);
316
+                if (!($node instanceof Calendar) || $node->getOwner() !== $path) {
317
+                    throw new DavException('No such calendar');
318
+                }
319
+
320
+                $this->defaultCalendarValidator->validateScheduleDefaultCalendar($node);
321
+                break;
322
+        }
323
+    }
324
+
325
+    /**
326
+     * @param string[] $requestedProperties
327
+     *
328
+     * @return array<string, mixed|Complex|Href|string>
329
+     * @throws \OCP\DB\Exception
330
+     */
331
+    private function getPublishedProperties(string $path, array $requestedProperties): array {
332
+        $allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
333
+
334
+        if (empty($allowedProps)) {
335
+            return [];
336
+        }
337
+
338
+        if (isset($this->publishedCache[$path])) {
339
+            return $this->publishedCache[$path];
340
+        }
341
+
342
+        $qb = $this->connection->getQueryBuilder();
343
+        $qb->select('*')
344
+            ->from(self::TABLE_NAME)
345
+            ->where($qb->expr()->eq('propertypath', $qb->createNamedParameter($path)));
346
+        $result = $qb->executeQuery();
347
+        $props = [];
348
+        while ($row = $result->fetch()) {
349
+            $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
350
+        }
351
+        $result->closeCursor();
352
+        $this->publishedCache[$path] = $props;
353
+        return $props;
354
+    }
355
+
356
+    /**
357
+     * Prefetch all user properties in a directory
358
+     */
359
+    private function cacheDirectory(string $path, Directory $node): void {
360
+        $prefix = ltrim($path . '/', '/');
361
+        $query = $this->connection->getQueryBuilder();
362
+        $query->select('name', 'p.propertypath', 'p.propertyname', 'p.propertyvalue', 'p.valuetype')
363
+            ->from('filecache', 'f')
364
+            ->hintShardKey('storage', $node->getNode()->getMountPoint()->getNumericStorageId())
365
+            ->leftJoin('f', 'properties', 'p', $query->expr()->eq('p.propertypath', $query->func()->concat(
366
+                $query->createNamedParameter($prefix),
367
+                'f.name'
368
+            )),
369
+            )
370
+            ->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT)))
371
+            ->andWhere($query->expr()->orX(
372
+                $query->expr()->eq('p.userid', $query->createNamedParameter($this->user->getUID())),
373
+                $query->expr()->isNull('p.userid'),
374
+            ));
375
+        $result = $query->executeQuery();
376
+
377
+        $propsByPath = [];
378
+
379
+        while ($row = $result->fetch()) {
380
+            $childPath = $prefix . $row['name'];
381
+            if (!isset($propsByPath[$childPath])) {
382
+                $propsByPath[$childPath] = [];
383
+            }
384
+            if (isset($row['propertyname'])) {
385
+                $propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
386
+            }
387
+        }
388
+        $this->userCache = array_merge($this->userCache, $propsByPath);
389
+    }
390
+
391
+    private function cacheCalendars(CalendarHome $node, array $requestedProperties): void {
392
+        $calendars = $node->getChildren();
393
+
394
+        $users = [];
395
+        foreach ($calendars as $calendar) {
396
+            if ($calendar instanceof Calendar) {
397
+                $user = str_replace('principals/users/', '', $calendar->getPrincipalURI());
398
+                if (!isset($users[$user])) {
399
+                    $users[$user] = ['calendars/' . $user];
400
+                }
401
+                $users[$user][] = 'calendars/' . $user . '/' . $calendar->getUri();
402
+            } elseif ($calendar instanceof Inbox || $calendar instanceof Outbox || $calendar instanceof TrashbinHome || $calendar instanceof ExternalCalendar) {
403
+                if ($calendar->getOwner()) {
404
+                    $user = str_replace('principals/users/', '', $calendar->getOwner());
405
+                    if (!isset($users[$user])) {
406
+                        $users[$user] = ['calendars/' . $user];
407
+                    }
408
+                    $users[$user][] = 'calendars/' . $user . '/' . $calendar->getName();
409
+                }
410
+            }
411
+        }
412
+
413
+        // user properties
414
+        $properties = $this->propertyMapper->findPropertiesByPathsAndUsers($users);
415
+
416
+        $propsByPath = [];
417
+        foreach ($users as $paths) {
418
+            foreach ($paths as $path) {
419
+                $propsByPath[$path] = [];
420
+            }
421
+        }
422
+
423
+        foreach ($properties as $property) {
424
+            $propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype());
425
+        }
426
+        $this->userCache = array_merge($this->userCache, $propsByPath);
427
+
428
+        // published properties
429
+        $allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
430
+        if (empty($allowedProps)) {
431
+            return;
432
+        }
433
+        $paths = [];
434
+        foreach ($users as $nestedPaths) {
435
+            $paths = array_merge($paths, $nestedPaths);
436
+        }
437
+        $paths = array_unique($paths);
438
+
439
+        $propsByPath = array_fill_keys(array_values($paths), []);
440
+        $properties = $this->propertyMapper->findPropertiesByPaths($paths, $allowedProps);
441
+        foreach ($properties as $property) {
442
+            $propsByPath[$property->getPropertypath()][$property->getPropertyname()] = $this->decodeValueFromDatabase($property->getPropertyvalue(), $property->getValuetype());
443
+        }
444
+        $this->publishedCache = array_merge($this->publishedCache, $propsByPath);
445
+    }
446
+
447
+    /**
448
+     * Returns a list of properties for the given path and current user
449
+     *
450
+     * @param array $requestedProperties requested properties or empty array for "all"
451
+     * @return array<string, mixed>
452
+     * @note The properties list is a list of propertynames the client
453
+     * requested, encoded as xmlnamespace#tagName, for example:
454
+     * http://www.example.org/namespace#author If the array is empty, all
455
+     * properties should be returned
456
+     */
457
+    private function getUserProperties(string $path, array $requestedProperties): array {
458
+        if (isset($this->userCache[$path])) {
459
+            return $this->userCache[$path];
460
+        }
461
+
462
+        $props = [];
463
+
464
+        $qb = $this->connection->getQueryBuilder();
465
+        $qb->select('*')
466
+            ->from('properties')
467
+            ->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID(), IQueryBuilder::PARAM_STR)))
468
+            ->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($path), IQueryBuilder::PARAM_STR)));
469
+
470
+        if (!empty($requestedProperties)) {
471
+            // request only a subset
472
+            $qb->andWhere($qb->expr()->in('propertyname', $qb->createParameter('requestedProperties')));
473
+            $chunks = array_chunk($requestedProperties, 1000);
474
+            foreach ($chunks as $chunk) {
475
+                $qb->setParameter('requestedProperties', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
476
+                $result = $qb->executeQuery();
477
+                while ($row = $result->fetch()) {
478
+                    $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
479
+                }
480
+            }
481
+        } else {
482
+            $result = $qb->executeQuery();
483
+            while ($row = $result->fetch()) {
484
+                $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
485
+            }
486
+        }
487
+
488
+        $this->userCache[$path] = $props;
489
+        return $props;
490
+    }
491
+
492
+    private function isPropertyDefaultValue(string $name, mixed $value): bool {
493
+        if (!isset(self::PROPERTY_DEFAULT_VALUES[$name])) {
494
+            return false;
495
+        }
496
+
497
+        return self::PROPERTY_DEFAULT_VALUES[$name] === $value;
498
+    }
499
+
500
+    /**
501
+     * @param array<string, string> $properties
502
+     * @throws Exception
503
+     */
504
+    private function updateProperties(string $path, array $properties): bool {
505
+        // TODO: use "insert or update" strategy ?
506
+        $existing = $this->getUserProperties($path, []);
507
+        try {
508
+            $this->connection->beginTransaction();
509
+            foreach ($properties as $propertyName => $propertyValue) {
510
+                // common parameters for all queries
511
+                $dbParameters = [
512
+                    'userid' => $this->user->getUID(),
513
+                    'propertyPath' => $this->formatPath($path),
514
+                    'propertyName' => $propertyName,
515
+                ];
516
+
517
+                // If it was null or set to the default value, we need to delete the property
518
+                if (is_null($propertyValue) || $this->isPropertyDefaultValue($propertyName, $propertyValue)) {
519
+                    if (array_key_exists($propertyName, $existing)) {
520
+                        $deleteQuery = $deleteQuery ?? $this->createDeleteQuery();
521
+                        $deleteQuery
522
+                            ->setParameters($dbParameters)
523
+                            ->executeStatement();
524
+                    }
525
+                } else {
526
+                    [$value, $valueType] = $this->encodeValueForDatabase(
527
+                        $path,
528
+                        $propertyName,
529
+                        $propertyValue,
530
+                    );
531
+                    $dbParameters['propertyValue'] = $value;
532
+                    $dbParameters['valueType'] = $valueType;
533
+
534
+                    if (!array_key_exists($propertyName, $existing)) {
535
+                        $insertQuery = $insertQuery ?? $this->createInsertQuery();
536
+                        $insertQuery
537
+                            ->setParameters($dbParameters)
538
+                            ->executeStatement();
539
+                    } else {
540
+                        $updateQuery = $updateQuery ?? $this->createUpdateQuery();
541
+                        $updateQuery
542
+                            ->setParameters($dbParameters)
543
+                            ->executeStatement();
544
+                    }
545
+                }
546
+            }
547
+
548
+            $this->connection->commit();
549
+            unset($this->userCache[$path]);
550
+        } catch (Exception $e) {
551
+            $this->connection->rollBack();
552
+            throw $e;
553
+        }
554
+
555
+        return true;
556
+    }
557
+
558
+    /**
559
+     * Long paths are hashed to ensure they fit in the database
560
+     */
561
+    private function formatPath(string $path): string {
562
+        if (strlen($path) > 250) {
563
+            return sha1($path);
564
+        }
565
+
566
+        return $path;
567
+    }
568
+
569
+    private static function checkIsArrayOfScalar(string $name, array $array): void {
570
+        foreach ($array as $item) {
571
+            if (is_array($item)) {
572
+                self::checkIsArrayOfScalar($name, $item);
573
+            } elseif ($item !== null && !is_scalar($item)) {
574
+                throw new DavException(
575
+                    "Property \"$name\" has an invalid value of array containing " . gettype($item),
576
+                );
577
+            }
578
+        }
579
+    }
580
+
581
+    /**
582
+     * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails
583
+     * @throws DavException If the property value is invalid
584
+     */
585
+    private function encodeValueForDatabase(string $path, string $name, mixed $value): array {
586
+        // Try to parse a more specialized property type first
587
+        if ($value instanceof Complex) {
588
+            $xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri());
589
+            $value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value;
590
+        }
591
+
592
+        if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') {
593
+            $value = $this->encodeDefaultCalendarUrl($value);
594
+        }
595
+
596
+        try {
597
+            $this->validateProperty($path, $name, $value);
598
+        } catch (DavException $e) {
599
+            throw new DavException(
600
+                "Property \"$name\" has an invalid value: " . $e->getMessage(),
601
+                0,
602
+                $e,
603
+            );
604
+        }
605
+
606
+        if (is_scalar($value)) {
607
+            $valueType = self::PROPERTY_TYPE_STRING;
608
+        } elseif ($value instanceof Complex) {
609
+            $valueType = self::PROPERTY_TYPE_XML;
610
+            $value = $value->getXml();
611
+        } elseif ($value instanceof Href) {
612
+            $valueType = self::PROPERTY_TYPE_HREF;
613
+            $value = $value->getHref();
614
+        } else {
615
+            if (is_array($value)) {
616
+                // For array only allow scalar values
617
+                self::checkIsArrayOfScalar($name, $value);
618
+            } elseif (!is_object($value)) {
619
+                throw new DavException(
620
+                    "Property \"$name\" has an invalid value of type " . gettype($value),
621
+                );
622
+            } else {
623
+                if (!str_starts_with($value::class, 'Sabre\\DAV\\Xml\\Property\\')
624
+                    && !str_starts_with($value::class, 'Sabre\\CalDAV\\Xml\\Property\\')
625
+                    && !str_starts_with($value::class, 'Sabre\\CardDAV\\Xml\\Property\\')
626
+                    && !str_starts_with($value::class, 'OCA\\DAV\\')) {
627
+                    throw new DavException(
628
+                        "Property \"$name\" has an invalid value of class " . $value::class,
629
+                    );
630
+                }
631
+            }
632
+            $valueType = self::PROPERTY_TYPE_OBJECT;
633
+            // serialize produces null character
634
+            // these can not be properly stored in some databases and need to be replaced
635
+            $value = str_replace(chr(0), '\x00', serialize($value));
636
+        }
637
+        return [$value, $valueType];
638
+    }
639
+
640
+    /**
641
+     * @return mixed|Complex|string
642
+     */
643
+    private function decodeValueFromDatabase(string $value, int $valueType): mixed {
644
+        switch ($valueType) {
645
+            case self::PROPERTY_TYPE_XML:
646
+                return new Complex($value);
647
+            case self::PROPERTY_TYPE_HREF:
648
+                return new Href($value);
649
+            case self::PROPERTY_TYPE_OBJECT:
650
+                if (preg_match('/^a:/', $value)) {
651
+                    // Array, unserialize only scalar values
652
+                    return unserialize(str_replace('\x00', chr(0), $value), ['allowed_classes' => false]);
653
+                }
654
+                if (!preg_match('/^O\:\d+\:\"(OCA\\\\DAV\\\\|Sabre\\\\(Cal|Card)?DAV\\\\Xml\\\\Property\\\\)/', $value)) {
655
+                    throw new \LogicException('Found an object class serialized in DB that is not allowed');
656
+                }
657
+                // some databases can not handel null characters, these are custom encoded during serialization
658
+                // this custom encoding needs to be first reversed before unserializing
659
+                return unserialize(str_replace('\x00', chr(0), $value));
660
+            default:
661
+                return $value;
662
+        };
663
+    }
664
+
665
+    private function encodeDefaultCalendarUrl(Href $value): Href {
666
+        $href = $value->getHref();
667
+        if ($href === null) {
668
+            return $value;
669
+        }
670
+
671
+        if (!str_starts_with($href, '/')) {
672
+            return $value;
673
+        }
674
+
675
+        try {
676
+            // Build path relative to the dav base URI to be used later to find the node
677
+            $value = new LocalHref($this->server->calculateUri($href) . '/');
678
+        } catch (DavException\Forbidden) {
679
+            // Not existing calendars will be handled later when the value is validated
680
+        }
681
+
682
+        return $value;
683
+    }
684
+
685
+    private function createDeleteQuery(): IQueryBuilder {
686
+        $deleteQuery = $this->connection->getQueryBuilder();
687
+        $deleteQuery->delete('properties')
688
+            ->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
689
+            ->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
690
+            ->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
691
+        return $deleteQuery;
692
+    }
693
+
694
+    private function createInsertQuery(): IQueryBuilder {
695
+        $insertQuery = $this->connection->getQueryBuilder();
696
+        $insertQuery->insert('properties')
697
+            ->values([
698
+                'userid' => $insertQuery->createParameter('userid'),
699
+                'propertypath' => $insertQuery->createParameter('propertyPath'),
700
+                'propertyname' => $insertQuery->createParameter('propertyName'),
701
+                'propertyvalue' => $insertQuery->createParameter('propertyValue'),
702
+                'valuetype' => $insertQuery->createParameter('valueType'),
703
+            ]);
704
+        return $insertQuery;
705
+    }
706
+
707
+    private function createUpdateQuery(): IQueryBuilder {
708
+        $updateQuery = $this->connection->getQueryBuilder();
709
+        $updateQuery->update('properties')
710
+            ->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
711
+            ->set('valuetype', $updateQuery->createParameter('valueType'))
712
+            ->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
713
+            ->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
714
+            ->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
715
+        return $updateQuery;
716
+    }
717 717
 }
Please login to merge, or discard this patch.
Spacing   +12 added lines, -12 removed lines patch added patch discarded remove patch
@@ -261,7 +261,7 @@  discard block
 block discarded – undo
261 261
 	 */
262 262
 	#[Override]
263 263
 	public function propPatch($path, PropPatch $propPatch): void {
264
-		$propPatch->handleRemaining(function (array $changedProps) use ($path) {
264
+		$propPatch->handleRemaining(function(array $changedProps) use ($path) {
265 265
 			return $this->updateProperties($path, $changedProps);
266 266
 		});
267 267
 	}
@@ -357,7 +357,7 @@  discard block
 block discarded – undo
357 357
 	 * Prefetch all user properties in a directory
358 358
 	 */
359 359
 	private function cacheDirectory(string $path, Directory $node): void {
360
-		$prefix = ltrim($path . '/', '/');
360
+		$prefix = ltrim($path.'/', '/');
361 361
 		$query = $this->connection->getQueryBuilder();
362 362
 		$query->select('name', 'p.propertypath', 'p.propertyname', 'p.propertyvalue', 'p.valuetype')
363 363
 			->from('filecache', 'f')
@@ -377,7 +377,7 @@  discard block
 block discarded – undo
377 377
 		$propsByPath = [];
378 378
 
379 379
 		while ($row = $result->fetch()) {
380
-			$childPath = $prefix . $row['name'];
380
+			$childPath = $prefix.$row['name'];
381 381
 			if (!isset($propsByPath[$childPath])) {
382 382
 				$propsByPath[$childPath] = [];
383 383
 			}
@@ -396,16 +396,16 @@  discard block
 block discarded – undo
396 396
 			if ($calendar instanceof Calendar) {
397 397
 				$user = str_replace('principals/users/', '', $calendar->getPrincipalURI());
398 398
 				if (!isset($users[$user])) {
399
-					$users[$user] = ['calendars/' . $user];
399
+					$users[$user] = ['calendars/'.$user];
400 400
 				}
401
-				$users[$user][] = 'calendars/' . $user . '/' . $calendar->getUri();
401
+				$users[$user][] = 'calendars/'.$user.'/'.$calendar->getUri();
402 402
 			} elseif ($calendar instanceof Inbox || $calendar instanceof Outbox || $calendar instanceof TrashbinHome || $calendar instanceof ExternalCalendar) {
403 403
 				if ($calendar->getOwner()) {
404 404
 					$user = str_replace('principals/users/', '', $calendar->getOwner());
405 405
 					if (!isset($users[$user])) {
406
-						$users[$user] = ['calendars/' . $user];
406
+						$users[$user] = ['calendars/'.$user];
407 407
 					}
408
-					$users[$user][] = 'calendars/' . $user . '/' . $calendar->getName();
408
+					$users[$user][] = 'calendars/'.$user.'/'.$calendar->getName();
409 409
 				}
410 410
 			}
411 411
 		}
@@ -572,7 +572,7 @@  discard block
 block discarded – undo
572 572
 				self::checkIsArrayOfScalar($name, $item);
573 573
 			} elseif ($item !== null && !is_scalar($item)) {
574 574
 				throw new DavException(
575
-					"Property \"$name\" has an invalid value of array containing " . gettype($item),
575
+					"Property \"$name\" has an invalid value of array containing ".gettype($item),
576 576
 				);
577 577
 			}
578 578
 		}
@@ -597,7 +597,7 @@  discard block
 block discarded – undo
597 597
 			$this->validateProperty($path, $name, $value);
598 598
 		} catch (DavException $e) {
599 599
 			throw new DavException(
600
-				"Property \"$name\" has an invalid value: " . $e->getMessage(),
600
+				"Property \"$name\" has an invalid value: ".$e->getMessage(),
601 601
 				0,
602 602
 				$e,
603 603
 			);
@@ -617,7 +617,7 @@  discard block
 block discarded – undo
617 617
 				self::checkIsArrayOfScalar($name, $value);
618 618
 			} elseif (!is_object($value)) {
619 619
 				throw new DavException(
620
-					"Property \"$name\" has an invalid value of type " . gettype($value),
620
+					"Property \"$name\" has an invalid value of type ".gettype($value),
621 621
 				);
622 622
 			} else {
623 623
 				if (!str_starts_with($value::class, 'Sabre\\DAV\\Xml\\Property\\')
@@ -625,7 +625,7 @@  discard block
 block discarded – undo
625 625
 					&& !str_starts_with($value::class, 'Sabre\\CardDAV\\Xml\\Property\\')
626 626
 					&& !str_starts_with($value::class, 'OCA\\DAV\\')) {
627 627
 					throw new DavException(
628
-						"Property \"$name\" has an invalid value of class " . $value::class,
628
+						"Property \"$name\" has an invalid value of class ".$value::class,
629 629
 					);
630 630
 				}
631 631
 			}
@@ -674,7 +674,7 @@  discard block
 block discarded – undo
674 674
 
675 675
 		try {
676 676
 			// Build path relative to the dav base URI to be used later to find the node
677
-			$value = new LocalHref($this->server->calculateUri($href) . '/');
677
+			$value = new LocalHref($this->server->calculateUri($href).'/');
678 678
 		} catch (DavException\Forbidden) {
679 679
 			// Not existing calendars will be handled later when the value is validated
680 680
 		}
Please login to merge, or discard this patch.