Completed
Push — master ( b3f29e...3f5c2c )
by
unknown
36:54
created
apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php 1 patch
Indentation   +434 added lines, -434 removed lines patch added patch discarded remove patch
@@ -29,438 +29,438 @@
 block discarded – undo
29 29
  * @group DB
30 30
  */
31 31
 class CustomPropertiesBackendTest extends TestCase {
32
-	private const BASE_URI = '/remote.php/dav/';
33
-
34
-	private Server&MockObject $server;
35
-	private Tree&MockObject $tree;
36
-	private IDBConnection $dbConnection;
37
-	private IUser&MockObject $user;
38
-	private DefaultCalendarValidator&MockObject $defaultCalendarValidator;
39
-	private CustomPropertiesBackend $backend;
40
-	private PropertyMapper $propertyMapper;
41
-
42
-	protected function setUp(): void {
43
-		parent::setUp();
44
-
45
-		$this->server = $this->createMock(Server::class);
46
-		$this->server->method('getBaseUri')
47
-			->willReturn(self::BASE_URI);
48
-		$this->tree = $this->createMock(Tree::class);
49
-		$this->user = $this->createMock(IUser::class);
50
-		$this->user->method('getUID')
51
-			->with()
52
-			->willReturn('dummy_user_42');
53
-		$this->dbConnection = \OCP\Server::get(IDBConnection::class);
54
-		$this->propertyMapper = \OCP\Server::get(PropertyMapper::class);
55
-		$this->defaultCalendarValidator = $this->createMock(DefaultCalendarValidator::class);
56
-
57
-		$this->backend = new CustomPropertiesBackend(
58
-			$this->server,
59
-			$this->tree,
60
-			$this->dbConnection,
61
-			$this->user,
62
-			$this->propertyMapper,
63
-			$this->defaultCalendarValidator,
64
-		);
65
-	}
66
-
67
-	protected function tearDown(): void {
68
-		$query = $this->dbConnection->getQueryBuilder();
69
-		$query->delete('properties');
70
-		$query->executeStatement();
71
-
72
-		parent::tearDown();
73
-	}
74
-
75
-	private function formatPath(string $path): string {
76
-		if (strlen($path) > 250) {
77
-			return sha1($path);
78
-		} else {
79
-			return $path;
80
-		}
81
-	}
82
-
83
-	protected function insertProps(string $user, string $path, array $props): void {
84
-		foreach ($props as $name => $value) {
85
-			$this->insertProp($user, $path, $name, $value);
86
-		}
87
-	}
88
-
89
-	protected function insertProp(string $user, string $path, string $name, mixed $value): void {
90
-		$type = CustomPropertiesBackend::PROPERTY_TYPE_STRING;
91
-		if ($value instanceof Href) {
92
-			$value = $value->getHref();
93
-			$type = CustomPropertiesBackend::PROPERTY_TYPE_HREF;
94
-		}
95
-
96
-		$query = $this->dbConnection->getQueryBuilder();
97
-		$query->insert('properties')
98
-			->values([
99
-				'userid' => $query->createNamedParameter($user),
100
-				'propertypath' => $query->createNamedParameter($this->formatPath($path)),
101
-				'propertyname' => $query->createNamedParameter($name),
102
-				'propertyvalue' => $query->createNamedParameter($value),
103
-				'valuetype' => $query->createNamedParameter($type, IQueryBuilder::PARAM_INT)
104
-			]);
105
-		$query->executeStatement();
106
-	}
107
-
108
-	protected function getProps(string $user, string $path): array {
109
-		$query = $this->dbConnection->getQueryBuilder();
110
-		$query->select('propertyname', 'propertyvalue', 'valuetype')
111
-			->from('properties')
112
-			->where($query->expr()->eq('userid', $query->createNamedParameter($user)))
113
-			->andWhere($query->expr()->eq('propertypath', $query->createNamedParameter($this->formatPath($path))));
114
-
115
-		$result = $query->executeQuery();
116
-		$data = [];
117
-		while ($row = $result->fetch()) {
118
-			$value = $row['propertyvalue'];
119
-			if ((int)$row['valuetype'] === CustomPropertiesBackend::PROPERTY_TYPE_HREF) {
120
-				$value = new Href($value);
121
-			}
122
-			$data[$row['propertyname']] = $value;
123
-		}
124
-		$result->closeCursor();
125
-
126
-		return $data;
127
-	}
128
-
129
-	public function testPropFindNoDbCalls(): void {
130
-		$db = $this->createMock(IDBConnection::class);
131
-		$backend = new CustomPropertiesBackend(
132
-			$this->server,
133
-			$this->tree,
134
-			$db,
135
-			$this->user,
136
-			$this->propertyMapper,
137
-			$this->defaultCalendarValidator,
138
-		);
139
-
140
-		$propFind = $this->createMock(PropFind::class);
141
-		$propFind->expects($this->once())
142
-			->method('get404Properties')
143
-			->with()
144
-			->willReturn([
145
-				'{http://owncloud.org/ns}permissions',
146
-				'{http://owncloud.org/ns}downloadURL',
147
-				'{http://owncloud.org/ns}dDC',
148
-				'{http://owncloud.org/ns}size',
149
-			]);
150
-
151
-		$db->expects($this->never())
152
-			->method($this->anything());
153
-
154
-		$backend->propFind('foo_bar_path_1337_0', $propFind);
155
-	}
156
-
157
-	public function testPropFindCalendarCall(): void {
158
-		$propFind = $this->createMock(PropFind::class);
159
-		$propFind->method('get404Properties')
160
-			->with()
161
-			->willReturn([
162
-				'{DAV:}getcontentlength',
163
-				'{DAV:}getcontenttype',
164
-				'{DAV:}getetag',
165
-				'{abc}def',
166
-			]);
167
-
168
-		$propFind->method('getRequestedProperties')
169
-			->with()
170
-			->willReturn([
171
-				'{DAV:}getcontentlength',
172
-				'{DAV:}getcontenttype',
173
-				'{DAV:}getetag',
174
-				'{DAV:}displayname',
175
-				'{urn:ietf:params:xml:ns:caldav}calendar-description',
176
-				'{urn:ietf:params:xml:ns:caldav}calendar-timezone',
177
-				'{abc}def',
178
-			]);
179
-
180
-		$props = [
181
-			'{abc}def' => 'a',
182
-			'{DAV:}displayname' => 'b',
183
-			'{urn:ietf:params:xml:ns:caldav}calendar-description' => 'c',
184
-			'{urn:ietf:params:xml:ns:caldav}calendar-timezone' => 'd',
185
-		];
186
-
187
-		$this->insertProps('dummy_user_42', 'calendars/foo/bar_path_1337_0', $props);
188
-
189
-		$setProps = [];
190
-		$propFind->method('set')
191
-			->willReturnCallback(function ($name, $value, $status) use (&$setProps): void {
192
-				$setProps[$name] = $value;
193
-			});
194
-
195
-		$this->backend->propFind('calendars/foo/bar_path_1337_0', $propFind);
196
-		$this->assertEquals($props, $setProps);
197
-	}
198
-
199
-	public function testPropFindPrincipalCall(): void {
200
-		$this->tree->method('getNodeForPath')
201
-			->willReturnCallback(function ($uri) {
202
-				$node = $this->createMock(Calendar::class);
203
-				$node->method('getOwner')
204
-					->willReturn('principals/users/dummy_user_42');
205
-				return $node;
206
-			});
207
-
208
-		$propFind = $this->createMock(PropFind::class);
209
-		$propFind->method('get404Properties')
210
-			->with()
211
-			->willReturn([
212
-				'{DAV:}getcontentlength',
213
-				'{DAV:}getcontenttype',
214
-				'{DAV:}getetag',
215
-				'{abc}def',
216
-			]);
217
-
218
-		$propFind->method('getRequestedProperties')
219
-			->with()
220
-			->willReturn([
221
-				'{DAV:}getcontentlength',
222
-				'{DAV:}getcontenttype',
223
-				'{DAV:}getetag',
224
-				'{abc}def',
225
-				'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
226
-			]);
227
-
228
-		$props = [
229
-			'{abc}def' => 'a',
230
-			'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/admin/personal'),
231
-		];
232
-		$this->insertProps('dummy_user_42', 'principals/users/dummy_user_42', $props);
233
-
234
-		$setProps = [];
235
-		$propFind->method('set')
236
-			->willReturnCallback(function ($name, $value, $status) use (&$setProps): void {
237
-				$setProps[$name] = $value;
238
-			});
239
-
240
-		$this->backend->propFind('principals/users/dummy_user_42', $propFind);
241
-		$this->assertEquals($props, $setProps);
242
-	}
243
-
244
-	public static function propFindPrincipalScheduleDefaultCalendarProviderUrlProvider(): array {
245
-		// [ user, nodes, existingProps, requestedProps, returnedProps ]
246
-		return [
247
-			[ // Exists
248
-				'dummy_user_42',
249
-				['calendars/dummy_user_42/foo/' => Calendar::class],
250
-				['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/foo/')],
251
-				['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
252
-				['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/foo/')],
253
-			],
254
-			[ // Doesn't exist
255
-				'dummy_user_42',
256
-				['calendars/dummy_user_42/foo/' => Calendar::class],
257
-				['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/bar/')],
258
-				['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
259
-				[],
260
-			],
261
-			[ // No privilege
262
-				'dummy_user_42',
263
-				['calendars/user2/baz/' => Calendar::class],
264
-				['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/user2/baz/')],
265
-				['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
266
-				[],
267
-			],
268
-			[ // Not a calendar
269
-				'dummy_user_42',
270
-				['foo/dummy_user_42/bar/' => IACL::class],
271
-				['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/dummy_user_42/bar/')],
272
-				['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
273
-				[],
274
-			],
275
-		];
276
-
277
-	}
278
-
279
-	#[\PHPUnit\Framework\Attributes\DataProvider('propFindPrincipalScheduleDefaultCalendarProviderUrlProvider')]
280
-	public function testPropFindPrincipalScheduleDefaultCalendarUrl(
281
-		string $user,
282
-		array $nodes,
283
-		array $existingProps,
284
-		array $requestedProps,
285
-		array $returnedProps,
286
-	): void {
287
-		$propFind = $this->createMock(PropFind::class);
288
-		$propFind->method('get404Properties')
289
-			->with()
290
-			->willReturn([
291
-				'{DAV:}getcontentlength',
292
-				'{DAV:}getcontenttype',
293
-				'{DAV:}getetag',
294
-			]);
295
-
296
-		$propFind->method('getRequestedProperties')
297
-			->with()
298
-			->willReturn(array_merge([
299
-				'{DAV:}getcontentlength',
300
-				'{DAV:}getcontenttype',
301
-				'{DAV:}getetag',
302
-				'{abc}def',
303
-			],
304
-				$requestedProps,
305
-			));
306
-
307
-		$this->server->method('calculateUri')
308
-			->willReturnCallback(function ($uri) {
309
-				if (!str_starts_with($uri, self::BASE_URI)) {
310
-					return trim(substr($uri, strlen(self::BASE_URI)), '/');
311
-				}
312
-				return null;
313
-			});
314
-		$this->tree->method('getNodeForPath')
315
-			->willReturnCallback(function ($uri) use ($nodes) {
316
-				if (str_starts_with($uri, 'principals/')) {
317
-					return $this->createMock(IPrincipal::class);
318
-				}
319
-				if (array_key_exists($uri, $nodes)) {
320
-					$owner = explode('/', $uri)[1];
321
-					$node = $this->createMock($nodes[$uri]);
322
-					$node->method('getOwner')
323
-						->willReturn("principals/users/$owner");
324
-					return $node;
325
-				}
326
-				throw new NotFound('Node not found');
327
-			});
328
-
329
-		$this->insertProps($user, "principals/users/$user", $existingProps);
330
-
331
-		$setProps = [];
332
-		$propFind->method('set')
333
-			->willReturnCallback(function ($name, $value, $status) use (&$setProps): void {
334
-				$setProps[$name] = $value;
335
-			});
336
-
337
-		$this->backend->propFind("principals/users/$user", $propFind);
338
-		$this->assertEquals($returnedProps, $setProps);
339
-	}
340
-
341
-	#[\PHPUnit\Framework\Attributes\DataProvider('propPatchProvider')]
342
-	public function testPropPatch(string $path, array $existing, array $props, array $result): void {
343
-		$this->server->method('calculateUri')
344
-			->willReturnCallback(function ($uri) {
345
-				if (str_starts_with($uri, self::BASE_URI)) {
346
-					return trim(substr($uri, strlen(self::BASE_URI)), '/');
347
-				}
348
-				return null;
349
-			});
350
-		$this->tree->method('getNodeForPath')
351
-			->willReturnCallback(function ($uri) {
352
-				$node = $this->createMock(Calendar::class);
353
-				$node->method('getOwner')
354
-					->willReturn('principals/users/' . $this->user->getUID());
355
-				return $node;
356
-			});
357
-
358
-		$this->insertProps($this->user->getUID(), $path, $existing);
359
-		$propPatch = new PropPatch($props);
360
-
361
-		$this->backend->propPatch($path, $propPatch);
362
-		$propPatch->commit();
363
-
364
-		$storedProps = $this->getProps($this->user->getUID(), $path);
365
-		$this->assertEquals($result, $storedProps);
366
-	}
367
-
368
-	public static function propPatchProvider(): array {
369
-		$longPath = str_repeat('long_path', 100);
370
-		return [
371
-			['foo_bar_path_1337', [], ['{DAV:}displayname' => 'anything'], ['{DAV:}displayname' => 'anything']],
372
-			['foo_bar_path_1337', ['{DAV:}displayname' => 'foo'], ['{DAV:}displayname' => 'anything'], ['{DAV:}displayname' => 'anything']],
373
-			['foo_bar_path_1337', ['{DAV:}displayname' => 'foo'], ['{DAV:}displayname' => null], []],
374
-			[$longPath, [], ['{DAV:}displayname' => 'anything'], ['{DAV:}displayname' => 'anything']],
375
-			['principals/users/dummy_user_42', [], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')]],
376
-			['principals/users/dummy_user_42', [], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href(self::BASE_URI . 'foo/bar/')], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')]],
377
-		];
378
-	}
379
-
380
-	public function testPropPatchWithUnsuitableCalendar(): void {
381
-		$path = 'principals/users/' . $this->user->getUID();
382
-
383
-		$node = $this->createMock(Calendar::class);
384
-		$node->expects(self::once())
385
-			->method('getOwner')
386
-			->willReturn($path);
387
-
388
-		$this->defaultCalendarValidator->expects(self::once())
389
-			->method('validateScheduleDefaultCalendar')
390
-			->with($node)
391
-			->willThrowException(new \Sabre\DAV\Exception('Invalid calendar'));
392
-
393
-		$this->server->method('calculateUri')
394
-			->willReturnCallback(function ($uri) {
395
-				if (str_starts_with($uri, self::BASE_URI)) {
396
-					return trim(substr($uri, strlen(self::BASE_URI)), '/');
397
-				}
398
-				return null;
399
-			});
400
-		$this->tree->expects(self::once())
401
-			->method('getNodeForPath')
402
-			->with('foo/bar/')
403
-			->willReturn($node);
404
-
405
-		$storedProps = $this->getProps($this->user->getUID(), $path);
406
-		$this->assertEquals([], $storedProps);
407
-
408
-		$propPatch = new PropPatch([
409
-			'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/'),
410
-		]);
411
-		$this->backend->propPatch($path, $propPatch);
412
-		try {
413
-			$propPatch->commit();
414
-		} catch (\Throwable $e) {
415
-			$this->assertInstanceOf(\Sabre\DAV\Exception::class, $e);
416
-		}
417
-
418
-		$storedProps = $this->getProps($this->user->getUID(), $path);
419
-		$this->assertEquals([], $storedProps);
420
-	}
421
-
422
-	#[\PHPUnit\Framework\Attributes\DataProvider('deleteProvider')]
423
-	public function testDelete(string $path): void {
424
-		$this->insertProps('dummy_user_42', $path, ['foo' => 'bar']);
425
-		$this->backend->delete($path);
426
-		$this->assertEquals([], $this->getProps('dummy_user_42', $path));
427
-	}
428
-
429
-	public static function deleteProvider(): array {
430
-		return [
431
-			['foo_bar_path_1337'],
432
-			[str_repeat('long_path', 100)]
433
-		];
434
-	}
435
-
436
-	#[\PHPUnit\Framework\Attributes\DataProvider('moveProvider')]
437
-	public function testMove(string $source, string $target): void {
438
-		$this->insertProps('dummy_user_42', $source, ['foo' => 'bar']);
439
-		$this->backend->move($source, $target);
440
-		$this->assertEquals([], $this->getProps('dummy_user_42', $source));
441
-		$this->assertEquals(['foo' => 'bar'], $this->getProps('dummy_user_42', $target));
442
-	}
443
-
444
-	public static function moveProvider(): array {
445
-		return [
446
-			['foo_bar_path_1337', 'foo_bar_path_7333'],
447
-			[str_repeat('long_path1', 100), str_repeat('long_path2', 100)]
448
-		];
449
-	}
450
-
451
-	public function testDecodeValueFromDatabaseObjectCurrent(): void {
452
-		$propertyValue = 'O:48:"Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp":1:{s:8:"\x00*\x00value";s:6:"opaque";}';
453
-		$propertyType = 3;
454
-		$decodeValue = $this->invokePrivate($this->backend, 'decodeValueFromDatabase', [$propertyValue, $propertyType]);
455
-		$this->assertInstanceOf(\Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp::class, $decodeValue);
456
-		$this->assertEquals('opaque', $decodeValue->getValue());
457
-	}
458
-
459
-	public function testDecodeValueFromDatabaseObjectLegacy(): void {
460
-		$propertyValue = 'O:48:"Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp":1:{s:8:"' . chr(0) . '*' . chr(0) . 'value";s:6:"opaque";}';
461
-		$propertyType = 3;
462
-		$decodeValue = $this->invokePrivate($this->backend, 'decodeValueFromDatabase', [$propertyValue, $propertyType]);
463
-		$this->assertInstanceOf(\Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp::class, $decodeValue);
464
-		$this->assertEquals('opaque', $decodeValue->getValue());
465
-	}
32
+    private const BASE_URI = '/remote.php/dav/';
33
+
34
+    private Server&MockObject $server;
35
+    private Tree&MockObject $tree;
36
+    private IDBConnection $dbConnection;
37
+    private IUser&MockObject $user;
38
+    private DefaultCalendarValidator&MockObject $defaultCalendarValidator;
39
+    private CustomPropertiesBackend $backend;
40
+    private PropertyMapper $propertyMapper;
41
+
42
+    protected function setUp(): void {
43
+        parent::setUp();
44
+
45
+        $this->server = $this->createMock(Server::class);
46
+        $this->server->method('getBaseUri')
47
+            ->willReturn(self::BASE_URI);
48
+        $this->tree = $this->createMock(Tree::class);
49
+        $this->user = $this->createMock(IUser::class);
50
+        $this->user->method('getUID')
51
+            ->with()
52
+            ->willReturn('dummy_user_42');
53
+        $this->dbConnection = \OCP\Server::get(IDBConnection::class);
54
+        $this->propertyMapper = \OCP\Server::get(PropertyMapper::class);
55
+        $this->defaultCalendarValidator = $this->createMock(DefaultCalendarValidator::class);
56
+
57
+        $this->backend = new CustomPropertiesBackend(
58
+            $this->server,
59
+            $this->tree,
60
+            $this->dbConnection,
61
+            $this->user,
62
+            $this->propertyMapper,
63
+            $this->defaultCalendarValidator,
64
+        );
65
+    }
66
+
67
+    protected function tearDown(): void {
68
+        $query = $this->dbConnection->getQueryBuilder();
69
+        $query->delete('properties');
70
+        $query->executeStatement();
71
+
72
+        parent::tearDown();
73
+    }
74
+
75
+    private function formatPath(string $path): string {
76
+        if (strlen($path) > 250) {
77
+            return sha1($path);
78
+        } else {
79
+            return $path;
80
+        }
81
+    }
82
+
83
+    protected function insertProps(string $user, string $path, array $props): void {
84
+        foreach ($props as $name => $value) {
85
+            $this->insertProp($user, $path, $name, $value);
86
+        }
87
+    }
88
+
89
+    protected function insertProp(string $user, string $path, string $name, mixed $value): void {
90
+        $type = CustomPropertiesBackend::PROPERTY_TYPE_STRING;
91
+        if ($value instanceof Href) {
92
+            $value = $value->getHref();
93
+            $type = CustomPropertiesBackend::PROPERTY_TYPE_HREF;
94
+        }
95
+
96
+        $query = $this->dbConnection->getQueryBuilder();
97
+        $query->insert('properties')
98
+            ->values([
99
+                'userid' => $query->createNamedParameter($user),
100
+                'propertypath' => $query->createNamedParameter($this->formatPath($path)),
101
+                'propertyname' => $query->createNamedParameter($name),
102
+                'propertyvalue' => $query->createNamedParameter($value),
103
+                'valuetype' => $query->createNamedParameter($type, IQueryBuilder::PARAM_INT)
104
+            ]);
105
+        $query->executeStatement();
106
+    }
107
+
108
+    protected function getProps(string $user, string $path): array {
109
+        $query = $this->dbConnection->getQueryBuilder();
110
+        $query->select('propertyname', 'propertyvalue', 'valuetype')
111
+            ->from('properties')
112
+            ->where($query->expr()->eq('userid', $query->createNamedParameter($user)))
113
+            ->andWhere($query->expr()->eq('propertypath', $query->createNamedParameter($this->formatPath($path))));
114
+
115
+        $result = $query->executeQuery();
116
+        $data = [];
117
+        while ($row = $result->fetch()) {
118
+            $value = $row['propertyvalue'];
119
+            if ((int)$row['valuetype'] === CustomPropertiesBackend::PROPERTY_TYPE_HREF) {
120
+                $value = new Href($value);
121
+            }
122
+            $data[$row['propertyname']] = $value;
123
+        }
124
+        $result->closeCursor();
125
+
126
+        return $data;
127
+    }
128
+
129
+    public function testPropFindNoDbCalls(): void {
130
+        $db = $this->createMock(IDBConnection::class);
131
+        $backend = new CustomPropertiesBackend(
132
+            $this->server,
133
+            $this->tree,
134
+            $db,
135
+            $this->user,
136
+            $this->propertyMapper,
137
+            $this->defaultCalendarValidator,
138
+        );
139
+
140
+        $propFind = $this->createMock(PropFind::class);
141
+        $propFind->expects($this->once())
142
+            ->method('get404Properties')
143
+            ->with()
144
+            ->willReturn([
145
+                '{http://owncloud.org/ns}permissions',
146
+                '{http://owncloud.org/ns}downloadURL',
147
+                '{http://owncloud.org/ns}dDC',
148
+                '{http://owncloud.org/ns}size',
149
+            ]);
150
+
151
+        $db->expects($this->never())
152
+            ->method($this->anything());
153
+
154
+        $backend->propFind('foo_bar_path_1337_0', $propFind);
155
+    }
156
+
157
+    public function testPropFindCalendarCall(): void {
158
+        $propFind = $this->createMock(PropFind::class);
159
+        $propFind->method('get404Properties')
160
+            ->with()
161
+            ->willReturn([
162
+                '{DAV:}getcontentlength',
163
+                '{DAV:}getcontenttype',
164
+                '{DAV:}getetag',
165
+                '{abc}def',
166
+            ]);
167
+
168
+        $propFind->method('getRequestedProperties')
169
+            ->with()
170
+            ->willReturn([
171
+                '{DAV:}getcontentlength',
172
+                '{DAV:}getcontenttype',
173
+                '{DAV:}getetag',
174
+                '{DAV:}displayname',
175
+                '{urn:ietf:params:xml:ns:caldav}calendar-description',
176
+                '{urn:ietf:params:xml:ns:caldav}calendar-timezone',
177
+                '{abc}def',
178
+            ]);
179
+
180
+        $props = [
181
+            '{abc}def' => 'a',
182
+            '{DAV:}displayname' => 'b',
183
+            '{urn:ietf:params:xml:ns:caldav}calendar-description' => 'c',
184
+            '{urn:ietf:params:xml:ns:caldav}calendar-timezone' => 'd',
185
+        ];
186
+
187
+        $this->insertProps('dummy_user_42', 'calendars/foo/bar_path_1337_0', $props);
188
+
189
+        $setProps = [];
190
+        $propFind->method('set')
191
+            ->willReturnCallback(function ($name, $value, $status) use (&$setProps): void {
192
+                $setProps[$name] = $value;
193
+            });
194
+
195
+        $this->backend->propFind('calendars/foo/bar_path_1337_0', $propFind);
196
+        $this->assertEquals($props, $setProps);
197
+    }
198
+
199
+    public function testPropFindPrincipalCall(): void {
200
+        $this->tree->method('getNodeForPath')
201
+            ->willReturnCallback(function ($uri) {
202
+                $node = $this->createMock(Calendar::class);
203
+                $node->method('getOwner')
204
+                    ->willReturn('principals/users/dummy_user_42');
205
+                return $node;
206
+            });
207
+
208
+        $propFind = $this->createMock(PropFind::class);
209
+        $propFind->method('get404Properties')
210
+            ->with()
211
+            ->willReturn([
212
+                '{DAV:}getcontentlength',
213
+                '{DAV:}getcontenttype',
214
+                '{DAV:}getetag',
215
+                '{abc}def',
216
+            ]);
217
+
218
+        $propFind->method('getRequestedProperties')
219
+            ->with()
220
+            ->willReturn([
221
+                '{DAV:}getcontentlength',
222
+                '{DAV:}getcontenttype',
223
+                '{DAV:}getetag',
224
+                '{abc}def',
225
+                '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
226
+            ]);
227
+
228
+        $props = [
229
+            '{abc}def' => 'a',
230
+            '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/admin/personal'),
231
+        ];
232
+        $this->insertProps('dummy_user_42', 'principals/users/dummy_user_42', $props);
233
+
234
+        $setProps = [];
235
+        $propFind->method('set')
236
+            ->willReturnCallback(function ($name, $value, $status) use (&$setProps): void {
237
+                $setProps[$name] = $value;
238
+            });
239
+
240
+        $this->backend->propFind('principals/users/dummy_user_42', $propFind);
241
+        $this->assertEquals($props, $setProps);
242
+    }
243
+
244
+    public static function propFindPrincipalScheduleDefaultCalendarProviderUrlProvider(): array {
245
+        // [ user, nodes, existingProps, requestedProps, returnedProps ]
246
+        return [
247
+            [ // Exists
248
+                'dummy_user_42',
249
+                ['calendars/dummy_user_42/foo/' => Calendar::class],
250
+                ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/foo/')],
251
+                ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
252
+                ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/foo/')],
253
+            ],
254
+            [ // Doesn't exist
255
+                'dummy_user_42',
256
+                ['calendars/dummy_user_42/foo/' => Calendar::class],
257
+                ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/bar/')],
258
+                ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
259
+                [],
260
+            ],
261
+            [ // No privilege
262
+                'dummy_user_42',
263
+                ['calendars/user2/baz/' => Calendar::class],
264
+                ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/user2/baz/')],
265
+                ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
266
+                [],
267
+            ],
268
+            [ // Not a calendar
269
+                'dummy_user_42',
270
+                ['foo/dummy_user_42/bar/' => IACL::class],
271
+                ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/dummy_user_42/bar/')],
272
+                ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
273
+                [],
274
+            ],
275
+        ];
276
+
277
+    }
278
+
279
+    #[\PHPUnit\Framework\Attributes\DataProvider('propFindPrincipalScheduleDefaultCalendarProviderUrlProvider')]
280
+    public function testPropFindPrincipalScheduleDefaultCalendarUrl(
281
+        string $user,
282
+        array $nodes,
283
+        array $existingProps,
284
+        array $requestedProps,
285
+        array $returnedProps,
286
+    ): void {
287
+        $propFind = $this->createMock(PropFind::class);
288
+        $propFind->method('get404Properties')
289
+            ->with()
290
+            ->willReturn([
291
+                '{DAV:}getcontentlength',
292
+                '{DAV:}getcontenttype',
293
+                '{DAV:}getetag',
294
+            ]);
295
+
296
+        $propFind->method('getRequestedProperties')
297
+            ->with()
298
+            ->willReturn(array_merge([
299
+                '{DAV:}getcontentlength',
300
+                '{DAV:}getcontenttype',
301
+                '{DAV:}getetag',
302
+                '{abc}def',
303
+            ],
304
+                $requestedProps,
305
+            ));
306
+
307
+        $this->server->method('calculateUri')
308
+            ->willReturnCallback(function ($uri) {
309
+                if (!str_starts_with($uri, self::BASE_URI)) {
310
+                    return trim(substr($uri, strlen(self::BASE_URI)), '/');
311
+                }
312
+                return null;
313
+            });
314
+        $this->tree->method('getNodeForPath')
315
+            ->willReturnCallback(function ($uri) use ($nodes) {
316
+                if (str_starts_with($uri, 'principals/')) {
317
+                    return $this->createMock(IPrincipal::class);
318
+                }
319
+                if (array_key_exists($uri, $nodes)) {
320
+                    $owner = explode('/', $uri)[1];
321
+                    $node = $this->createMock($nodes[$uri]);
322
+                    $node->method('getOwner')
323
+                        ->willReturn("principals/users/$owner");
324
+                    return $node;
325
+                }
326
+                throw new NotFound('Node not found');
327
+            });
328
+
329
+        $this->insertProps($user, "principals/users/$user", $existingProps);
330
+
331
+        $setProps = [];
332
+        $propFind->method('set')
333
+            ->willReturnCallback(function ($name, $value, $status) use (&$setProps): void {
334
+                $setProps[$name] = $value;
335
+            });
336
+
337
+        $this->backend->propFind("principals/users/$user", $propFind);
338
+        $this->assertEquals($returnedProps, $setProps);
339
+    }
340
+
341
+    #[\PHPUnit\Framework\Attributes\DataProvider('propPatchProvider')]
342
+    public function testPropPatch(string $path, array $existing, array $props, array $result): void {
343
+        $this->server->method('calculateUri')
344
+            ->willReturnCallback(function ($uri) {
345
+                if (str_starts_with($uri, self::BASE_URI)) {
346
+                    return trim(substr($uri, strlen(self::BASE_URI)), '/');
347
+                }
348
+                return null;
349
+            });
350
+        $this->tree->method('getNodeForPath')
351
+            ->willReturnCallback(function ($uri) {
352
+                $node = $this->createMock(Calendar::class);
353
+                $node->method('getOwner')
354
+                    ->willReturn('principals/users/' . $this->user->getUID());
355
+                return $node;
356
+            });
357
+
358
+        $this->insertProps($this->user->getUID(), $path, $existing);
359
+        $propPatch = new PropPatch($props);
360
+
361
+        $this->backend->propPatch($path, $propPatch);
362
+        $propPatch->commit();
363
+
364
+        $storedProps = $this->getProps($this->user->getUID(), $path);
365
+        $this->assertEquals($result, $storedProps);
366
+    }
367
+
368
+    public static function propPatchProvider(): array {
369
+        $longPath = str_repeat('long_path', 100);
370
+        return [
371
+            ['foo_bar_path_1337', [], ['{DAV:}displayname' => 'anything'], ['{DAV:}displayname' => 'anything']],
372
+            ['foo_bar_path_1337', ['{DAV:}displayname' => 'foo'], ['{DAV:}displayname' => 'anything'], ['{DAV:}displayname' => 'anything']],
373
+            ['foo_bar_path_1337', ['{DAV:}displayname' => 'foo'], ['{DAV:}displayname' => null], []],
374
+            [$longPath, [], ['{DAV:}displayname' => 'anything'], ['{DAV:}displayname' => 'anything']],
375
+            ['principals/users/dummy_user_42', [], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')]],
376
+            ['principals/users/dummy_user_42', [], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href(self::BASE_URI . 'foo/bar/')], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')]],
377
+        ];
378
+    }
379
+
380
+    public function testPropPatchWithUnsuitableCalendar(): void {
381
+        $path = 'principals/users/' . $this->user->getUID();
382
+
383
+        $node = $this->createMock(Calendar::class);
384
+        $node->expects(self::once())
385
+            ->method('getOwner')
386
+            ->willReturn($path);
387
+
388
+        $this->defaultCalendarValidator->expects(self::once())
389
+            ->method('validateScheduleDefaultCalendar')
390
+            ->with($node)
391
+            ->willThrowException(new \Sabre\DAV\Exception('Invalid calendar'));
392
+
393
+        $this->server->method('calculateUri')
394
+            ->willReturnCallback(function ($uri) {
395
+                if (str_starts_with($uri, self::BASE_URI)) {
396
+                    return trim(substr($uri, strlen(self::BASE_URI)), '/');
397
+                }
398
+                return null;
399
+            });
400
+        $this->tree->expects(self::once())
401
+            ->method('getNodeForPath')
402
+            ->with('foo/bar/')
403
+            ->willReturn($node);
404
+
405
+        $storedProps = $this->getProps($this->user->getUID(), $path);
406
+        $this->assertEquals([], $storedProps);
407
+
408
+        $propPatch = new PropPatch([
409
+            '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/'),
410
+        ]);
411
+        $this->backend->propPatch($path, $propPatch);
412
+        try {
413
+            $propPatch->commit();
414
+        } catch (\Throwable $e) {
415
+            $this->assertInstanceOf(\Sabre\DAV\Exception::class, $e);
416
+        }
417
+
418
+        $storedProps = $this->getProps($this->user->getUID(), $path);
419
+        $this->assertEquals([], $storedProps);
420
+    }
421
+
422
+    #[\PHPUnit\Framework\Attributes\DataProvider('deleteProvider')]
423
+    public function testDelete(string $path): void {
424
+        $this->insertProps('dummy_user_42', $path, ['foo' => 'bar']);
425
+        $this->backend->delete($path);
426
+        $this->assertEquals([], $this->getProps('dummy_user_42', $path));
427
+    }
428
+
429
+    public static function deleteProvider(): array {
430
+        return [
431
+            ['foo_bar_path_1337'],
432
+            [str_repeat('long_path', 100)]
433
+        ];
434
+    }
435
+
436
+    #[\PHPUnit\Framework\Attributes\DataProvider('moveProvider')]
437
+    public function testMove(string $source, string $target): void {
438
+        $this->insertProps('dummy_user_42', $source, ['foo' => 'bar']);
439
+        $this->backend->move($source, $target);
440
+        $this->assertEquals([], $this->getProps('dummy_user_42', $source));
441
+        $this->assertEquals(['foo' => 'bar'], $this->getProps('dummy_user_42', $target));
442
+    }
443
+
444
+    public static function moveProvider(): array {
445
+        return [
446
+            ['foo_bar_path_1337', 'foo_bar_path_7333'],
447
+            [str_repeat('long_path1', 100), str_repeat('long_path2', 100)]
448
+        ];
449
+    }
450
+
451
+    public function testDecodeValueFromDatabaseObjectCurrent(): void {
452
+        $propertyValue = 'O:48:"Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp":1:{s:8:"\x00*\x00value";s:6:"opaque";}';
453
+        $propertyType = 3;
454
+        $decodeValue = $this->invokePrivate($this->backend, 'decodeValueFromDatabase', [$propertyValue, $propertyType]);
455
+        $this->assertInstanceOf(\Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp::class, $decodeValue);
456
+        $this->assertEquals('opaque', $decodeValue->getValue());
457
+    }
458
+
459
+    public function testDecodeValueFromDatabaseObjectLegacy(): void {
460
+        $propertyValue = 'O:48:"Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp":1:{s:8:"' . chr(0) . '*' . chr(0) . 'value";s:6:"opaque";}';
461
+        $propertyType = 3;
462
+        $decodeValue = $this->invokePrivate($this->backend, 'decodeValueFromDatabase', [$propertyValue, $propertyType]);
463
+        $this->assertInstanceOf(\Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp::class, $decodeValue);
464
+        $this->assertEquals('opaque', $decodeValue->getValue());
465
+    }
466 466
 }
Please login to merge, or discard this patch.
apps/dav/lib/DAV/CustomPropertiesBackend.php 2 patches
Indentation   +634 added lines, -634 removed lines patch added patch discarded remove patch
@@ -40,638 +40,638 @@
 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
-	/**
570
-	 * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails
571
-	 * @throws DavException If the property value is invalid
572
-	 */
573
-	private function encodeValueForDatabase(string $path, string $name, mixed $value): array {
574
-		// Try to parse a more specialized property type first
575
-		if ($value instanceof Complex) {
576
-			$xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri());
577
-			$value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value;
578
-		}
579
-
580
-		if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') {
581
-			$value = $this->encodeDefaultCalendarUrl($value);
582
-		}
583
-
584
-		try {
585
-			$this->validateProperty($path, $name, $value);
586
-		} catch (DavException $e) {
587
-			throw new DavException(
588
-				"Property \"$name\" has an invalid value: " . $e->getMessage(),
589
-				0,
590
-				$e,
591
-			);
592
-		}
593
-
594
-		if (is_scalar($value)) {
595
-			$valueType = self::PROPERTY_TYPE_STRING;
596
-		} elseif ($value instanceof Complex) {
597
-			$valueType = self::PROPERTY_TYPE_XML;
598
-			$value = $value->getXml();
599
-		} elseif ($value instanceof Href) {
600
-			$valueType = self::PROPERTY_TYPE_HREF;
601
-			$value = $value->getHref();
602
-		} else {
603
-			$valueType = self::PROPERTY_TYPE_OBJECT;
604
-			// serialize produces null character
605
-			// these can not be properly stored in some databases and need to be replaced
606
-			$value = str_replace(chr(0), '\x00', serialize($value));
607
-		}
608
-		return [$value, $valueType];
609
-	}
610
-
611
-	/**
612
-	 * @return mixed|Complex|string
613
-	 */
614
-	private function decodeValueFromDatabase(string $value, int $valueType): mixed {
615
-		return match ($valueType) {
616
-			self::PROPERTY_TYPE_XML => new Complex($value),
617
-			self::PROPERTY_TYPE_HREF => new Href($value),
618
-			// some databases can not handel null characters, these are custom encoded during serialization
619
-			// this custom encoding needs to be first reversed before unserializing
620
-			self::PROPERTY_TYPE_OBJECT => unserialize(str_replace('\x00', chr(0), $value)),
621
-			default => $value,
622
-		};
623
-	}
624
-
625
-	private function encodeDefaultCalendarUrl(Href $value): Href {
626
-		$href = $value->getHref();
627
-		if ($href === null) {
628
-			return $value;
629
-		}
630
-
631
-		if (!str_starts_with($href, '/')) {
632
-			return $value;
633
-		}
634
-
635
-		try {
636
-			// Build path relative to the dav base URI to be used later to find the node
637
-			$value = new LocalHref($this->server->calculateUri($href) . '/');
638
-		} catch (DavException\Forbidden) {
639
-			// Not existing calendars will be handled later when the value is validated
640
-		}
641
-
642
-		return $value;
643
-	}
644
-
645
-	private function createDeleteQuery(): IQueryBuilder {
646
-		$deleteQuery = $this->connection->getQueryBuilder();
647
-		$deleteQuery->delete('properties')
648
-			->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
649
-			->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
650
-			->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
651
-		return $deleteQuery;
652
-	}
653
-
654
-	private function createInsertQuery(): IQueryBuilder {
655
-		$insertQuery = $this->connection->getQueryBuilder();
656
-		$insertQuery->insert('properties')
657
-			->values([
658
-				'userid' => $insertQuery->createParameter('userid'),
659
-				'propertypath' => $insertQuery->createParameter('propertyPath'),
660
-				'propertyname' => $insertQuery->createParameter('propertyName'),
661
-				'propertyvalue' => $insertQuery->createParameter('propertyValue'),
662
-				'valuetype' => $insertQuery->createParameter('valueType'),
663
-			]);
664
-		return $insertQuery;
665
-	}
666
-
667
-	private function createUpdateQuery(): IQueryBuilder {
668
-		$updateQuery = $this->connection->getQueryBuilder();
669
-		$updateQuery->update('properties')
670
-			->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
671
-			->set('valuetype', $updateQuery->createParameter('valueType'))
672
-			->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
673
-			->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
674
-			->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
675
-		return $updateQuery;
676
-	}
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
+    /**
570
+     * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails
571
+     * @throws DavException If the property value is invalid
572
+     */
573
+    private function encodeValueForDatabase(string $path, string $name, mixed $value): array {
574
+        // Try to parse a more specialized property type first
575
+        if ($value instanceof Complex) {
576
+            $xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri());
577
+            $value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value;
578
+        }
579
+
580
+        if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') {
581
+            $value = $this->encodeDefaultCalendarUrl($value);
582
+        }
583
+
584
+        try {
585
+            $this->validateProperty($path, $name, $value);
586
+        } catch (DavException $e) {
587
+            throw new DavException(
588
+                "Property \"$name\" has an invalid value: " . $e->getMessage(),
589
+                0,
590
+                $e,
591
+            );
592
+        }
593
+
594
+        if (is_scalar($value)) {
595
+            $valueType = self::PROPERTY_TYPE_STRING;
596
+        } elseif ($value instanceof Complex) {
597
+            $valueType = self::PROPERTY_TYPE_XML;
598
+            $value = $value->getXml();
599
+        } elseif ($value instanceof Href) {
600
+            $valueType = self::PROPERTY_TYPE_HREF;
601
+            $value = $value->getHref();
602
+        } else {
603
+            $valueType = self::PROPERTY_TYPE_OBJECT;
604
+            // serialize produces null character
605
+            // these can not be properly stored in some databases and need to be replaced
606
+            $value = str_replace(chr(0), '\x00', serialize($value));
607
+        }
608
+        return [$value, $valueType];
609
+    }
610
+
611
+    /**
612
+     * @return mixed|Complex|string
613
+     */
614
+    private function decodeValueFromDatabase(string $value, int $valueType): mixed {
615
+        return match ($valueType) {
616
+            self::PROPERTY_TYPE_XML => new Complex($value),
617
+            self::PROPERTY_TYPE_HREF => new Href($value),
618
+            // some databases can not handel null characters, these are custom encoded during serialization
619
+            // this custom encoding needs to be first reversed before unserializing
620
+            self::PROPERTY_TYPE_OBJECT => unserialize(str_replace('\x00', chr(0), $value)),
621
+            default => $value,
622
+        };
623
+    }
624
+
625
+    private function encodeDefaultCalendarUrl(Href $value): Href {
626
+        $href = $value->getHref();
627
+        if ($href === null) {
628
+            return $value;
629
+        }
630
+
631
+        if (!str_starts_with($href, '/')) {
632
+            return $value;
633
+        }
634
+
635
+        try {
636
+            // Build path relative to the dav base URI to be used later to find the node
637
+            $value = new LocalHref($this->server->calculateUri($href) . '/');
638
+        } catch (DavException\Forbidden) {
639
+            // Not existing calendars will be handled later when the value is validated
640
+        }
641
+
642
+        return $value;
643
+    }
644
+
645
+    private function createDeleteQuery(): IQueryBuilder {
646
+        $deleteQuery = $this->connection->getQueryBuilder();
647
+        $deleteQuery->delete('properties')
648
+            ->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
649
+            ->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
650
+            ->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
651
+        return $deleteQuery;
652
+    }
653
+
654
+    private function createInsertQuery(): IQueryBuilder {
655
+        $insertQuery = $this->connection->getQueryBuilder();
656
+        $insertQuery->insert('properties')
657
+            ->values([
658
+                'userid' => $insertQuery->createParameter('userid'),
659
+                'propertypath' => $insertQuery->createParameter('propertyPath'),
660
+                'propertyname' => $insertQuery->createParameter('propertyName'),
661
+                'propertyvalue' => $insertQuery->createParameter('propertyValue'),
662
+                'valuetype' => $insertQuery->createParameter('valueType'),
663
+            ]);
664
+        return $insertQuery;
665
+    }
666
+
667
+    private function createUpdateQuery(): IQueryBuilder {
668
+        $updateQuery = $this->connection->getQueryBuilder();
669
+        $updateQuery->update('properties')
670
+            ->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
671
+            ->set('valuetype', $updateQuery->createParameter('valueType'))
672
+            ->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
673
+            ->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
674
+            ->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
675
+        return $updateQuery;
676
+    }
677 677
 }
Please login to merge, or discard this patch.
Spacing   +9 added lines, -9 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
 		}
@@ -585,7 +585,7 @@  discard block
 block discarded – undo
585 585
 			$this->validateProperty($path, $name, $value);
586 586
 		} catch (DavException $e) {
587 587
 			throw new DavException(
588
-				"Property \"$name\" has an invalid value: " . $e->getMessage(),
588
+				"Property \"$name\" has an invalid value: ".$e->getMessage(),
589 589
 				0,
590 590
 				$e,
591 591
 			);
@@ -634,7 +634,7 @@  discard block
 block discarded – undo
634 634
 
635 635
 		try {
636 636
 			// Build path relative to the dav base URI to be used later to find the node
637
-			$value = new LocalHref($this->server->calculateUri($href) . '/');
637
+			$value = new LocalHref($this->server->calculateUri($href).'/');
638 638
 		} catch (DavException\Forbidden) {
639 639
 			// Not existing calendars will be handled later when the value is validated
640 640
 		}
Please login to merge, or discard this patch.