Completed
Push — master ( d73537...38327e )
by
unknown
34:40 queued 12:15
created
apps/dav/lib/CardDAV/CardDavBackend.php 2 patches
Indentation   +1483 added lines, -1483 removed lines patch added patch discarded remove patch
@@ -35,926 +35,926 @@  discard block
 block discarded – undo
35 35
 use Sabre\VObject\Reader;
36 36
 
37 37
 class CardDavBackend implements BackendInterface, SyncSupport {
38
-	use TTransactional;
39
-	public const PERSONAL_ADDRESSBOOK_URI = 'contacts';
40
-	public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts';
41
-
42
-	private string $dbCardsTable = 'cards';
43
-	private string $dbCardsPropertiesTable = 'cards_properties';
44
-
45
-	/** @var array properties to index */
46
-	public static array $indexProperties = [
47
-		'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME',
48
-		'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO',
49
-		'CLOUD', 'X-SOCIALPROFILE'];
50
-
51
-	/**
52
-	 * @var string[] Map of uid => display name
53
-	 */
54
-	protected array $userDisplayNames;
55
-	private array $etagCache = [];
56
-
57
-	public function __construct(
58
-		private IDBConnection $db,
59
-		private Principal $principalBackend,
60
-		private IUserManager $userManager,
61
-		private IEventDispatcher $dispatcher,
62
-		private Sharing\Backend $sharingBackend,
63
-		private IConfig $config,
64
-	) {
65
-	}
66
-
67
-	/**
68
-	 * Return the number of address books for a principal
69
-	 *
70
-	 * @param $principalUri
71
-	 * @return int
72
-	 */
73
-	public function getAddressBooksForUserCount($principalUri) {
74
-		$principalUri = $this->convertPrincipal($principalUri, true);
75
-		$query = $this->db->getQueryBuilder();
76
-		$query->select($query->func()->count('*'))
77
-			->from('addressbooks')
78
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
79
-
80
-		$result = $query->executeQuery();
81
-		$column = (int)$result->fetchOne();
82
-		$result->closeCursor();
83
-		return $column;
84
-	}
85
-
86
-	/**
87
-	 * Returns the list of address books for a specific user.
88
-	 *
89
-	 * Every addressbook should have the following properties:
90
-	 *   id - an arbitrary unique id
91
-	 *   uri - the 'basename' part of the url
92
-	 *   principaluri - Same as the passed parameter
93
-	 *
94
-	 * Any additional clark-notation property may be passed besides this. Some
95
-	 * common ones are :
96
-	 *   {DAV:}displayname
97
-	 *   {urn:ietf:params:xml:ns:carddav}addressbook-description
98
-	 *   {http://calendarserver.org/ns/}getctag
99
-	 *
100
-	 * @param string $principalUri
101
-	 * @return array
102
-	 */
103
-	public function getAddressBooksForUser($principalUri) {
104
-		return $this->atomic(function () use ($principalUri) {
105
-			$principalUriOriginal = $principalUri;
106
-			$principalUri = $this->convertPrincipal($principalUri, true);
107
-			$select = $this->db->getQueryBuilder();
108
-			$select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
109
-				->from('addressbooks')
110
-				->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri)));
111
-
112
-			$addressBooks = [];
113
-
114
-			$result = $select->executeQuery();
115
-			while ($row = $result->fetch()) {
116
-				$addressBooks[$row['id']] = [
117
-					'id' => $row['id'],
118
-					'uri' => $row['uri'],
119
-					'principaluri' => $this->convertPrincipal($row['principaluri'], false),
120
-					'{DAV:}displayname' => $row['displayname'],
121
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
122
-					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
123
-					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
124
-				];
125
-
126
-				$this->addOwnerPrincipal($addressBooks[$row['id']]);
127
-			}
128
-			$result->closeCursor();
129
-
130
-			// query for shared addressbooks
131
-			$principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
132
-
133
-			$principals[] = $principalUri;
134
-
135
-			$select = $this->db->getQueryBuilder();
136
-			$subSelect = $this->db->getQueryBuilder();
137
-
138
-			$subSelect->select('id')
139
-				->from('dav_shares', 'd')
140
-				->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
141
-				->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY));
142
-
143
-
144
-			$select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
145
-				->from('dav_shares', 's')
146
-				->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id'))
147
-				->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY)))
148
-				->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR)))
149
-				->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
150
-			$result = $select->executeQuery();
151
-
152
-			$readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
153
-			while ($row = $result->fetch()) {
154
-				if ($row['principaluri'] === $principalUri) {
155
-					continue;
156
-				}
157
-
158
-				$readOnly = (int)$row['access'] === Backend::ACCESS_READ;
159
-				if (isset($addressBooks[$row['id']])) {
160
-					if ($readOnly) {
161
-						// New share can not have more permissions then the old one.
162
-						continue;
163
-					}
164
-					if (isset($addressBooks[$row['id']][$readOnlyPropertyName])
165
-						&& $addressBooks[$row['id']][$readOnlyPropertyName] === 0) {
166
-						// Old share is already read-write, no more permissions can be gained
167
-						continue;
168
-					}
169
-				}
170
-
171
-				[, $name] = \Sabre\Uri\split($row['principaluri']);
172
-				$uri = $row['uri'] . '_shared_by_' . $name;
173
-				$displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')';
174
-
175
-				$addressBooks[$row['id']] = [
176
-					'id' => $row['id'],
177
-					'uri' => $uri,
178
-					'principaluri' => $principalUriOriginal,
179
-					'{DAV:}displayname' => $displayName,
180
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
181
-					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
182
-					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
183
-					'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
184
-					$readOnlyPropertyName => $readOnly,
185
-				];
186
-
187
-				$this->addOwnerPrincipal($addressBooks[$row['id']]);
188
-			}
189
-			$result->closeCursor();
190
-
191
-			return array_values($addressBooks);
192
-		}, $this->db);
193
-	}
194
-
195
-	public function getUsersOwnAddressBooks($principalUri) {
196
-		$principalUri = $this->convertPrincipal($principalUri, true);
197
-		$query = $this->db->getQueryBuilder();
198
-		$query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
199
-			->from('addressbooks')
200
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
201
-
202
-		$addressBooks = [];
203
-
204
-		$result = $query->executeQuery();
205
-		while ($row = $result->fetch()) {
206
-			$addressBooks[$row['id']] = [
207
-				'id' => $row['id'],
208
-				'uri' => $row['uri'],
209
-				'principaluri' => $this->convertPrincipal($row['principaluri'], false),
210
-				'{DAV:}displayname' => $row['displayname'],
211
-				'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
212
-				'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
213
-				'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
214
-			];
215
-
216
-			$this->addOwnerPrincipal($addressBooks[$row['id']]);
217
-		}
218
-		$result->closeCursor();
219
-
220
-		return array_values($addressBooks);
221
-	}
222
-
223
-	/**
224
-	 * @param int $addressBookId
225
-	 */
226
-	public function getAddressBookById(int $addressBookId): ?array {
227
-		$query = $this->db->getQueryBuilder();
228
-		$result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
229
-			->from('addressbooks')
230
-			->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
231
-			->executeQuery();
232
-		$row = $result->fetch();
233
-		$result->closeCursor();
234
-		if (!$row) {
235
-			return null;
236
-		}
237
-
238
-		$addressBook = [
239
-			'id' => $row['id'],
240
-			'uri' => $row['uri'],
241
-			'principaluri' => $row['principaluri'],
242
-			'{DAV:}displayname' => $row['displayname'],
243
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
244
-			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
245
-			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
246
-		];
247
-
248
-		$this->addOwnerPrincipal($addressBook);
249
-
250
-		return $addressBook;
251
-	}
252
-
253
-	public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array {
254
-		$query = $this->db->getQueryBuilder();
255
-		$result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
256
-			->from('addressbooks')
257
-			->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri)))
258
-			->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
259
-			->setMaxResults(1)
260
-			->executeQuery();
261
-
262
-		$row = $result->fetch();
263
-		$result->closeCursor();
264
-		if ($row === false) {
265
-			return null;
266
-		}
267
-
268
-		$addressBook = [
269
-			'id' => $row['id'],
270
-			'uri' => $row['uri'],
271
-			'principaluri' => $row['principaluri'],
272
-			'{DAV:}displayname' => $row['displayname'],
273
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
274
-			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
275
-			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
276
-
277
-		];
278
-
279
-		// system address books are always read only
280
-		if ($principal === 'principals/system/system') {
281
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri'];
282
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true;
283
-		}
284
-
285
-		$this->addOwnerPrincipal($addressBook);
286
-
287
-		return $addressBook;
288
-	}
289
-
290
-	/**
291
-	 * Updates properties for an address book.
292
-	 *
293
-	 * The list of mutations is stored in a Sabre\DAV\PropPatch object.
294
-	 * To do the actual updates, you must tell this object which properties
295
-	 * you're going to process with the handle() method.
296
-	 *
297
-	 * Calling the handle method is like telling the PropPatch object "I
298
-	 * promise I can handle updating this property".
299
-	 *
300
-	 * Read the PropPatch documentation for more info and examples.
301
-	 *
302
-	 * @param string $addressBookId
303
-	 * @param \Sabre\DAV\PropPatch $propPatch
304
-	 * @return void
305
-	 */
306
-	public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
307
-		$supportedProperties = [
308
-			'{DAV:}displayname',
309
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description',
310
-		];
311
-
312
-		$propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
313
-			$updates = [];
314
-			foreach ($mutations as $property => $newValue) {
315
-				switch ($property) {
316
-					case '{DAV:}displayname':
317
-						$updates['displayname'] = $newValue;
318
-						break;
319
-					case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
320
-						$updates['description'] = $newValue;
321
-						break;
322
-				}
323
-			}
324
-			[$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) {
325
-				$query = $this->db->getQueryBuilder();
326
-				$query->update('addressbooks');
327
-
328
-				foreach ($updates as $key => $value) {
329
-					$query->set($key, $query->createNamedParameter($value));
330
-				}
331
-				$query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
332
-					->executeStatement();
333
-
334
-				$this->addChange($addressBookId, '', 2);
335
-
336
-				$addressBookRow = $this->getAddressBookById((int)$addressBookId);
337
-				$shares = $this->getShares((int)$addressBookId);
338
-				return [$addressBookRow, $shares];
339
-			}, $this->db);
340
-
341
-			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
342
-
343
-			return true;
344
-		});
345
-	}
346
-
347
-	/**
348
-	 * Creates a new address book
349
-	 *
350
-	 * @param string $principalUri
351
-	 * @param string $url Just the 'basename' of the url.
352
-	 * @param array $properties
353
-	 * @return int
354
-	 * @throws BadRequest
355
-	 * @throws Exception
356
-	 */
357
-	public function createAddressBook($principalUri, $url, array $properties) {
358
-		if (strlen($url) > 255) {
359
-			throw new BadRequest('URI too long. Address book not created');
360
-		}
361
-
362
-		$values = [
363
-			'displayname' => null,
364
-			'description' => null,
365
-			'principaluri' => $principalUri,
366
-			'uri' => $url,
367
-			'synctoken' => 1
368
-		];
369
-
370
-		foreach ($properties as $property => $newValue) {
371
-			switch ($property) {
372
-				case '{DAV:}displayname':
373
-					$values['displayname'] = $newValue;
374
-					break;
375
-				case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
376
-					$values['description'] = $newValue;
377
-					break;
378
-				default:
379
-					throw new BadRequest('Unknown property: ' . $property);
380
-			}
381
-		}
382
-
383
-		// Fallback to make sure the displayname is set. Some clients may refuse
384
-		// to work with addressbooks not having a displayname.
385
-		if (is_null($values['displayname'])) {
386
-			$values['displayname'] = $url;
387
-		}
388
-
389
-		[$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) {
390
-			$query = $this->db->getQueryBuilder();
391
-			$query->insert('addressbooks')
392
-				->values([
393
-					'uri' => $query->createParameter('uri'),
394
-					'displayname' => $query->createParameter('displayname'),
395
-					'description' => $query->createParameter('description'),
396
-					'principaluri' => $query->createParameter('principaluri'),
397
-					'synctoken' => $query->createParameter('synctoken'),
398
-				])
399
-				->setParameters($values)
400
-				->executeStatement();
401
-
402
-			$addressBookId = $query->getLastInsertId();
403
-			return [
404
-				$addressBookId,
405
-				$this->getAddressBookById($addressBookId),
406
-			];
407
-		}, $this->db);
408
-
409
-		$this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow));
410
-
411
-		return $addressBookId;
412
-	}
413
-
414
-	/**
415
-	 * Deletes an entire addressbook and all its contents
416
-	 *
417
-	 * @param mixed $addressBookId
418
-	 * @return void
419
-	 */
420
-	public function deleteAddressBook($addressBookId) {
421
-		$this->atomic(function () use ($addressBookId): void {
422
-			$addressBookId = (int)$addressBookId;
423
-			$addressBookData = $this->getAddressBookById($addressBookId);
424
-			$shares = $this->getShares($addressBookId);
425
-
426
-			$query = $this->db->getQueryBuilder();
427
-			$query->delete($this->dbCardsTable)
428
-				->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
429
-				->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
430
-				->executeStatement();
431
-
432
-			$query = $this->db->getQueryBuilder();
433
-			$query->delete('addressbookchanges')
434
-				->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
435
-				->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
436
-				->executeStatement();
437
-
438
-			$query = $this->db->getQueryBuilder();
439
-			$query->delete('addressbooks')
440
-				->where($query->expr()->eq('id', $query->createParameter('id')))
441
-				->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT)
442
-				->executeStatement();
443
-
444
-			$this->sharingBackend->deleteAllShares($addressBookId);
445
-
446
-			$query = $this->db->getQueryBuilder();
447
-			$query->delete($this->dbCardsPropertiesTable)
448
-				->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
449
-				->executeStatement();
450
-
451
-			if ($addressBookData) {
452
-				$this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares));
453
-			}
454
-		}, $this->db);
455
-	}
456
-
457
-	/**
458
-	 * Returns all cards for a specific addressbook id.
459
-	 *
460
-	 * This method should return the following properties for each card:
461
-	 *   * carddata - raw vcard data
462
-	 *   * uri - Some unique url
463
-	 *   * lastmodified - A unix timestamp
464
-	 *
465
-	 * It's recommended to also return the following properties:
466
-	 *   * etag - A unique etag. This must change every time the card changes.
467
-	 *   * size - The size of the card in bytes.
468
-	 *
469
-	 * If these last two properties are provided, less time will be spent
470
-	 * calculating them. If they are specified, you can also omit carddata.
471
-	 * This may speed up certain requests, especially with large cards.
472
-	 *
473
-	 * @param mixed $addressbookId
474
-	 * @return array
475
-	 */
476
-	public function getCards($addressbookId) {
477
-		$query = $this->db->getQueryBuilder();
478
-		$query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
479
-			->from($this->dbCardsTable)
480
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId)));
481
-
482
-		$cards = [];
483
-
484
-		$result = $query->executeQuery();
485
-		while ($row = $result->fetch()) {
486
-			$row['etag'] = '"' . $row['etag'] . '"';
487
-
488
-			$modified = false;
489
-			$row['carddata'] = $this->readBlob($row['carddata'], $modified);
490
-			if ($modified) {
491
-				$row['size'] = strlen($row['carddata']);
492
-			}
493
-
494
-			$cards[] = $row;
495
-		}
496
-		$result->closeCursor();
497
-
498
-		return $cards;
499
-	}
500
-
501
-	/**
502
-	 * Returns a specific card.
503
-	 *
504
-	 * The same set of properties must be returned as with getCards. The only
505
-	 * exception is that 'carddata' is absolutely required.
506
-	 *
507
-	 * If the card does not exist, you must return false.
508
-	 *
509
-	 * @param mixed $addressBookId
510
-	 * @param string $cardUri
511
-	 * @return array
512
-	 */
513
-	public function getCard($addressBookId, $cardUri) {
514
-		$query = $this->db->getQueryBuilder();
515
-		$query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
516
-			->from($this->dbCardsTable)
517
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
518
-			->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
519
-			->setMaxResults(1);
520
-
521
-		$result = $query->executeQuery();
522
-		$row = $result->fetch();
523
-		if (!$row) {
524
-			return false;
525
-		}
526
-		$row['etag'] = '"' . $row['etag'] . '"';
527
-
528
-		$modified = false;
529
-		$row['carddata'] = $this->readBlob($row['carddata'], $modified);
530
-		if ($modified) {
531
-			$row['size'] = strlen($row['carddata']);
532
-		}
533
-
534
-		return $row;
535
-	}
536
-
537
-	/**
538
-	 * Returns a list of cards.
539
-	 *
540
-	 * This method should work identical to getCard, but instead return all the
541
-	 * cards in the list as an array.
542
-	 *
543
-	 * If the backend supports this, it may allow for some speed-ups.
544
-	 *
545
-	 * @param mixed $addressBookId
546
-	 * @param array $uris
547
-	 * @return array
548
-	 */
549
-	public function getMultipleCards($addressBookId, array $uris) {
550
-		if (empty($uris)) {
551
-			return [];
552
-		}
553
-
554
-		$chunks = array_chunk($uris, 100);
555
-		$cards = [];
556
-
557
-		$query = $this->db->getQueryBuilder();
558
-		$query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
559
-			->from($this->dbCardsTable)
560
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
561
-			->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
562
-
563
-		foreach ($chunks as $uris) {
564
-			$query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY);
565
-			$result = $query->executeQuery();
566
-
567
-			while ($row = $result->fetch()) {
568
-				$row['etag'] = '"' . $row['etag'] . '"';
569
-
570
-				$modified = false;
571
-				$row['carddata'] = $this->readBlob($row['carddata'], $modified);
572
-				if ($modified) {
573
-					$row['size'] = strlen($row['carddata']);
574
-				}
575
-
576
-				$cards[] = $row;
577
-			}
578
-			$result->closeCursor();
579
-		}
580
-		return $cards;
581
-	}
582
-
583
-	/**
584
-	 * Creates a new card.
585
-	 *
586
-	 * The addressbook id will be passed as the first argument. This is the
587
-	 * same id as it is returned from the getAddressBooksForUser method.
588
-	 *
589
-	 * The cardUri is a base uri, and doesn't include the full path. The
590
-	 * cardData argument is the vcard body, and is passed as a string.
591
-	 *
592
-	 * It is possible to return an ETag from this method. This ETag is for the
593
-	 * newly created resource, and must be enclosed with double quotes (that
594
-	 * is, the string itself must contain the double quotes).
595
-	 *
596
-	 * You should only return the ETag if you store the carddata as-is. If a
597
-	 * subsequent GET request on the same card does not have the same body,
598
-	 * byte-by-byte and you did return an ETag here, clients tend to get
599
-	 * confused.
600
-	 *
601
-	 * If you don't return an ETag, you can just return null.
602
-	 *
603
-	 * @param mixed $addressBookId
604
-	 * @param string $cardUri
605
-	 * @param string $cardData
606
-	 * @param bool $checkAlreadyExists
607
-	 * @return string
608
-	 */
609
-	public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
610
-		$etag = md5($cardData);
611
-		$uid = $this->getUID($cardData);
612
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
613
-			if ($checkAlreadyExists) {
614
-				$q = $this->db->getQueryBuilder();
615
-				$q->select('uid')
616
-					->from($this->dbCardsTable)
617
-					->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
618
-					->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
619
-					->setMaxResults(1);
620
-				$result = $q->executeQuery();
621
-				$count = (bool)$result->fetchOne();
622
-				$result->closeCursor();
623
-				if ($count) {
624
-					throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
625
-				}
626
-			}
627
-
628
-			$query = $this->db->getQueryBuilder();
629
-			$query->insert('cards')
630
-				->values([
631
-					'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB),
632
-					'uri' => $query->createNamedParameter($cardUri),
633
-					'lastmodified' => $query->createNamedParameter(time()),
634
-					'addressbookid' => $query->createNamedParameter($addressBookId),
635
-					'size' => $query->createNamedParameter(strlen($cardData)),
636
-					'etag' => $query->createNamedParameter($etag),
637
-					'uid' => $query->createNamedParameter($uid),
638
-				])
639
-				->executeStatement();
640
-
641
-			$etagCacheKey = "$addressBookId#$cardUri";
642
-			$this->etagCache[$etagCacheKey] = $etag;
643
-
644
-			$this->addChange($addressBookId, $cardUri, 1);
645
-			$this->updateProperties($addressBookId, $cardUri, $cardData);
646
-
647
-			$addressBookData = $this->getAddressBookById($addressBookId);
648
-			$shares = $this->getShares($addressBookId);
649
-			$objectRow = $this->getCard($addressBookId, $cardUri);
650
-			$this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
651
-
652
-			return '"' . $etag . '"';
653
-		}, $this->db);
654
-	}
655
-
656
-	/**
657
-	 * Updates a card.
658
-	 *
659
-	 * The addressbook id will be passed as the first argument. This is the
660
-	 * same id as it is returned from the getAddressBooksForUser method.
661
-	 *
662
-	 * The cardUri is a base uri, and doesn't include the full path. The
663
-	 * cardData argument is the vcard body, and is passed as a string.
664
-	 *
665
-	 * It is possible to return an ETag from this method. This ETag should
666
-	 * match that of the updated resource, and must be enclosed with double
667
-	 * quotes (that is: the string itself must contain the actual quotes).
668
-	 *
669
-	 * You should only return the ETag if you store the carddata as-is. If a
670
-	 * subsequent GET request on the same card does not have the same body,
671
-	 * byte-by-byte and you did return an ETag here, clients tend to get
672
-	 * confused.
673
-	 *
674
-	 * If you don't return an ETag, you can just return null.
675
-	 *
676
-	 * @param mixed $addressBookId
677
-	 * @param string $cardUri
678
-	 * @param string $cardData
679
-	 * @return string
680
-	 */
681
-	public function updateCard($addressBookId, $cardUri, $cardData) {
682
-		$uid = $this->getUID($cardData);
683
-		$etag = md5($cardData);
684
-
685
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
686
-			$query = $this->db->getQueryBuilder();
687
-
688
-			// check for recently stored etag and stop if it is the same
689
-			$etagCacheKey = "$addressBookId#$cardUri";
690
-			if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
691
-				return '"' . $etag . '"';
692
-			}
693
-
694
-			$query->update($this->dbCardsTable)
695
-				->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB))
696
-				->set('lastmodified', $query->createNamedParameter(time()))
697
-				->set('size', $query->createNamedParameter(strlen($cardData)))
698
-				->set('etag', $query->createNamedParameter($etag))
699
-				->set('uid', $query->createNamedParameter($uid))
700
-				->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
701
-				->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
702
-				->executeStatement();
703
-
704
-			$this->etagCache[$etagCacheKey] = $etag;
705
-
706
-			$this->addChange($addressBookId, $cardUri, 2);
707
-			$this->updateProperties($addressBookId, $cardUri, $cardData);
708
-
709
-			$addressBookData = $this->getAddressBookById($addressBookId);
710
-			$shares = $this->getShares($addressBookId);
711
-			$objectRow = $this->getCard($addressBookId, $cardUri);
712
-			$this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
713
-			return '"' . $etag . '"';
714
-		}, $this->db);
715
-	}
716
-
717
-	/**
718
-	 * @throws Exception
719
-	 */
720
-	public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool {
721
-		return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
722
-			$card = $this->getCard($sourceAddressBookId, $sourceObjectUri);
723
-			if (empty($card)) {
724
-				return false;
725
-			}
726
-			$sourceObjectId = (int)$card['id'];
727
-
728
-			$query = $this->db->getQueryBuilder();
729
-			$query->update('cards')
730
-				->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT))
731
-				->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR))
732
-				->where($query->expr()->eq('uri', $query->createNamedParameter($sourceObjectUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
733
-				->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
734
-				->executeStatement();
735
-
736
-			$this->purgeProperties($sourceAddressBookId, $sourceObjectId);
737
-			$this->updateProperties($targetAddressBookId, $tragetObjectUri, $card['carddata']);
738
-
739
-			$this->addChange($sourceAddressBookId, $sourceObjectUri, 3);
740
-			$this->addChange($targetAddressBookId, $tragetObjectUri, 1);
741
-
742
-			$card = $this->getCard($targetAddressBookId, $tragetObjectUri);
743
-			// Card wasn't found - possibly because it was deleted in the meantime by a different client
744
-			if (empty($card)) {
745
-				return false;
746
-			}
747
-			$targetAddressBookRow = $this->getAddressBookById($targetAddressBookId);
748
-			// the address book this card is being moved to does not exist any longer
749
-			if (empty($targetAddressBookRow)) {
750
-				return false;
751
-			}
752
-
753
-			$sourceShares = $this->getShares($sourceAddressBookId);
754
-			$targetShares = $this->getShares($targetAddressBookId);
755
-			$sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId);
756
-			$this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $card));
757
-			return true;
758
-		}, $this->db);
759
-	}
760
-
761
-	/**
762
-	 * Deletes a card
763
-	 *
764
-	 * @param mixed $addressBookId
765
-	 * @param string $cardUri
766
-	 * @return bool
767
-	 */
768
-	public function deleteCard($addressBookId, $cardUri) {
769
-		return $this->atomic(function () use ($addressBookId, $cardUri) {
770
-			$addressBookData = $this->getAddressBookById($addressBookId);
771
-			$shares = $this->getShares($addressBookId);
772
-			$objectRow = $this->getCard($addressBookId, $cardUri);
773
-
774
-			try {
775
-				$cardId = $this->getCardId($addressBookId, $cardUri);
776
-			} catch (\InvalidArgumentException $e) {
777
-				$cardId = null;
778
-			}
779
-			$query = $this->db->getQueryBuilder();
780
-			$ret = $query->delete($this->dbCardsTable)
781
-				->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
782
-				->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
783
-				->executeStatement();
784
-
785
-			$this->addChange($addressBookId, $cardUri, 3);
786
-
787
-			if ($ret === 1) {
788
-				if ($cardId !== null) {
789
-					$this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow));
790
-					$this->purgeProperties($addressBookId, $cardId);
791
-				}
792
-				return true;
793
-			}
794
-
795
-			return false;
796
-		}, $this->db);
797
-	}
798
-
799
-	/**
800
-	 * The getChanges method returns all the changes that have happened, since
801
-	 * the specified syncToken in the specified address book.
802
-	 *
803
-	 * This function should return an array, such as the following:
804
-	 *
805
-	 * [
806
-	 *   'syncToken' => 'The current synctoken',
807
-	 *   'added'   => [
808
-	 *      'new.txt',
809
-	 *   ],
810
-	 *   'modified'   => [
811
-	 *      'modified.txt',
812
-	 *   ],
813
-	 *   'deleted' => [
814
-	 *      'foo.php.bak',
815
-	 *      'old.txt'
816
-	 *   ]
817
-	 * ];
818
-	 *
819
-	 * The returned syncToken property should reflect the *current* syncToken
820
-	 * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
821
-	 * property. This is needed here too, to ensure the operation is atomic.
822
-	 *
823
-	 * If the $syncToken argument is specified as null, this is an initial
824
-	 * sync, and all members should be reported.
825
-	 *
826
-	 * The modified property is an array of nodenames that have changed since
827
-	 * the last token.
828
-	 *
829
-	 * The deleted property is an array with nodenames, that have been deleted
830
-	 * from collection.
831
-	 *
832
-	 * The $syncLevel argument is basically the 'depth' of the report. If it's
833
-	 * 1, you only have to report changes that happened only directly in
834
-	 * immediate descendants. If it's 2, it should also include changes from
835
-	 * the nodes below the child collections. (grandchildren)
836
-	 *
837
-	 * The $limit argument allows a client to specify how many results should
838
-	 * be returned at most. If the limit is not specified, it should be treated
839
-	 * as infinite.
840
-	 *
841
-	 * If the limit (infinite or not) is higher than you're willing to return,
842
-	 * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
843
-	 *
844
-	 * If the syncToken is expired (due to data cleanup) or unknown, you must
845
-	 * return null.
846
-	 *
847
-	 * The limit is 'suggestive'. You are free to ignore it.
848
-	 *
849
-	 * @param string $addressBookId
850
-	 * @param string $syncToken
851
-	 * @param int $syncLevel
852
-	 * @param int|null $limit
853
-	 * @return array
854
-	 */
855
-	public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
856
-		$maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500);
857
-		$limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit);
858
-		// Current synctoken
859
-		return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
860
-			$qb = $this->db->getQueryBuilder();
861
-			$qb->select('synctoken')
862
-				->from('addressbooks')
863
-				->where(
864
-					$qb->expr()->eq('id', $qb->createNamedParameter($addressBookId))
865
-				);
866
-			$stmt = $qb->executeQuery();
867
-			$currentToken = $stmt->fetchOne();
868
-			$stmt->closeCursor();
869
-
870
-			if (is_null($currentToken)) {
871
-				return [];
872
-			}
873
-
874
-			$result = [
875
-				'syncToken' => $currentToken,
876
-				'added' => [],
877
-				'modified' => [],
878
-				'deleted' => [],
879
-			];
880
-			if (str_starts_with($syncToken, 'init_')) {
881
-				$syncValues = explode('_', $syncToken);
882
-				$lastID = $syncValues[1];
883
-				$initialSyncToken = $syncValues[2];
884
-				$qb = $this->db->getQueryBuilder();
885
-				$qb->select('id', 'uri')
886
-					->from('cards')
887
-					->where(
888
-						$qb->expr()->andX(
889
-							$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)),
890
-							$qb->expr()->gt('id', $qb->createNamedParameter($lastID)))
891
-					)->orderBy('id')
892
-					->setMaxResults($limit);
893
-				$stmt = $qb->executeQuery();
894
-				$values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
895
-				$stmt->closeCursor();
896
-				if (count($values) === 0) {
897
-					$result['syncToken'] = $initialSyncToken;
898
-					$result['result_truncated'] = false;
899
-					$result['added'] = [];
900
-				} else {
901
-					$lastID = $values[array_key_last($values)]['id'];
902
-					$result['added'] = array_column($values, 'uri');
903
-					$result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ;
904
-					$result['result_truncated'] = count($result['added']) >= $limit;
905
-				}
906
-			} elseif ($syncToken) {
907
-				$qb = $this->db->getQueryBuilder();
908
-				$qb->select('uri', 'operation', 'synctoken')
909
-					->from('addressbookchanges')
910
-					->where(
911
-						$qb->expr()->andX(
912
-							$qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)),
913
-							$qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)),
914
-							$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
915
-						)
916
-					)->orderBy('synctoken');
917
-
918
-				if ($limit > 0) {
919
-					$qb->setMaxResults($limit);
920
-				}
921
-
922
-				// Fetching all changes
923
-				$stmt = $qb->executeQuery();
924
-				$rowCount = $stmt->rowCount();
925
-
926
-				$changes = [];
927
-				$highestSyncToken = 0;
928
-
929
-				// This loop ensures that any duplicates are overwritten, only the
930
-				// last change on a node is relevant.
931
-				while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
932
-					$changes[$row['uri']] = $row['operation'];
933
-					$highestSyncToken = $row['synctoken'];
934
-				}
935
-
936
-				$stmt->closeCursor();
937
-
938
-				// No changes found, use current token
939
-				if (empty($changes)) {
940
-					$result['syncToken'] = $currentToken;
941
-				}
942
-
943
-				foreach ($changes as $uri => $operation) {
944
-					switch ($operation) {
945
-						case 1:
946
-							$result['added'][] = $uri;
947
-							break;
948
-						case 2:
949
-							$result['modified'][] = $uri;
950
-							break;
951
-						case 3:
952
-							$result['deleted'][] = $uri;
953
-							break;
954
-					}
955
-				}
956
-
957
-				/*
38
+    use TTransactional;
39
+    public const PERSONAL_ADDRESSBOOK_URI = 'contacts';
40
+    public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts';
41
+
42
+    private string $dbCardsTable = 'cards';
43
+    private string $dbCardsPropertiesTable = 'cards_properties';
44
+
45
+    /** @var array properties to index */
46
+    public static array $indexProperties = [
47
+        'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME',
48
+        'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO',
49
+        'CLOUD', 'X-SOCIALPROFILE'];
50
+
51
+    /**
52
+     * @var string[] Map of uid => display name
53
+     */
54
+    protected array $userDisplayNames;
55
+    private array $etagCache = [];
56
+
57
+    public function __construct(
58
+        private IDBConnection $db,
59
+        private Principal $principalBackend,
60
+        private IUserManager $userManager,
61
+        private IEventDispatcher $dispatcher,
62
+        private Sharing\Backend $sharingBackend,
63
+        private IConfig $config,
64
+    ) {
65
+    }
66
+
67
+    /**
68
+     * Return the number of address books for a principal
69
+     *
70
+     * @param $principalUri
71
+     * @return int
72
+     */
73
+    public function getAddressBooksForUserCount($principalUri) {
74
+        $principalUri = $this->convertPrincipal($principalUri, true);
75
+        $query = $this->db->getQueryBuilder();
76
+        $query->select($query->func()->count('*'))
77
+            ->from('addressbooks')
78
+            ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
79
+
80
+        $result = $query->executeQuery();
81
+        $column = (int)$result->fetchOne();
82
+        $result->closeCursor();
83
+        return $column;
84
+    }
85
+
86
+    /**
87
+     * Returns the list of address books for a specific user.
88
+     *
89
+     * Every addressbook should have the following properties:
90
+     *   id - an arbitrary unique id
91
+     *   uri - the 'basename' part of the url
92
+     *   principaluri - Same as the passed parameter
93
+     *
94
+     * Any additional clark-notation property may be passed besides this. Some
95
+     * common ones are :
96
+     *   {DAV:}displayname
97
+     *   {urn:ietf:params:xml:ns:carddav}addressbook-description
98
+     *   {http://calendarserver.org/ns/}getctag
99
+     *
100
+     * @param string $principalUri
101
+     * @return array
102
+     */
103
+    public function getAddressBooksForUser($principalUri) {
104
+        return $this->atomic(function () use ($principalUri) {
105
+            $principalUriOriginal = $principalUri;
106
+            $principalUri = $this->convertPrincipal($principalUri, true);
107
+            $select = $this->db->getQueryBuilder();
108
+            $select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
109
+                ->from('addressbooks')
110
+                ->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri)));
111
+
112
+            $addressBooks = [];
113
+
114
+            $result = $select->executeQuery();
115
+            while ($row = $result->fetch()) {
116
+                $addressBooks[$row['id']] = [
117
+                    'id' => $row['id'],
118
+                    'uri' => $row['uri'],
119
+                    'principaluri' => $this->convertPrincipal($row['principaluri'], false),
120
+                    '{DAV:}displayname' => $row['displayname'],
121
+                    '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
122
+                    '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
123
+                    '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
124
+                ];
125
+
126
+                $this->addOwnerPrincipal($addressBooks[$row['id']]);
127
+            }
128
+            $result->closeCursor();
129
+
130
+            // query for shared addressbooks
131
+            $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
132
+
133
+            $principals[] = $principalUri;
134
+
135
+            $select = $this->db->getQueryBuilder();
136
+            $subSelect = $this->db->getQueryBuilder();
137
+
138
+            $subSelect->select('id')
139
+                ->from('dav_shares', 'd')
140
+                ->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
141
+                ->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY));
142
+
143
+
144
+            $select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
145
+                ->from('dav_shares', 's')
146
+                ->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id'))
147
+                ->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY)))
148
+                ->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR)))
149
+                ->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
150
+            $result = $select->executeQuery();
151
+
152
+            $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
153
+            while ($row = $result->fetch()) {
154
+                if ($row['principaluri'] === $principalUri) {
155
+                    continue;
156
+                }
157
+
158
+                $readOnly = (int)$row['access'] === Backend::ACCESS_READ;
159
+                if (isset($addressBooks[$row['id']])) {
160
+                    if ($readOnly) {
161
+                        // New share can not have more permissions then the old one.
162
+                        continue;
163
+                    }
164
+                    if (isset($addressBooks[$row['id']][$readOnlyPropertyName])
165
+                        && $addressBooks[$row['id']][$readOnlyPropertyName] === 0) {
166
+                        // Old share is already read-write, no more permissions can be gained
167
+                        continue;
168
+                    }
169
+                }
170
+
171
+                [, $name] = \Sabre\Uri\split($row['principaluri']);
172
+                $uri = $row['uri'] . '_shared_by_' . $name;
173
+                $displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')';
174
+
175
+                $addressBooks[$row['id']] = [
176
+                    'id' => $row['id'],
177
+                    'uri' => $uri,
178
+                    'principaluri' => $principalUriOriginal,
179
+                    '{DAV:}displayname' => $displayName,
180
+                    '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
181
+                    '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
182
+                    '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
183
+                    '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
184
+                    $readOnlyPropertyName => $readOnly,
185
+                ];
186
+
187
+                $this->addOwnerPrincipal($addressBooks[$row['id']]);
188
+            }
189
+            $result->closeCursor();
190
+
191
+            return array_values($addressBooks);
192
+        }, $this->db);
193
+    }
194
+
195
+    public function getUsersOwnAddressBooks($principalUri) {
196
+        $principalUri = $this->convertPrincipal($principalUri, true);
197
+        $query = $this->db->getQueryBuilder();
198
+        $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
199
+            ->from('addressbooks')
200
+            ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
201
+
202
+        $addressBooks = [];
203
+
204
+        $result = $query->executeQuery();
205
+        while ($row = $result->fetch()) {
206
+            $addressBooks[$row['id']] = [
207
+                'id' => $row['id'],
208
+                'uri' => $row['uri'],
209
+                'principaluri' => $this->convertPrincipal($row['principaluri'], false),
210
+                '{DAV:}displayname' => $row['displayname'],
211
+                '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
212
+                '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
213
+                '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
214
+            ];
215
+
216
+            $this->addOwnerPrincipal($addressBooks[$row['id']]);
217
+        }
218
+        $result->closeCursor();
219
+
220
+        return array_values($addressBooks);
221
+    }
222
+
223
+    /**
224
+     * @param int $addressBookId
225
+     */
226
+    public function getAddressBookById(int $addressBookId): ?array {
227
+        $query = $this->db->getQueryBuilder();
228
+        $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
229
+            ->from('addressbooks')
230
+            ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
231
+            ->executeQuery();
232
+        $row = $result->fetch();
233
+        $result->closeCursor();
234
+        if (!$row) {
235
+            return null;
236
+        }
237
+
238
+        $addressBook = [
239
+            'id' => $row['id'],
240
+            'uri' => $row['uri'],
241
+            'principaluri' => $row['principaluri'],
242
+            '{DAV:}displayname' => $row['displayname'],
243
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
244
+            '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
245
+            '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
246
+        ];
247
+
248
+        $this->addOwnerPrincipal($addressBook);
249
+
250
+        return $addressBook;
251
+    }
252
+
253
+    public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array {
254
+        $query = $this->db->getQueryBuilder();
255
+        $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
256
+            ->from('addressbooks')
257
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri)))
258
+            ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
259
+            ->setMaxResults(1)
260
+            ->executeQuery();
261
+
262
+        $row = $result->fetch();
263
+        $result->closeCursor();
264
+        if ($row === false) {
265
+            return null;
266
+        }
267
+
268
+        $addressBook = [
269
+            'id' => $row['id'],
270
+            'uri' => $row['uri'],
271
+            'principaluri' => $row['principaluri'],
272
+            '{DAV:}displayname' => $row['displayname'],
273
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
274
+            '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
275
+            '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
276
+
277
+        ];
278
+
279
+        // system address books are always read only
280
+        if ($principal === 'principals/system/system') {
281
+            $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri'];
282
+            $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true;
283
+        }
284
+
285
+        $this->addOwnerPrincipal($addressBook);
286
+
287
+        return $addressBook;
288
+    }
289
+
290
+    /**
291
+     * Updates properties for an address book.
292
+     *
293
+     * The list of mutations is stored in a Sabre\DAV\PropPatch object.
294
+     * To do the actual updates, you must tell this object which properties
295
+     * you're going to process with the handle() method.
296
+     *
297
+     * Calling the handle method is like telling the PropPatch object "I
298
+     * promise I can handle updating this property".
299
+     *
300
+     * Read the PropPatch documentation for more info and examples.
301
+     *
302
+     * @param string $addressBookId
303
+     * @param \Sabre\DAV\PropPatch $propPatch
304
+     * @return void
305
+     */
306
+    public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
307
+        $supportedProperties = [
308
+            '{DAV:}displayname',
309
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description',
310
+        ];
311
+
312
+        $propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
313
+            $updates = [];
314
+            foreach ($mutations as $property => $newValue) {
315
+                switch ($property) {
316
+                    case '{DAV:}displayname':
317
+                        $updates['displayname'] = $newValue;
318
+                        break;
319
+                    case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
320
+                        $updates['description'] = $newValue;
321
+                        break;
322
+                }
323
+            }
324
+            [$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) {
325
+                $query = $this->db->getQueryBuilder();
326
+                $query->update('addressbooks');
327
+
328
+                foreach ($updates as $key => $value) {
329
+                    $query->set($key, $query->createNamedParameter($value));
330
+                }
331
+                $query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
332
+                    ->executeStatement();
333
+
334
+                $this->addChange($addressBookId, '', 2);
335
+
336
+                $addressBookRow = $this->getAddressBookById((int)$addressBookId);
337
+                $shares = $this->getShares((int)$addressBookId);
338
+                return [$addressBookRow, $shares];
339
+            }, $this->db);
340
+
341
+            $this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
342
+
343
+            return true;
344
+        });
345
+    }
346
+
347
+    /**
348
+     * Creates a new address book
349
+     *
350
+     * @param string $principalUri
351
+     * @param string $url Just the 'basename' of the url.
352
+     * @param array $properties
353
+     * @return int
354
+     * @throws BadRequest
355
+     * @throws Exception
356
+     */
357
+    public function createAddressBook($principalUri, $url, array $properties) {
358
+        if (strlen($url) > 255) {
359
+            throw new BadRequest('URI too long. Address book not created');
360
+        }
361
+
362
+        $values = [
363
+            'displayname' => null,
364
+            'description' => null,
365
+            'principaluri' => $principalUri,
366
+            'uri' => $url,
367
+            'synctoken' => 1
368
+        ];
369
+
370
+        foreach ($properties as $property => $newValue) {
371
+            switch ($property) {
372
+                case '{DAV:}displayname':
373
+                    $values['displayname'] = $newValue;
374
+                    break;
375
+                case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
376
+                    $values['description'] = $newValue;
377
+                    break;
378
+                default:
379
+                    throw new BadRequest('Unknown property: ' . $property);
380
+            }
381
+        }
382
+
383
+        // Fallback to make sure the displayname is set. Some clients may refuse
384
+        // to work with addressbooks not having a displayname.
385
+        if (is_null($values['displayname'])) {
386
+            $values['displayname'] = $url;
387
+        }
388
+
389
+        [$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) {
390
+            $query = $this->db->getQueryBuilder();
391
+            $query->insert('addressbooks')
392
+                ->values([
393
+                    'uri' => $query->createParameter('uri'),
394
+                    'displayname' => $query->createParameter('displayname'),
395
+                    'description' => $query->createParameter('description'),
396
+                    'principaluri' => $query->createParameter('principaluri'),
397
+                    'synctoken' => $query->createParameter('synctoken'),
398
+                ])
399
+                ->setParameters($values)
400
+                ->executeStatement();
401
+
402
+            $addressBookId = $query->getLastInsertId();
403
+            return [
404
+                $addressBookId,
405
+                $this->getAddressBookById($addressBookId),
406
+            ];
407
+        }, $this->db);
408
+
409
+        $this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow));
410
+
411
+        return $addressBookId;
412
+    }
413
+
414
+    /**
415
+     * Deletes an entire addressbook and all its contents
416
+     *
417
+     * @param mixed $addressBookId
418
+     * @return void
419
+     */
420
+    public function deleteAddressBook($addressBookId) {
421
+        $this->atomic(function () use ($addressBookId): void {
422
+            $addressBookId = (int)$addressBookId;
423
+            $addressBookData = $this->getAddressBookById($addressBookId);
424
+            $shares = $this->getShares($addressBookId);
425
+
426
+            $query = $this->db->getQueryBuilder();
427
+            $query->delete($this->dbCardsTable)
428
+                ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
429
+                ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
430
+                ->executeStatement();
431
+
432
+            $query = $this->db->getQueryBuilder();
433
+            $query->delete('addressbookchanges')
434
+                ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
435
+                ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
436
+                ->executeStatement();
437
+
438
+            $query = $this->db->getQueryBuilder();
439
+            $query->delete('addressbooks')
440
+                ->where($query->expr()->eq('id', $query->createParameter('id')))
441
+                ->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT)
442
+                ->executeStatement();
443
+
444
+            $this->sharingBackend->deleteAllShares($addressBookId);
445
+
446
+            $query = $this->db->getQueryBuilder();
447
+            $query->delete($this->dbCardsPropertiesTable)
448
+                ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
449
+                ->executeStatement();
450
+
451
+            if ($addressBookData) {
452
+                $this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares));
453
+            }
454
+        }, $this->db);
455
+    }
456
+
457
+    /**
458
+     * Returns all cards for a specific addressbook id.
459
+     *
460
+     * This method should return the following properties for each card:
461
+     *   * carddata - raw vcard data
462
+     *   * uri - Some unique url
463
+     *   * lastmodified - A unix timestamp
464
+     *
465
+     * It's recommended to also return the following properties:
466
+     *   * etag - A unique etag. This must change every time the card changes.
467
+     *   * size - The size of the card in bytes.
468
+     *
469
+     * If these last two properties are provided, less time will be spent
470
+     * calculating them. If they are specified, you can also omit carddata.
471
+     * This may speed up certain requests, especially with large cards.
472
+     *
473
+     * @param mixed $addressbookId
474
+     * @return array
475
+     */
476
+    public function getCards($addressbookId) {
477
+        $query = $this->db->getQueryBuilder();
478
+        $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
479
+            ->from($this->dbCardsTable)
480
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId)));
481
+
482
+        $cards = [];
483
+
484
+        $result = $query->executeQuery();
485
+        while ($row = $result->fetch()) {
486
+            $row['etag'] = '"' . $row['etag'] . '"';
487
+
488
+            $modified = false;
489
+            $row['carddata'] = $this->readBlob($row['carddata'], $modified);
490
+            if ($modified) {
491
+                $row['size'] = strlen($row['carddata']);
492
+            }
493
+
494
+            $cards[] = $row;
495
+        }
496
+        $result->closeCursor();
497
+
498
+        return $cards;
499
+    }
500
+
501
+    /**
502
+     * Returns a specific card.
503
+     *
504
+     * The same set of properties must be returned as with getCards. The only
505
+     * exception is that 'carddata' is absolutely required.
506
+     *
507
+     * If the card does not exist, you must return false.
508
+     *
509
+     * @param mixed $addressBookId
510
+     * @param string $cardUri
511
+     * @return array
512
+     */
513
+    public function getCard($addressBookId, $cardUri) {
514
+        $query = $this->db->getQueryBuilder();
515
+        $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
516
+            ->from($this->dbCardsTable)
517
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
518
+            ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
519
+            ->setMaxResults(1);
520
+
521
+        $result = $query->executeQuery();
522
+        $row = $result->fetch();
523
+        if (!$row) {
524
+            return false;
525
+        }
526
+        $row['etag'] = '"' . $row['etag'] . '"';
527
+
528
+        $modified = false;
529
+        $row['carddata'] = $this->readBlob($row['carddata'], $modified);
530
+        if ($modified) {
531
+            $row['size'] = strlen($row['carddata']);
532
+        }
533
+
534
+        return $row;
535
+    }
536
+
537
+    /**
538
+     * Returns a list of cards.
539
+     *
540
+     * This method should work identical to getCard, but instead return all the
541
+     * cards in the list as an array.
542
+     *
543
+     * If the backend supports this, it may allow for some speed-ups.
544
+     *
545
+     * @param mixed $addressBookId
546
+     * @param array $uris
547
+     * @return array
548
+     */
549
+    public function getMultipleCards($addressBookId, array $uris) {
550
+        if (empty($uris)) {
551
+            return [];
552
+        }
553
+
554
+        $chunks = array_chunk($uris, 100);
555
+        $cards = [];
556
+
557
+        $query = $this->db->getQueryBuilder();
558
+        $query->select(['id', 'addressbookid', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
559
+            ->from($this->dbCardsTable)
560
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
561
+            ->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
562
+
563
+        foreach ($chunks as $uris) {
564
+            $query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY);
565
+            $result = $query->executeQuery();
566
+
567
+            while ($row = $result->fetch()) {
568
+                $row['etag'] = '"' . $row['etag'] . '"';
569
+
570
+                $modified = false;
571
+                $row['carddata'] = $this->readBlob($row['carddata'], $modified);
572
+                if ($modified) {
573
+                    $row['size'] = strlen($row['carddata']);
574
+                }
575
+
576
+                $cards[] = $row;
577
+            }
578
+            $result->closeCursor();
579
+        }
580
+        return $cards;
581
+    }
582
+
583
+    /**
584
+     * Creates a new card.
585
+     *
586
+     * The addressbook id will be passed as the first argument. This is the
587
+     * same id as it is returned from the getAddressBooksForUser method.
588
+     *
589
+     * The cardUri is a base uri, and doesn't include the full path. The
590
+     * cardData argument is the vcard body, and is passed as a string.
591
+     *
592
+     * It is possible to return an ETag from this method. This ETag is for the
593
+     * newly created resource, and must be enclosed with double quotes (that
594
+     * is, the string itself must contain the double quotes).
595
+     *
596
+     * You should only return the ETag if you store the carddata as-is. If a
597
+     * subsequent GET request on the same card does not have the same body,
598
+     * byte-by-byte and you did return an ETag here, clients tend to get
599
+     * confused.
600
+     *
601
+     * If you don't return an ETag, you can just return null.
602
+     *
603
+     * @param mixed $addressBookId
604
+     * @param string $cardUri
605
+     * @param string $cardData
606
+     * @param bool $checkAlreadyExists
607
+     * @return string
608
+     */
609
+    public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
610
+        $etag = md5($cardData);
611
+        $uid = $this->getUID($cardData);
612
+        return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
613
+            if ($checkAlreadyExists) {
614
+                $q = $this->db->getQueryBuilder();
615
+                $q->select('uid')
616
+                    ->from($this->dbCardsTable)
617
+                    ->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
618
+                    ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
619
+                    ->setMaxResults(1);
620
+                $result = $q->executeQuery();
621
+                $count = (bool)$result->fetchOne();
622
+                $result->closeCursor();
623
+                if ($count) {
624
+                    throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
625
+                }
626
+            }
627
+
628
+            $query = $this->db->getQueryBuilder();
629
+            $query->insert('cards')
630
+                ->values([
631
+                    'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB),
632
+                    'uri' => $query->createNamedParameter($cardUri),
633
+                    'lastmodified' => $query->createNamedParameter(time()),
634
+                    'addressbookid' => $query->createNamedParameter($addressBookId),
635
+                    'size' => $query->createNamedParameter(strlen($cardData)),
636
+                    'etag' => $query->createNamedParameter($etag),
637
+                    'uid' => $query->createNamedParameter($uid),
638
+                ])
639
+                ->executeStatement();
640
+
641
+            $etagCacheKey = "$addressBookId#$cardUri";
642
+            $this->etagCache[$etagCacheKey] = $etag;
643
+
644
+            $this->addChange($addressBookId, $cardUri, 1);
645
+            $this->updateProperties($addressBookId, $cardUri, $cardData);
646
+
647
+            $addressBookData = $this->getAddressBookById($addressBookId);
648
+            $shares = $this->getShares($addressBookId);
649
+            $objectRow = $this->getCard($addressBookId, $cardUri);
650
+            $this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
651
+
652
+            return '"' . $etag . '"';
653
+        }, $this->db);
654
+    }
655
+
656
+    /**
657
+     * Updates a card.
658
+     *
659
+     * The addressbook id will be passed as the first argument. This is the
660
+     * same id as it is returned from the getAddressBooksForUser method.
661
+     *
662
+     * The cardUri is a base uri, and doesn't include the full path. The
663
+     * cardData argument is the vcard body, and is passed as a string.
664
+     *
665
+     * It is possible to return an ETag from this method. This ETag should
666
+     * match that of the updated resource, and must be enclosed with double
667
+     * quotes (that is: the string itself must contain the actual quotes).
668
+     *
669
+     * You should only return the ETag if you store the carddata as-is. If a
670
+     * subsequent GET request on the same card does not have the same body,
671
+     * byte-by-byte and you did return an ETag here, clients tend to get
672
+     * confused.
673
+     *
674
+     * If you don't return an ETag, you can just return null.
675
+     *
676
+     * @param mixed $addressBookId
677
+     * @param string $cardUri
678
+     * @param string $cardData
679
+     * @return string
680
+     */
681
+    public function updateCard($addressBookId, $cardUri, $cardData) {
682
+        $uid = $this->getUID($cardData);
683
+        $etag = md5($cardData);
684
+
685
+        return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
686
+            $query = $this->db->getQueryBuilder();
687
+
688
+            // check for recently stored etag and stop if it is the same
689
+            $etagCacheKey = "$addressBookId#$cardUri";
690
+            if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
691
+                return '"' . $etag . '"';
692
+            }
693
+
694
+            $query->update($this->dbCardsTable)
695
+                ->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB))
696
+                ->set('lastmodified', $query->createNamedParameter(time()))
697
+                ->set('size', $query->createNamedParameter(strlen($cardData)))
698
+                ->set('etag', $query->createNamedParameter($etag))
699
+                ->set('uid', $query->createNamedParameter($uid))
700
+                ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
701
+                ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
702
+                ->executeStatement();
703
+
704
+            $this->etagCache[$etagCacheKey] = $etag;
705
+
706
+            $this->addChange($addressBookId, $cardUri, 2);
707
+            $this->updateProperties($addressBookId, $cardUri, $cardData);
708
+
709
+            $addressBookData = $this->getAddressBookById($addressBookId);
710
+            $shares = $this->getShares($addressBookId);
711
+            $objectRow = $this->getCard($addressBookId, $cardUri);
712
+            $this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
713
+            return '"' . $etag . '"';
714
+        }, $this->db);
715
+    }
716
+
717
+    /**
718
+     * @throws Exception
719
+     */
720
+    public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool {
721
+        return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
722
+            $card = $this->getCard($sourceAddressBookId, $sourceObjectUri);
723
+            if (empty($card)) {
724
+                return false;
725
+            }
726
+            $sourceObjectId = (int)$card['id'];
727
+
728
+            $query = $this->db->getQueryBuilder();
729
+            $query->update('cards')
730
+                ->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT))
731
+                ->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR))
732
+                ->where($query->expr()->eq('uri', $query->createNamedParameter($sourceObjectUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
733
+                ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
734
+                ->executeStatement();
735
+
736
+            $this->purgeProperties($sourceAddressBookId, $sourceObjectId);
737
+            $this->updateProperties($targetAddressBookId, $tragetObjectUri, $card['carddata']);
738
+
739
+            $this->addChange($sourceAddressBookId, $sourceObjectUri, 3);
740
+            $this->addChange($targetAddressBookId, $tragetObjectUri, 1);
741
+
742
+            $card = $this->getCard($targetAddressBookId, $tragetObjectUri);
743
+            // Card wasn't found - possibly because it was deleted in the meantime by a different client
744
+            if (empty($card)) {
745
+                return false;
746
+            }
747
+            $targetAddressBookRow = $this->getAddressBookById($targetAddressBookId);
748
+            // the address book this card is being moved to does not exist any longer
749
+            if (empty($targetAddressBookRow)) {
750
+                return false;
751
+            }
752
+
753
+            $sourceShares = $this->getShares($sourceAddressBookId);
754
+            $targetShares = $this->getShares($targetAddressBookId);
755
+            $sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId);
756
+            $this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $card));
757
+            return true;
758
+        }, $this->db);
759
+    }
760
+
761
+    /**
762
+     * Deletes a card
763
+     *
764
+     * @param mixed $addressBookId
765
+     * @param string $cardUri
766
+     * @return bool
767
+     */
768
+    public function deleteCard($addressBookId, $cardUri) {
769
+        return $this->atomic(function () use ($addressBookId, $cardUri) {
770
+            $addressBookData = $this->getAddressBookById($addressBookId);
771
+            $shares = $this->getShares($addressBookId);
772
+            $objectRow = $this->getCard($addressBookId, $cardUri);
773
+
774
+            try {
775
+                $cardId = $this->getCardId($addressBookId, $cardUri);
776
+            } catch (\InvalidArgumentException $e) {
777
+                $cardId = null;
778
+            }
779
+            $query = $this->db->getQueryBuilder();
780
+            $ret = $query->delete($this->dbCardsTable)
781
+                ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
782
+                ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
783
+                ->executeStatement();
784
+
785
+            $this->addChange($addressBookId, $cardUri, 3);
786
+
787
+            if ($ret === 1) {
788
+                if ($cardId !== null) {
789
+                    $this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow));
790
+                    $this->purgeProperties($addressBookId, $cardId);
791
+                }
792
+                return true;
793
+            }
794
+
795
+            return false;
796
+        }, $this->db);
797
+    }
798
+
799
+    /**
800
+     * The getChanges method returns all the changes that have happened, since
801
+     * the specified syncToken in the specified address book.
802
+     *
803
+     * This function should return an array, such as the following:
804
+     *
805
+     * [
806
+     *   'syncToken' => 'The current synctoken',
807
+     *   'added'   => [
808
+     *      'new.txt',
809
+     *   ],
810
+     *   'modified'   => [
811
+     *      'modified.txt',
812
+     *   ],
813
+     *   'deleted' => [
814
+     *      'foo.php.bak',
815
+     *      'old.txt'
816
+     *   ]
817
+     * ];
818
+     *
819
+     * The returned syncToken property should reflect the *current* syncToken
820
+     * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
821
+     * property. This is needed here too, to ensure the operation is atomic.
822
+     *
823
+     * If the $syncToken argument is specified as null, this is an initial
824
+     * sync, and all members should be reported.
825
+     *
826
+     * The modified property is an array of nodenames that have changed since
827
+     * the last token.
828
+     *
829
+     * The deleted property is an array with nodenames, that have been deleted
830
+     * from collection.
831
+     *
832
+     * The $syncLevel argument is basically the 'depth' of the report. If it's
833
+     * 1, you only have to report changes that happened only directly in
834
+     * immediate descendants. If it's 2, it should also include changes from
835
+     * the nodes below the child collections. (grandchildren)
836
+     *
837
+     * The $limit argument allows a client to specify how many results should
838
+     * be returned at most. If the limit is not specified, it should be treated
839
+     * as infinite.
840
+     *
841
+     * If the limit (infinite or not) is higher than you're willing to return,
842
+     * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
843
+     *
844
+     * If the syncToken is expired (due to data cleanup) or unknown, you must
845
+     * return null.
846
+     *
847
+     * The limit is 'suggestive'. You are free to ignore it.
848
+     *
849
+     * @param string $addressBookId
850
+     * @param string $syncToken
851
+     * @param int $syncLevel
852
+     * @param int|null $limit
853
+     * @return array
854
+     */
855
+    public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
856
+        $maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500);
857
+        $limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit);
858
+        // Current synctoken
859
+        return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
860
+            $qb = $this->db->getQueryBuilder();
861
+            $qb->select('synctoken')
862
+                ->from('addressbooks')
863
+                ->where(
864
+                    $qb->expr()->eq('id', $qb->createNamedParameter($addressBookId))
865
+                );
866
+            $stmt = $qb->executeQuery();
867
+            $currentToken = $stmt->fetchOne();
868
+            $stmt->closeCursor();
869
+
870
+            if (is_null($currentToken)) {
871
+                return [];
872
+            }
873
+
874
+            $result = [
875
+                'syncToken' => $currentToken,
876
+                'added' => [],
877
+                'modified' => [],
878
+                'deleted' => [],
879
+            ];
880
+            if (str_starts_with($syncToken, 'init_')) {
881
+                $syncValues = explode('_', $syncToken);
882
+                $lastID = $syncValues[1];
883
+                $initialSyncToken = $syncValues[2];
884
+                $qb = $this->db->getQueryBuilder();
885
+                $qb->select('id', 'uri')
886
+                    ->from('cards')
887
+                    ->where(
888
+                        $qb->expr()->andX(
889
+                            $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId)),
890
+                            $qb->expr()->gt('id', $qb->createNamedParameter($lastID)))
891
+                    )->orderBy('id')
892
+                    ->setMaxResults($limit);
893
+                $stmt = $qb->executeQuery();
894
+                $values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
895
+                $stmt->closeCursor();
896
+                if (count($values) === 0) {
897
+                    $result['syncToken'] = $initialSyncToken;
898
+                    $result['result_truncated'] = false;
899
+                    $result['added'] = [];
900
+                } else {
901
+                    $lastID = $values[array_key_last($values)]['id'];
902
+                    $result['added'] = array_column($values, 'uri');
903
+                    $result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ;
904
+                    $result['result_truncated'] = count($result['added']) >= $limit;
905
+                }
906
+            } elseif ($syncToken) {
907
+                $qb = $this->db->getQueryBuilder();
908
+                $qb->select('uri', 'operation', 'synctoken')
909
+                    ->from('addressbookchanges')
910
+                    ->where(
911
+                        $qb->expr()->andX(
912
+                            $qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)),
913
+                            $qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)),
914
+                            $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
915
+                        )
916
+                    )->orderBy('synctoken');
917
+
918
+                if ($limit > 0) {
919
+                    $qb->setMaxResults($limit);
920
+                }
921
+
922
+                // Fetching all changes
923
+                $stmt = $qb->executeQuery();
924
+                $rowCount = $stmt->rowCount();
925
+
926
+                $changes = [];
927
+                $highestSyncToken = 0;
928
+
929
+                // This loop ensures that any duplicates are overwritten, only the
930
+                // last change on a node is relevant.
931
+                while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
932
+                    $changes[$row['uri']] = $row['operation'];
933
+                    $highestSyncToken = $row['synctoken'];
934
+                }
935
+
936
+                $stmt->closeCursor();
937
+
938
+                // No changes found, use current token
939
+                if (empty($changes)) {
940
+                    $result['syncToken'] = $currentToken;
941
+                }
942
+
943
+                foreach ($changes as $uri => $operation) {
944
+                    switch ($operation) {
945
+                        case 1:
946
+                            $result['added'][] = $uri;
947
+                            break;
948
+                        case 2:
949
+                            $result['modified'][] = $uri;
950
+                            break;
951
+                        case 3:
952
+                            $result['deleted'][] = $uri;
953
+                            break;
954
+                    }
955
+                }
956
+
957
+                /*
958 958
 				 * The synctoken in oc_addressbooks is always the highest synctoken in oc_addressbookchanges for a given addressbook plus one (see addChange).
959 959
 				 *
960 960
 				 * For truncated results, it is expected that we return the highest token from the response, so the client can continue from the latest change.
@@ -963,567 +963,567 @@  discard block
 block discarded – undo
963 963
 				 *
964 964
 				 * Therefore, we differentiate between truncated and non-truncated results when returning the synctoken.
965 965
 				 */
966
-				if ($rowCount === $limit && $highestSyncToken < $currentToken) {
967
-					$result['syncToken'] = $highestSyncToken;
968
-					$result['result_truncated'] = true;
969
-				}
970
-			} else {
971
-				$qb = $this->db->getQueryBuilder();
972
-				$qb->select('id', 'uri')
973
-					->from('cards')
974
-					->where(
975
-						$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
976
-					);
977
-				// No synctoken supplied, this is the initial sync.
978
-				$qb->setMaxResults($limit);
979
-				$stmt = $qb->executeQuery();
980
-				$values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
981
-				if (empty($values)) {
982
-					$result['added'] = [];
983
-					return $result;
984
-				}
985
-				$lastID = $values[array_key_last($values)]['id'];
986
-				if (count($values) >= $limit) {
987
-					$result['syncToken'] = 'init_' . $lastID . '_' . $currentToken;
988
-					$result['result_truncated'] = true;
989
-				}
990
-
991
-				$result['added'] = array_column($values, 'uri');
992
-
993
-				$stmt->closeCursor();
994
-			}
995
-			return $result;
996
-		}, $this->db);
997
-	}
998
-
999
-	/**
1000
-	 * Adds a change record to the addressbookchanges table.
1001
-	 *
1002
-	 * @param mixed $addressBookId
1003
-	 * @param string $objectUri
1004
-	 * @param int $operation 1 = add, 2 = modify, 3 = delete
1005
-	 * @return void
1006
-	 */
1007
-	protected function addChange(int $addressBookId, string $objectUri, int $operation): void {
1008
-		$this->atomic(function () use ($addressBookId, $objectUri, $operation): void {
1009
-			$query = $this->db->getQueryBuilder();
1010
-			$query->select('synctoken')
1011
-				->from('addressbooks')
1012
-				->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)));
1013
-			$result = $query->executeQuery();
1014
-			$syncToken = (int)$result->fetchOne();
1015
-			$result->closeCursor();
1016
-
1017
-			$query = $this->db->getQueryBuilder();
1018
-			$query->insert('addressbookchanges')
1019
-				->values([
1020
-					'uri' => $query->createNamedParameter($objectUri),
1021
-					'synctoken' => $query->createNamedParameter($syncToken),
1022
-					'addressbookid' => $query->createNamedParameter($addressBookId),
1023
-					'operation' => $query->createNamedParameter($operation),
1024
-					'created_at' => time(),
1025
-				])
1026
-				->executeStatement();
1027
-
1028
-			$query = $this->db->getQueryBuilder();
1029
-			$query->update('addressbooks')
1030
-				->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT))
1031
-				->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
1032
-				->executeStatement();
1033
-		}, $this->db);
1034
-	}
1035
-
1036
-	/**
1037
-	 * @param resource|string $cardData
1038
-	 * @param bool $modified
1039
-	 * @return string
1040
-	 */
1041
-	private function readBlob($cardData, &$modified = false) {
1042
-		if (is_resource($cardData)) {
1043
-			$cardData = stream_get_contents($cardData);
1044
-		}
1045
-
1046
-		// Micro optimisation
1047
-		// don't loop through
1048
-		if (str_starts_with($cardData, 'PHOTO:data:')) {
1049
-			return $cardData;
1050
-		}
1051
-
1052
-		$cardDataArray = explode("\r\n", $cardData);
1053
-
1054
-		$cardDataFiltered = [];
1055
-		$removingPhoto = false;
1056
-		foreach ($cardDataArray as $line) {
1057
-			if (str_starts_with($line, 'PHOTO:data:')
1058
-				&& !str_starts_with($line, 'PHOTO:data:image/')) {
1059
-				// Filter out PHOTO data of non-images
1060
-				$removingPhoto = true;
1061
-				$modified = true;
1062
-				continue;
1063
-			}
1064
-
1065
-			if ($removingPhoto) {
1066
-				if (str_starts_with($line, ' ')) {
1067
-					continue;
1068
-				}
1069
-				// No leading space means this is a new property
1070
-				$removingPhoto = false;
1071
-			}
1072
-
1073
-			$cardDataFiltered[] = $line;
1074
-		}
1075
-		return implode("\r\n", $cardDataFiltered);
1076
-	}
1077
-
1078
-	/**
1079
-	 * @param list<array{href: string, commonName: string, readOnly: bool}> $add
1080
-	 * @param list<string> $remove
1081
-	 */
1082
-	public function updateShares(IShareable $shareable, array $add, array $remove): void {
1083
-		$this->atomic(function () use ($shareable, $add, $remove): void {
1084
-			$addressBookId = $shareable->getResourceId();
1085
-			$addressBookData = $this->getAddressBookById($addressBookId);
1086
-			$oldShares = $this->getShares($addressBookId);
1087
-
1088
-			$this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares);
1089
-
1090
-			$this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove));
1091
-		}, $this->db);
1092
-	}
1093
-
1094
-	/**
1095
-	 * Search contacts in a specific address-book
1096
-	 *
1097
-	 * @param int $addressBookId
1098
-	 * @param string $pattern which should match within the $searchProperties
1099
-	 * @param array $searchProperties defines the properties within the query pattern should match
1100
-	 * @param array $options = array() to define the search behavior
1101
-	 *                       - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array
1102
-	 *                       - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are
1103
-	 *                       - 'limit' - Set a numeric limit for the search results
1104
-	 *                       - 'offset' - Set the offset for the limited search results
1105
-	 *                       - 'wildcard' - Whether the search should use wildcards
1106
-	 * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options
1107
-	 * @return array an array of contacts which are arrays of key-value-pairs
1108
-	 */
1109
-	public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1110
-		return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) {
1111
-			return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1112
-		}, $this->db);
1113
-	}
1114
-
1115
-	/**
1116
-	 * Search contacts in all address-books accessible by a user
1117
-	 *
1118
-	 * @param string $principalUri
1119
-	 * @param string $pattern
1120
-	 * @param array $searchProperties
1121
-	 * @param array $options
1122
-	 * @return array
1123
-	 */
1124
-	public function searchPrincipalUri(string $principalUri,
1125
-		string $pattern,
1126
-		array $searchProperties,
1127
-		array $options = []): array {
1128
-		return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) {
1129
-			$addressBookIds = array_map(static function ($row):int {
1130
-				return (int)$row['id'];
1131
-			}, $this->getAddressBooksForUser($principalUri));
1132
-
1133
-			return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
1134
-		}, $this->db);
1135
-	}
1136
-
1137
-	/**
1138
-	 * @param int[] $addressBookIds
1139
-	 * @param string $pattern
1140
-	 * @param array $searchProperties
1141
-	 * @param array $options
1142
-	 * @psalm-param array{
1143
-	 *   types?: bool,
1144
-	 *   escape_like_param?: bool,
1145
-	 *   limit?: int,
1146
-	 *   offset?: int,
1147
-	 *   wildcard?: bool,
1148
-	 *   since?: DateTimeFilter|null,
1149
-	 *   until?: DateTimeFilter|null,
1150
-	 *   person?: string
1151
-	 * } $options
1152
-	 * @return array
1153
-	 */
1154
-	private function searchByAddressBookIds(array $addressBookIds,
1155
-		string $pattern,
1156
-		array $searchProperties,
1157
-		array $options = []): array {
1158
-		if (empty($addressBookIds)) {
1159
-			return [];
1160
-		}
1161
-		$escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
1162
-		$useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1163
-
1164
-		if ($escapePattern) {
1165
-			$searchProperties = array_filter($searchProperties, function ($property) use ($pattern) {
1166
-				if ($property === 'EMAIL' && str_contains($pattern, ' ')) {
1167
-					// There can be no spaces in emails
1168
-					return false;
1169
-				}
1170
-
1171
-				if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) {
1172
-					// There can be no chars in cloud ids which are not valid for user ids plus :/
1173
-					// worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/
1174
-					return false;
1175
-				}
1176
-
1177
-				return true;
1178
-			});
1179
-		}
1180
-
1181
-		if (empty($searchProperties)) {
1182
-			return [];
1183
-		}
1184
-
1185
-		$query2 = $this->db->getQueryBuilder();
1186
-		$query2->selectDistinct('cp.cardid')
1187
-			->from($this->dbCardsPropertiesTable, 'cp')
1188
-			->where($query2->expr()->in('cp.addressbookid', $query2->createNamedParameter($addressBookIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
1189
-			->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY)));
1190
-
1191
-		// No need for like when the pattern is empty
1192
-		if ($pattern !== '') {
1193
-			if (!$useWildcards) {
1194
-				$query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern)));
1195
-			} elseif (!$escapePattern) {
1196
-				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1197
-			} else {
1198
-				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1199
-			}
1200
-		}
1201
-		if (isset($options['limit'])) {
1202
-			$query2->setMaxResults($options['limit']);
1203
-		}
1204
-		if (isset($options['offset'])) {
1205
-			$query2->setFirstResult($options['offset']);
1206
-		}
1207
-
1208
-		if (isset($options['person'])) {
1209
-			$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%')));
1210
-		}
1211
-		if (isset($options['since']) || isset($options['until'])) {
1212
-			$query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid');
1213
-			$query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY')));
1214
-			/**
1215
-			 * FIXME Find a way to match only 4 last digits
1216
-			 * BDAY can be --1018 without year or 20001019 with it
1217
-			 * $bDayOr = [];
1218
-			 * if ($options['since'] instanceof DateTimeFilter) {
1219
-			 * $bDayOr[] =
1220
-			 * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)',
1221
-			 * $query2->createNamedParameter($options['since']->get()->format('md'))
1222
-			 * );
1223
-			 * }
1224
-			 * if ($options['until'] instanceof DateTimeFilter) {
1225
-			 * $bDayOr[] =
1226
-			 * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)',
1227
-			 * $query2->createNamedParameter($options['until']->get()->format('md'))
1228
-			 * );
1229
-			 * }
1230
-			 * $query2->andWhere($query2->expr()->orX(...$bDayOr));
1231
-			 */
1232
-		}
1233
-
1234
-		$result = $query2->executeQuery();
1235
-		$matches = $result->fetchAll();
1236
-		$result->closeCursor();
1237
-		$matches = array_map(function ($match) {
1238
-			return (int)$match['cardid'];
1239
-		}, $matches);
1240
-
1241
-		$cardResults = [];
1242
-		$query = $this->db->getQueryBuilder();
1243
-		$query->select('c.addressbookid', 'c.carddata', 'c.uri')
1244
-			->from($this->dbCardsTable, 'c')
1245
-			->where($query->expr()->in('c.id', $query->createParameter('matches')));
1246
-
1247
-		foreach (array_chunk($matches, 1000) as $matchesChunk) {
1248
-			$query->setParameter('matches', $matchesChunk, IQueryBuilder::PARAM_INT_ARRAY);
1249
-			$result = $query->executeQuery();
1250
-			$cardResults[] = $result->fetchAll();
1251
-			$result->closeCursor();
1252
-		}
1253
-
1254
-		$cards = array_merge(...$cardResults);
1255
-		return array_map(function ($array) {
1256
-			$array['addressbookid'] = (int)$array['addressbookid'];
1257
-			$modified = false;
1258
-			$array['carddata'] = $this->readBlob($array['carddata'], $modified);
1259
-			if ($modified) {
1260
-				$array['size'] = strlen($array['carddata']);
1261
-			}
1262
-			return $array;
1263
-		}, $cards);
1264
-	}
1265
-
1266
-	/**
1267
-	 * @param int $bookId
1268
-	 * @param string $name
1269
-	 * @return array
1270
-	 */
1271
-	public function collectCardProperties($bookId, $name) {
1272
-		$query = $this->db->getQueryBuilder();
1273
-		$result = $query->selectDistinct('value')
1274
-			->from($this->dbCardsPropertiesTable)
1275
-			->where($query->expr()->eq('name', $query->createNamedParameter($name)))
1276
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId)))
1277
-			->executeQuery();
1278
-
1279
-		$all = $result->fetchAll(PDO::FETCH_COLUMN);
1280
-		$result->closeCursor();
1281
-
1282
-		return $all;
1283
-	}
1284
-
1285
-	/**
1286
-	 * get URI from a given contact
1287
-	 *
1288
-	 * @param int $id
1289
-	 * @return string
1290
-	 */
1291
-	public function getCardUri($id) {
1292
-		$query = $this->db->getQueryBuilder();
1293
-		$query->select('uri')->from($this->dbCardsTable)
1294
-			->where($query->expr()->eq('id', $query->createParameter('id')))
1295
-			->setParameter('id', $id);
1296
-
1297
-		$result = $query->executeQuery();
1298
-		$uri = $result->fetch();
1299
-		$result->closeCursor();
1300
-
1301
-		if (!isset($uri['uri'])) {
1302
-			throw new \InvalidArgumentException('Card does not exists: ' . $id);
1303
-		}
1304
-
1305
-		return $uri['uri'];
1306
-	}
1307
-
1308
-	/**
1309
-	 * return contact with the given URI
1310
-	 *
1311
-	 * @param int $addressBookId
1312
-	 * @param string $uri
1313
-	 * @returns array
1314
-	 */
1315
-	public function getContact($addressBookId, $uri) {
1316
-		$result = [];
1317
-		$query = $this->db->getQueryBuilder();
1318
-		$query->select('*')->from($this->dbCardsTable)
1319
-			->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1320
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1321
-		$queryResult = $query->executeQuery();
1322
-		$contact = $queryResult->fetch();
1323
-		$queryResult->closeCursor();
1324
-
1325
-		if (is_array($contact)) {
1326
-			$modified = false;
1327
-			$contact['etag'] = '"' . $contact['etag'] . '"';
1328
-			$contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1329
-			if ($modified) {
1330
-				$contact['size'] = strlen($contact['carddata']);
1331
-			}
1332
-
1333
-			$result = $contact;
1334
-		}
1335
-
1336
-		return $result;
1337
-	}
1338
-
1339
-	/**
1340
-	 * Returns the list of people whom this address book is shared with.
1341
-	 *
1342
-	 * Every element in this array should have the following properties:
1343
-	 *   * href - Often a mailto: address
1344
-	 *   * commonName - Optional, for example a first + last name
1345
-	 *   * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants.
1346
-	 *   * readOnly - boolean
1347
-	 *
1348
-	 * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
1349
-	 */
1350
-	public function getShares(int $addressBookId): array {
1351
-		return $this->sharingBackend->getShares($addressBookId);
1352
-	}
1353
-
1354
-	/**
1355
-	 * update properties table
1356
-	 *
1357
-	 * @param int $addressBookId
1358
-	 * @param string $cardUri
1359
-	 * @param string $vCardSerialized
1360
-	 */
1361
-	protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1362
-		$this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void {
1363
-			$cardId = $this->getCardId($addressBookId, $cardUri);
1364
-			$vCard = $this->readCard($vCardSerialized);
1365
-
1366
-			$this->purgeProperties($addressBookId, $cardId);
1367
-
1368
-			$query = $this->db->getQueryBuilder();
1369
-			$query->insert($this->dbCardsPropertiesTable)
1370
-				->values(
1371
-					[
1372
-						'addressbookid' => $query->createNamedParameter($addressBookId),
1373
-						'cardid' => $query->createNamedParameter($cardId),
1374
-						'name' => $query->createParameter('name'),
1375
-						'value' => $query->createParameter('value'),
1376
-						'preferred' => $query->createParameter('preferred')
1377
-					]
1378
-				);
1379
-
1380
-			foreach ($vCard->children() as $property) {
1381
-				if (!in_array($property->name, self::$indexProperties)) {
1382
-					continue;
1383
-				}
1384
-				$preferred = 0;
1385
-				foreach ($property->parameters as $parameter) {
1386
-					if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') {
1387
-						$preferred = 1;
1388
-						break;
1389
-					}
1390
-				}
1391
-				$query->setParameter('name', $property->name);
1392
-				$query->setParameter('value', mb_strcut($property->getValue(), 0, 254));
1393
-				$query->setParameter('preferred', $preferred);
1394
-				$query->executeStatement();
1395
-			}
1396
-		}, $this->db);
1397
-	}
1398
-
1399
-	/**
1400
-	 * read vCard data into a vCard object
1401
-	 *
1402
-	 * @param string $cardData
1403
-	 * @return VCard
1404
-	 */
1405
-	protected function readCard($cardData) {
1406
-		return Reader::read($cardData);
1407
-	}
1408
-
1409
-	/**
1410
-	 * delete all properties from a given card
1411
-	 *
1412
-	 * @param int $addressBookId
1413
-	 * @param int $cardId
1414
-	 */
1415
-	protected function purgeProperties($addressBookId, $cardId) {
1416
-		$query = $this->db->getQueryBuilder();
1417
-		$query->delete($this->dbCardsPropertiesTable)
1418
-			->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId)))
1419
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1420
-		$query->executeStatement();
1421
-	}
1422
-
1423
-	/**
1424
-	 * Get ID from a given contact
1425
-	 */
1426
-	protected function getCardId(int $addressBookId, string $uri): int {
1427
-		$query = $this->db->getQueryBuilder();
1428
-		$query->select('id')->from($this->dbCardsTable)
1429
-			->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1430
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1431
-
1432
-		$result = $query->executeQuery();
1433
-		$cardIds = $result->fetch();
1434
-		$result->closeCursor();
1435
-
1436
-		if (!isset($cardIds['id'])) {
1437
-			throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1438
-		}
1439
-
1440
-		return (int)$cardIds['id'];
1441
-	}
1442
-
1443
-	/**
1444
-	 * For shared address books the sharee is set in the ACL of the address book
1445
-	 *
1446
-	 * @param int $addressBookId
1447
-	 * @param list<array{privilege: string, principal: string, protected: bool}> $acl
1448
-	 * @return list<array{privilege: string, principal: string, protected: bool}>
1449
-	 */
1450
-	public function applyShareAcl(int $addressBookId, array $acl): array {
1451
-		$shares = $this->sharingBackend->getShares($addressBookId);
1452
-		return $this->sharingBackend->applyShareAcl($shares, $acl);
1453
-	}
1454
-
1455
-	/**
1456
-	 * @throws \InvalidArgumentException
1457
-	 */
1458
-	public function pruneOutdatedSyncTokens(int $keep, int $retention): int {
1459
-		if ($keep < 0) {
1460
-			throw new \InvalidArgumentException();
1461
-		}
1462
-
1463
-		$query = $this->db->getQueryBuilder();
1464
-		$query->select($query->func()->max('id'))
1465
-			->from('addressbookchanges');
1466
-
1467
-		$result = $query->executeQuery();
1468
-		$maxId = (int)$result->fetchOne();
1469
-		$result->closeCursor();
1470
-		if (!$maxId || $maxId < $keep) {
1471
-			return 0;
1472
-		}
1473
-
1474
-		$query = $this->db->getQueryBuilder();
1475
-		$query->delete('addressbookchanges')
1476
-			->where(
1477
-				$query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
1478
-				$query->expr()->lte('created_at', $query->createNamedParameter($retention)),
1479
-			);
1480
-		return $query->executeStatement();
1481
-	}
1482
-
1483
-	private function convertPrincipal(string $principalUri, bool $toV2): string {
1484
-		if ($this->principalBackend->getPrincipalPrefix() === 'principals') {
1485
-			[, $name] = \Sabre\Uri\split($principalUri);
1486
-			if ($toV2 === true) {
1487
-				return "principals/users/$name";
1488
-			}
1489
-			return "principals/$name";
1490
-		}
1491
-		return $principalUri;
1492
-	}
1493
-
1494
-	private function addOwnerPrincipal(array &$addressbookInfo): void {
1495
-		$ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1496
-		$displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1497
-		if (isset($addressbookInfo[$ownerPrincipalKey])) {
1498
-			$uri = $addressbookInfo[$ownerPrincipalKey];
1499
-		} else {
1500
-			$uri = $addressbookInfo['principaluri'];
1501
-		}
1502
-
1503
-		$principalInformation = $this->principalBackend->getPrincipalByPath($uri);
1504
-		if (isset($principalInformation['{DAV:}displayname'])) {
1505
-			$addressbookInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
1506
-		}
1507
-	}
1508
-
1509
-	/**
1510
-	 * Extract UID from vcard
1511
-	 *
1512
-	 * @param string $cardData the vcard raw data
1513
-	 * @return string the uid
1514
-	 * @throws BadRequest if no UID is available or vcard is empty
1515
-	 */
1516
-	private function getUID(string $cardData): string {
1517
-		if ($cardData !== '') {
1518
-			$vCard = Reader::read($cardData);
1519
-			if ($vCard->UID) {
1520
-				$uid = $vCard->UID->getValue();
1521
-				return $uid;
1522
-			}
1523
-			// should already be handled, but just in case
1524
-			throw new BadRequest('vCards on CardDAV servers MUST have a UID property');
1525
-		}
1526
-		// should already be handled, but just in case
1527
-		throw new BadRequest('vCard can not be empty');
1528
-	}
966
+                if ($rowCount === $limit && $highestSyncToken < $currentToken) {
967
+                    $result['syncToken'] = $highestSyncToken;
968
+                    $result['result_truncated'] = true;
969
+                }
970
+            } else {
971
+                $qb = $this->db->getQueryBuilder();
972
+                $qb->select('id', 'uri')
973
+                    ->from('cards')
974
+                    ->where(
975
+                        $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
976
+                    );
977
+                // No synctoken supplied, this is the initial sync.
978
+                $qb->setMaxResults($limit);
979
+                $stmt = $qb->executeQuery();
980
+                $values = $stmt->fetchAll(\PDO::FETCH_ASSOC);
981
+                if (empty($values)) {
982
+                    $result['added'] = [];
983
+                    return $result;
984
+                }
985
+                $lastID = $values[array_key_last($values)]['id'];
986
+                if (count($values) >= $limit) {
987
+                    $result['syncToken'] = 'init_' . $lastID . '_' . $currentToken;
988
+                    $result['result_truncated'] = true;
989
+                }
990
+
991
+                $result['added'] = array_column($values, 'uri');
992
+
993
+                $stmt->closeCursor();
994
+            }
995
+            return $result;
996
+        }, $this->db);
997
+    }
998
+
999
+    /**
1000
+     * Adds a change record to the addressbookchanges table.
1001
+     *
1002
+     * @param mixed $addressBookId
1003
+     * @param string $objectUri
1004
+     * @param int $operation 1 = add, 2 = modify, 3 = delete
1005
+     * @return void
1006
+     */
1007
+    protected function addChange(int $addressBookId, string $objectUri, int $operation): void {
1008
+        $this->atomic(function () use ($addressBookId, $objectUri, $operation): void {
1009
+            $query = $this->db->getQueryBuilder();
1010
+            $query->select('synctoken')
1011
+                ->from('addressbooks')
1012
+                ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)));
1013
+            $result = $query->executeQuery();
1014
+            $syncToken = (int)$result->fetchOne();
1015
+            $result->closeCursor();
1016
+
1017
+            $query = $this->db->getQueryBuilder();
1018
+            $query->insert('addressbookchanges')
1019
+                ->values([
1020
+                    'uri' => $query->createNamedParameter($objectUri),
1021
+                    'synctoken' => $query->createNamedParameter($syncToken),
1022
+                    'addressbookid' => $query->createNamedParameter($addressBookId),
1023
+                    'operation' => $query->createNamedParameter($operation),
1024
+                    'created_at' => time(),
1025
+                ])
1026
+                ->executeStatement();
1027
+
1028
+            $query = $this->db->getQueryBuilder();
1029
+            $query->update('addressbooks')
1030
+                ->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT))
1031
+                ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
1032
+                ->executeStatement();
1033
+        }, $this->db);
1034
+    }
1035
+
1036
+    /**
1037
+     * @param resource|string $cardData
1038
+     * @param bool $modified
1039
+     * @return string
1040
+     */
1041
+    private function readBlob($cardData, &$modified = false) {
1042
+        if (is_resource($cardData)) {
1043
+            $cardData = stream_get_contents($cardData);
1044
+        }
1045
+
1046
+        // Micro optimisation
1047
+        // don't loop through
1048
+        if (str_starts_with($cardData, 'PHOTO:data:')) {
1049
+            return $cardData;
1050
+        }
1051
+
1052
+        $cardDataArray = explode("\r\n", $cardData);
1053
+
1054
+        $cardDataFiltered = [];
1055
+        $removingPhoto = false;
1056
+        foreach ($cardDataArray as $line) {
1057
+            if (str_starts_with($line, 'PHOTO:data:')
1058
+                && !str_starts_with($line, 'PHOTO:data:image/')) {
1059
+                // Filter out PHOTO data of non-images
1060
+                $removingPhoto = true;
1061
+                $modified = true;
1062
+                continue;
1063
+            }
1064
+
1065
+            if ($removingPhoto) {
1066
+                if (str_starts_with($line, ' ')) {
1067
+                    continue;
1068
+                }
1069
+                // No leading space means this is a new property
1070
+                $removingPhoto = false;
1071
+            }
1072
+
1073
+            $cardDataFiltered[] = $line;
1074
+        }
1075
+        return implode("\r\n", $cardDataFiltered);
1076
+    }
1077
+
1078
+    /**
1079
+     * @param list<array{href: string, commonName: string, readOnly: bool}> $add
1080
+     * @param list<string> $remove
1081
+     */
1082
+    public function updateShares(IShareable $shareable, array $add, array $remove): void {
1083
+        $this->atomic(function () use ($shareable, $add, $remove): void {
1084
+            $addressBookId = $shareable->getResourceId();
1085
+            $addressBookData = $this->getAddressBookById($addressBookId);
1086
+            $oldShares = $this->getShares($addressBookId);
1087
+
1088
+            $this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares);
1089
+
1090
+            $this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove));
1091
+        }, $this->db);
1092
+    }
1093
+
1094
+    /**
1095
+     * Search contacts in a specific address-book
1096
+     *
1097
+     * @param int $addressBookId
1098
+     * @param string $pattern which should match within the $searchProperties
1099
+     * @param array $searchProperties defines the properties within the query pattern should match
1100
+     * @param array $options = array() to define the search behavior
1101
+     *                       - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array
1102
+     *                       - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are
1103
+     *                       - 'limit' - Set a numeric limit for the search results
1104
+     *                       - 'offset' - Set the offset for the limited search results
1105
+     *                       - 'wildcard' - Whether the search should use wildcards
1106
+     * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options
1107
+     * @return array an array of contacts which are arrays of key-value-pairs
1108
+     */
1109
+    public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1110
+        return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) {
1111
+            return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1112
+        }, $this->db);
1113
+    }
1114
+
1115
+    /**
1116
+     * Search contacts in all address-books accessible by a user
1117
+     *
1118
+     * @param string $principalUri
1119
+     * @param string $pattern
1120
+     * @param array $searchProperties
1121
+     * @param array $options
1122
+     * @return array
1123
+     */
1124
+    public function searchPrincipalUri(string $principalUri,
1125
+        string $pattern,
1126
+        array $searchProperties,
1127
+        array $options = []): array {
1128
+        return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) {
1129
+            $addressBookIds = array_map(static function ($row):int {
1130
+                return (int)$row['id'];
1131
+            }, $this->getAddressBooksForUser($principalUri));
1132
+
1133
+            return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
1134
+        }, $this->db);
1135
+    }
1136
+
1137
+    /**
1138
+     * @param int[] $addressBookIds
1139
+     * @param string $pattern
1140
+     * @param array $searchProperties
1141
+     * @param array $options
1142
+     * @psalm-param array{
1143
+     *   types?: bool,
1144
+     *   escape_like_param?: bool,
1145
+     *   limit?: int,
1146
+     *   offset?: int,
1147
+     *   wildcard?: bool,
1148
+     *   since?: DateTimeFilter|null,
1149
+     *   until?: DateTimeFilter|null,
1150
+     *   person?: string
1151
+     * } $options
1152
+     * @return array
1153
+     */
1154
+    private function searchByAddressBookIds(array $addressBookIds,
1155
+        string $pattern,
1156
+        array $searchProperties,
1157
+        array $options = []): array {
1158
+        if (empty($addressBookIds)) {
1159
+            return [];
1160
+        }
1161
+        $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
1162
+        $useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1163
+
1164
+        if ($escapePattern) {
1165
+            $searchProperties = array_filter($searchProperties, function ($property) use ($pattern) {
1166
+                if ($property === 'EMAIL' && str_contains($pattern, ' ')) {
1167
+                    // There can be no spaces in emails
1168
+                    return false;
1169
+                }
1170
+
1171
+                if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) {
1172
+                    // There can be no chars in cloud ids which are not valid for user ids plus :/
1173
+                    // worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/
1174
+                    return false;
1175
+                }
1176
+
1177
+                return true;
1178
+            });
1179
+        }
1180
+
1181
+        if (empty($searchProperties)) {
1182
+            return [];
1183
+        }
1184
+
1185
+        $query2 = $this->db->getQueryBuilder();
1186
+        $query2->selectDistinct('cp.cardid')
1187
+            ->from($this->dbCardsPropertiesTable, 'cp')
1188
+            ->where($query2->expr()->in('cp.addressbookid', $query2->createNamedParameter($addressBookIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
1189
+            ->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY)));
1190
+
1191
+        // No need for like when the pattern is empty
1192
+        if ($pattern !== '') {
1193
+            if (!$useWildcards) {
1194
+                $query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern)));
1195
+            } elseif (!$escapePattern) {
1196
+                $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1197
+            } else {
1198
+                $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1199
+            }
1200
+        }
1201
+        if (isset($options['limit'])) {
1202
+            $query2->setMaxResults($options['limit']);
1203
+        }
1204
+        if (isset($options['offset'])) {
1205
+            $query2->setFirstResult($options['offset']);
1206
+        }
1207
+
1208
+        if (isset($options['person'])) {
1209
+            $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%')));
1210
+        }
1211
+        if (isset($options['since']) || isset($options['until'])) {
1212
+            $query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid');
1213
+            $query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY')));
1214
+            /**
1215
+             * FIXME Find a way to match only 4 last digits
1216
+             * BDAY can be --1018 without year or 20001019 with it
1217
+             * $bDayOr = [];
1218
+             * if ($options['since'] instanceof DateTimeFilter) {
1219
+             * $bDayOr[] =
1220
+             * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)',
1221
+             * $query2->createNamedParameter($options['since']->get()->format('md'))
1222
+             * );
1223
+             * }
1224
+             * if ($options['until'] instanceof DateTimeFilter) {
1225
+             * $bDayOr[] =
1226
+             * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)',
1227
+             * $query2->createNamedParameter($options['until']->get()->format('md'))
1228
+             * );
1229
+             * }
1230
+             * $query2->andWhere($query2->expr()->orX(...$bDayOr));
1231
+             */
1232
+        }
1233
+
1234
+        $result = $query2->executeQuery();
1235
+        $matches = $result->fetchAll();
1236
+        $result->closeCursor();
1237
+        $matches = array_map(function ($match) {
1238
+            return (int)$match['cardid'];
1239
+        }, $matches);
1240
+
1241
+        $cardResults = [];
1242
+        $query = $this->db->getQueryBuilder();
1243
+        $query->select('c.addressbookid', 'c.carddata', 'c.uri')
1244
+            ->from($this->dbCardsTable, 'c')
1245
+            ->where($query->expr()->in('c.id', $query->createParameter('matches')));
1246
+
1247
+        foreach (array_chunk($matches, 1000) as $matchesChunk) {
1248
+            $query->setParameter('matches', $matchesChunk, IQueryBuilder::PARAM_INT_ARRAY);
1249
+            $result = $query->executeQuery();
1250
+            $cardResults[] = $result->fetchAll();
1251
+            $result->closeCursor();
1252
+        }
1253
+
1254
+        $cards = array_merge(...$cardResults);
1255
+        return array_map(function ($array) {
1256
+            $array['addressbookid'] = (int)$array['addressbookid'];
1257
+            $modified = false;
1258
+            $array['carddata'] = $this->readBlob($array['carddata'], $modified);
1259
+            if ($modified) {
1260
+                $array['size'] = strlen($array['carddata']);
1261
+            }
1262
+            return $array;
1263
+        }, $cards);
1264
+    }
1265
+
1266
+    /**
1267
+     * @param int $bookId
1268
+     * @param string $name
1269
+     * @return array
1270
+     */
1271
+    public function collectCardProperties($bookId, $name) {
1272
+        $query = $this->db->getQueryBuilder();
1273
+        $result = $query->selectDistinct('value')
1274
+            ->from($this->dbCardsPropertiesTable)
1275
+            ->where($query->expr()->eq('name', $query->createNamedParameter($name)))
1276
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId)))
1277
+            ->executeQuery();
1278
+
1279
+        $all = $result->fetchAll(PDO::FETCH_COLUMN);
1280
+        $result->closeCursor();
1281
+
1282
+        return $all;
1283
+    }
1284
+
1285
+    /**
1286
+     * get URI from a given contact
1287
+     *
1288
+     * @param int $id
1289
+     * @return string
1290
+     */
1291
+    public function getCardUri($id) {
1292
+        $query = $this->db->getQueryBuilder();
1293
+        $query->select('uri')->from($this->dbCardsTable)
1294
+            ->where($query->expr()->eq('id', $query->createParameter('id')))
1295
+            ->setParameter('id', $id);
1296
+
1297
+        $result = $query->executeQuery();
1298
+        $uri = $result->fetch();
1299
+        $result->closeCursor();
1300
+
1301
+        if (!isset($uri['uri'])) {
1302
+            throw new \InvalidArgumentException('Card does not exists: ' . $id);
1303
+        }
1304
+
1305
+        return $uri['uri'];
1306
+    }
1307
+
1308
+    /**
1309
+     * return contact with the given URI
1310
+     *
1311
+     * @param int $addressBookId
1312
+     * @param string $uri
1313
+     * @returns array
1314
+     */
1315
+    public function getContact($addressBookId, $uri) {
1316
+        $result = [];
1317
+        $query = $this->db->getQueryBuilder();
1318
+        $query->select('*')->from($this->dbCardsTable)
1319
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1320
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1321
+        $queryResult = $query->executeQuery();
1322
+        $contact = $queryResult->fetch();
1323
+        $queryResult->closeCursor();
1324
+
1325
+        if (is_array($contact)) {
1326
+            $modified = false;
1327
+            $contact['etag'] = '"' . $contact['etag'] . '"';
1328
+            $contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1329
+            if ($modified) {
1330
+                $contact['size'] = strlen($contact['carddata']);
1331
+            }
1332
+
1333
+            $result = $contact;
1334
+        }
1335
+
1336
+        return $result;
1337
+    }
1338
+
1339
+    /**
1340
+     * Returns the list of people whom this address book is shared with.
1341
+     *
1342
+     * Every element in this array should have the following properties:
1343
+     *   * href - Often a mailto: address
1344
+     *   * commonName - Optional, for example a first + last name
1345
+     *   * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants.
1346
+     *   * readOnly - boolean
1347
+     *
1348
+     * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
1349
+     */
1350
+    public function getShares(int $addressBookId): array {
1351
+        return $this->sharingBackend->getShares($addressBookId);
1352
+    }
1353
+
1354
+    /**
1355
+     * update properties table
1356
+     *
1357
+     * @param int $addressBookId
1358
+     * @param string $cardUri
1359
+     * @param string $vCardSerialized
1360
+     */
1361
+    protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1362
+        $this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void {
1363
+            $cardId = $this->getCardId($addressBookId, $cardUri);
1364
+            $vCard = $this->readCard($vCardSerialized);
1365
+
1366
+            $this->purgeProperties($addressBookId, $cardId);
1367
+
1368
+            $query = $this->db->getQueryBuilder();
1369
+            $query->insert($this->dbCardsPropertiesTable)
1370
+                ->values(
1371
+                    [
1372
+                        'addressbookid' => $query->createNamedParameter($addressBookId),
1373
+                        'cardid' => $query->createNamedParameter($cardId),
1374
+                        'name' => $query->createParameter('name'),
1375
+                        'value' => $query->createParameter('value'),
1376
+                        'preferred' => $query->createParameter('preferred')
1377
+                    ]
1378
+                );
1379
+
1380
+            foreach ($vCard->children() as $property) {
1381
+                if (!in_array($property->name, self::$indexProperties)) {
1382
+                    continue;
1383
+                }
1384
+                $preferred = 0;
1385
+                foreach ($property->parameters as $parameter) {
1386
+                    if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') {
1387
+                        $preferred = 1;
1388
+                        break;
1389
+                    }
1390
+                }
1391
+                $query->setParameter('name', $property->name);
1392
+                $query->setParameter('value', mb_strcut($property->getValue(), 0, 254));
1393
+                $query->setParameter('preferred', $preferred);
1394
+                $query->executeStatement();
1395
+            }
1396
+        }, $this->db);
1397
+    }
1398
+
1399
+    /**
1400
+     * read vCard data into a vCard object
1401
+     *
1402
+     * @param string $cardData
1403
+     * @return VCard
1404
+     */
1405
+    protected function readCard($cardData) {
1406
+        return Reader::read($cardData);
1407
+    }
1408
+
1409
+    /**
1410
+     * delete all properties from a given card
1411
+     *
1412
+     * @param int $addressBookId
1413
+     * @param int $cardId
1414
+     */
1415
+    protected function purgeProperties($addressBookId, $cardId) {
1416
+        $query = $this->db->getQueryBuilder();
1417
+        $query->delete($this->dbCardsPropertiesTable)
1418
+            ->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId)))
1419
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1420
+        $query->executeStatement();
1421
+    }
1422
+
1423
+    /**
1424
+     * Get ID from a given contact
1425
+     */
1426
+    protected function getCardId(int $addressBookId, string $uri): int {
1427
+        $query = $this->db->getQueryBuilder();
1428
+        $query->select('id')->from($this->dbCardsTable)
1429
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1430
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1431
+
1432
+        $result = $query->executeQuery();
1433
+        $cardIds = $result->fetch();
1434
+        $result->closeCursor();
1435
+
1436
+        if (!isset($cardIds['id'])) {
1437
+            throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1438
+        }
1439
+
1440
+        return (int)$cardIds['id'];
1441
+    }
1442
+
1443
+    /**
1444
+     * For shared address books the sharee is set in the ACL of the address book
1445
+     *
1446
+     * @param int $addressBookId
1447
+     * @param list<array{privilege: string, principal: string, protected: bool}> $acl
1448
+     * @return list<array{privilege: string, principal: string, protected: bool}>
1449
+     */
1450
+    public function applyShareAcl(int $addressBookId, array $acl): array {
1451
+        $shares = $this->sharingBackend->getShares($addressBookId);
1452
+        return $this->sharingBackend->applyShareAcl($shares, $acl);
1453
+    }
1454
+
1455
+    /**
1456
+     * @throws \InvalidArgumentException
1457
+     */
1458
+    public function pruneOutdatedSyncTokens(int $keep, int $retention): int {
1459
+        if ($keep < 0) {
1460
+            throw new \InvalidArgumentException();
1461
+        }
1462
+
1463
+        $query = $this->db->getQueryBuilder();
1464
+        $query->select($query->func()->max('id'))
1465
+            ->from('addressbookchanges');
1466
+
1467
+        $result = $query->executeQuery();
1468
+        $maxId = (int)$result->fetchOne();
1469
+        $result->closeCursor();
1470
+        if (!$maxId || $maxId < $keep) {
1471
+            return 0;
1472
+        }
1473
+
1474
+        $query = $this->db->getQueryBuilder();
1475
+        $query->delete('addressbookchanges')
1476
+            ->where(
1477
+                $query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
1478
+                $query->expr()->lte('created_at', $query->createNamedParameter($retention)),
1479
+            );
1480
+        return $query->executeStatement();
1481
+    }
1482
+
1483
+    private function convertPrincipal(string $principalUri, bool $toV2): string {
1484
+        if ($this->principalBackend->getPrincipalPrefix() === 'principals') {
1485
+            [, $name] = \Sabre\Uri\split($principalUri);
1486
+            if ($toV2 === true) {
1487
+                return "principals/users/$name";
1488
+            }
1489
+            return "principals/$name";
1490
+        }
1491
+        return $principalUri;
1492
+    }
1493
+
1494
+    private function addOwnerPrincipal(array &$addressbookInfo): void {
1495
+        $ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1496
+        $displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1497
+        if (isset($addressbookInfo[$ownerPrincipalKey])) {
1498
+            $uri = $addressbookInfo[$ownerPrincipalKey];
1499
+        } else {
1500
+            $uri = $addressbookInfo['principaluri'];
1501
+        }
1502
+
1503
+        $principalInformation = $this->principalBackend->getPrincipalByPath($uri);
1504
+        if (isset($principalInformation['{DAV:}displayname'])) {
1505
+            $addressbookInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
1506
+        }
1507
+    }
1508
+
1509
+    /**
1510
+     * Extract UID from vcard
1511
+     *
1512
+     * @param string $cardData the vcard raw data
1513
+     * @return string the uid
1514
+     * @throws BadRequest if no UID is available or vcard is empty
1515
+     */
1516
+    private function getUID(string $cardData): string {
1517
+        if ($cardData !== '') {
1518
+            $vCard = Reader::read($cardData);
1519
+            if ($vCard->UID) {
1520
+                $uid = $vCard->UID->getValue();
1521
+                return $uid;
1522
+            }
1523
+            // should already be handled, but just in case
1524
+            throw new BadRequest('vCards on CardDAV servers MUST have a UID property');
1525
+        }
1526
+        // should already be handled, but just in case
1527
+        throw new BadRequest('vCard can not be empty');
1528
+    }
1529 1529
 }
Please login to merge, or discard this patch.
Spacing   +63 added lines, -63 removed lines patch added patch discarded remove patch
@@ -78,7 +78,7 @@  discard block
 block discarded – undo
78 78
 			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
79 79
 
80 80
 		$result = $query->executeQuery();
81
-		$column = (int)$result->fetchOne();
81
+		$column = (int) $result->fetchOne();
82 82
 		$result->closeCursor();
83 83
 		return $column;
84 84
 	}
@@ -101,7 +101,7 @@  discard block
 block discarded – undo
101 101
 	 * @return array
102 102
 	 */
103 103
 	public function getAddressBooksForUser($principalUri) {
104
-		return $this->atomic(function () use ($principalUri) {
104
+		return $this->atomic(function() use ($principalUri) {
105 105
 			$principalUriOriginal = $principalUri;
106 106
 			$principalUri = $this->convertPrincipal($principalUri, true);
107 107
 			$select = $this->db->getQueryBuilder();
@@ -118,7 +118,7 @@  discard block
 block discarded – undo
118 118
 					'uri' => $row['uri'],
119 119
 					'principaluri' => $this->convertPrincipal($row['principaluri'], false),
120 120
 					'{DAV:}displayname' => $row['displayname'],
121
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
121
+					'{'.Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
122 122
 					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
123 123
 					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
124 124
 				];
@@ -149,13 +149,13 @@  discard block
 block discarded – undo
149 149
 				->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
150 150
 			$result = $select->executeQuery();
151 151
 
152
-			$readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
152
+			$readOnlyPropertyName = '{'.\OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD.'}read-only';
153 153
 			while ($row = $result->fetch()) {
154 154
 				if ($row['principaluri'] === $principalUri) {
155 155
 					continue;
156 156
 				}
157 157
 
158
-				$readOnly = (int)$row['access'] === Backend::ACCESS_READ;
158
+				$readOnly = (int) $row['access'] === Backend::ACCESS_READ;
159 159
 				if (isset($addressBooks[$row['id']])) {
160 160
 					if ($readOnly) {
161 161
 						// New share can not have more permissions then the old one.
@@ -169,18 +169,18 @@  discard block
 block discarded – undo
169 169
 				}
170 170
 
171 171
 				[, $name] = \Sabre\Uri\split($row['principaluri']);
172
-				$uri = $row['uri'] . '_shared_by_' . $name;
173
-				$displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')';
172
+				$uri = $row['uri'].'_shared_by_'.$name;
173
+				$displayName = $row['displayname'].' ('.($this->userManager->getDisplayName($name) ?? $name ?? '').')';
174 174
 
175 175
 				$addressBooks[$row['id']] = [
176 176
 					'id' => $row['id'],
177 177
 					'uri' => $uri,
178 178
 					'principaluri' => $principalUriOriginal,
179 179
 					'{DAV:}displayname' => $displayName,
180
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
180
+					'{'.Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
181 181
 					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
182 182
 					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
183
-					'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
183
+					'{'.\OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD.'}owner-principal' => $row['principaluri'],
184 184
 					$readOnlyPropertyName => $readOnly,
185 185
 				];
186 186
 
@@ -208,7 +208,7 @@  discard block
 block discarded – undo
208 208
 				'uri' => $row['uri'],
209 209
 				'principaluri' => $this->convertPrincipal($row['principaluri'], false),
210 210
 				'{DAV:}displayname' => $row['displayname'],
211
-				'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
211
+				'{'.Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
212 212
 				'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
213 213
 				'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
214 214
 			];
@@ -240,7 +240,7 @@  discard block
 block discarded – undo
240 240
 			'uri' => $row['uri'],
241 241
 			'principaluri' => $row['principaluri'],
242 242
 			'{DAV:}displayname' => $row['displayname'],
243
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
243
+			'{'.Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
244 244
 			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
245 245
 			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
246 246
 		];
@@ -270,7 +270,7 @@  discard block
 block discarded – undo
270 270
 			'uri' => $row['uri'],
271 271
 			'principaluri' => $row['principaluri'],
272 272
 			'{DAV:}displayname' => $row['displayname'],
273
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
273
+			'{'.Plugin::NS_CARDDAV.'}addressbook-description' => $row['description'],
274 274
 			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
275 275
 			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
276 276
 
@@ -278,8 +278,8 @@  discard block
 block discarded – undo
278 278
 
279 279
 		// system address books are always read only
280 280
 		if ($principal === 'principals/system/system') {
281
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri'];
282
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true;
281
+			$addressBook['{'.\OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD.'}owner-principal'] = $row['principaluri'];
282
+			$addressBook['{'.\OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD.'}read-only'] = true;
283 283
 		}
284 284
 
285 285
 		$this->addOwnerPrincipal($addressBook);
@@ -306,22 +306,22 @@  discard block
 block discarded – undo
306 306
 	public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
307 307
 		$supportedProperties = [
308 308
 			'{DAV:}displayname',
309
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description',
309
+			'{'.Plugin::NS_CARDDAV.'}addressbook-description',
310 310
 		];
311 311
 
312
-		$propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
312
+		$propPatch->handle($supportedProperties, function($mutations) use ($addressBookId) {
313 313
 			$updates = [];
314 314
 			foreach ($mutations as $property => $newValue) {
315 315
 				switch ($property) {
316 316
 					case '{DAV:}displayname':
317 317
 						$updates['displayname'] = $newValue;
318 318
 						break;
319
-					case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
319
+					case '{'.Plugin::NS_CARDDAV.'}addressbook-description':
320 320
 						$updates['description'] = $newValue;
321 321
 						break;
322 322
 				}
323 323
 			}
324
-			[$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) {
324
+			[$addressBookRow, $shares] = $this->atomic(function() use ($addressBookId, $updates) {
325 325
 				$query = $this->db->getQueryBuilder();
326 326
 				$query->update('addressbooks');
327 327
 
@@ -333,12 +333,12 @@  discard block
 block discarded – undo
333 333
 
334 334
 				$this->addChange($addressBookId, '', 2);
335 335
 
336
-				$addressBookRow = $this->getAddressBookById((int)$addressBookId);
337
-				$shares = $this->getShares((int)$addressBookId);
336
+				$addressBookRow = $this->getAddressBookById((int) $addressBookId);
337
+				$shares = $this->getShares((int) $addressBookId);
338 338
 				return [$addressBookRow, $shares];
339 339
 			}, $this->db);
340 340
 
341
-			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
341
+			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int) $addressBookId, $addressBookRow, $shares, $mutations));
342 342
 
343 343
 			return true;
344 344
 		});
@@ -372,11 +372,11 @@  discard block
 block discarded – undo
372 372
 				case '{DAV:}displayname':
373 373
 					$values['displayname'] = $newValue;
374 374
 					break;
375
-				case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
375
+				case '{'.Plugin::NS_CARDDAV.'}addressbook-description':
376 376
 					$values['description'] = $newValue;
377 377
 					break;
378 378
 				default:
379
-					throw new BadRequest('Unknown property: ' . $property);
379
+					throw new BadRequest('Unknown property: '.$property);
380 380
 			}
381 381
 		}
382 382
 
@@ -386,7 +386,7 @@  discard block
 block discarded – undo
386 386
 			$values['displayname'] = $url;
387 387
 		}
388 388
 
389
-		[$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) {
389
+		[$addressBookId, $addressBookRow] = $this->atomic(function() use ($values) {
390 390
 			$query = $this->db->getQueryBuilder();
391 391
 			$query->insert('addressbooks')
392 392
 				->values([
@@ -418,8 +418,8 @@  discard block
 block discarded – undo
418 418
 	 * @return void
419 419
 	 */
420 420
 	public function deleteAddressBook($addressBookId) {
421
-		$this->atomic(function () use ($addressBookId): void {
422
-			$addressBookId = (int)$addressBookId;
421
+		$this->atomic(function() use ($addressBookId): void {
422
+			$addressBookId = (int) $addressBookId;
423 423
 			$addressBookData = $this->getAddressBookById($addressBookId);
424 424
 			$shares = $this->getShares($addressBookId);
425 425
 
@@ -483,7 +483,7 @@  discard block
 block discarded – undo
483 483
 
484 484
 		$result = $query->executeQuery();
485 485
 		while ($row = $result->fetch()) {
486
-			$row['etag'] = '"' . $row['etag'] . '"';
486
+			$row['etag'] = '"'.$row['etag'].'"';
487 487
 
488 488
 			$modified = false;
489 489
 			$row['carddata'] = $this->readBlob($row['carddata'], $modified);
@@ -523,7 +523,7 @@  discard block
 block discarded – undo
523 523
 		if (!$row) {
524 524
 			return false;
525 525
 		}
526
-		$row['etag'] = '"' . $row['etag'] . '"';
526
+		$row['etag'] = '"'.$row['etag'].'"';
527 527
 
528 528
 		$modified = false;
529 529
 		$row['carddata'] = $this->readBlob($row['carddata'], $modified);
@@ -565,7 +565,7 @@  discard block
 block discarded – undo
565 565
 			$result = $query->executeQuery();
566 566
 
567 567
 			while ($row = $result->fetch()) {
568
-				$row['etag'] = '"' . $row['etag'] . '"';
568
+				$row['etag'] = '"'.$row['etag'].'"';
569 569
 
570 570
 				$modified = false;
571 571
 				$row['carddata'] = $this->readBlob($row['carddata'], $modified);
@@ -609,7 +609,7 @@  discard block
 block discarded – undo
609 609
 	public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
610 610
 		$etag = md5($cardData);
611 611
 		$uid = $this->getUID($cardData);
612
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
612
+		return $this->atomic(function() use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
613 613
 			if ($checkAlreadyExists) {
614 614
 				$q = $this->db->getQueryBuilder();
615 615
 				$q->select('uid')
@@ -618,7 +618,7 @@  discard block
 block discarded – undo
618 618
 					->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
619 619
 					->setMaxResults(1);
620 620
 				$result = $q->executeQuery();
621
-				$count = (bool)$result->fetchOne();
621
+				$count = (bool) $result->fetchOne();
622 622
 				$result->closeCursor();
623 623
 				if ($count) {
624 624
 					throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
@@ -649,7 +649,7 @@  discard block
 block discarded – undo
649 649
 			$objectRow = $this->getCard($addressBookId, $cardUri);
650 650
 			$this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
651 651
 
652
-			return '"' . $etag . '"';
652
+			return '"'.$etag.'"';
653 653
 		}, $this->db);
654 654
 	}
655 655
 
@@ -682,13 +682,13 @@  discard block
 block discarded – undo
682 682
 		$uid = $this->getUID($cardData);
683 683
 		$etag = md5($cardData);
684 684
 
685
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
685
+		return $this->atomic(function() use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
686 686
 			$query = $this->db->getQueryBuilder();
687 687
 
688 688
 			// check for recently stored etag and stop if it is the same
689 689
 			$etagCacheKey = "$addressBookId#$cardUri";
690 690
 			if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
691
-				return '"' . $etag . '"';
691
+				return '"'.$etag.'"';
692 692
 			}
693 693
 
694 694
 			$query->update($this->dbCardsTable)
@@ -710,7 +710,7 @@  discard block
 block discarded – undo
710 710
 			$shares = $this->getShares($addressBookId);
711 711
 			$objectRow = $this->getCard($addressBookId, $cardUri);
712 712
 			$this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
713
-			return '"' . $etag . '"';
713
+			return '"'.$etag.'"';
714 714
 		}, $this->db);
715 715
 	}
716 716
 
@@ -718,12 +718,12 @@  discard block
 block discarded – undo
718 718
 	 * @throws Exception
719 719
 	 */
720 720
 	public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int $targetAddressBookId, string $tragetObjectUri): bool {
721
-		return $this->atomic(function () use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
721
+		return $this->atomic(function() use ($sourceAddressBookId, $sourceObjectUri, $targetAddressBookId, $tragetObjectUri) {
722 722
 			$card = $this->getCard($sourceAddressBookId, $sourceObjectUri);
723 723
 			if (empty($card)) {
724 724
 				return false;
725 725
 			}
726
-			$sourceObjectId = (int)$card['id'];
726
+			$sourceObjectId = (int) $card['id'];
727 727
 
728 728
 			$query = $this->db->getQueryBuilder();
729 729
 			$query->update('cards')
@@ -766,7 +766,7 @@  discard block
 block discarded – undo
766 766
 	 * @return bool
767 767
 	 */
768 768
 	public function deleteCard($addressBookId, $cardUri) {
769
-		return $this->atomic(function () use ($addressBookId, $cardUri) {
769
+		return $this->atomic(function() use ($addressBookId, $cardUri) {
770 770
 			$addressBookData = $this->getAddressBookById($addressBookId);
771 771
 			$shares = $this->getShares($addressBookId);
772 772
 			$objectRow = $this->getCard($addressBookId, $cardUri);
@@ -856,7 +856,7 @@  discard block
 block discarded – undo
856 856
 		$maxLimit = $this->config->getSystemValueInt('carddav_sync_request_truncation', 2500);
857 857
 		$limit = ($limit === null) ? $maxLimit : min($limit, $maxLimit);
858 858
 		// Current synctoken
859
-		return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
859
+		return $this->atomic(function() use ($addressBookId, $syncToken, $syncLevel, $limit) {
860 860
 			$qb = $this->db->getQueryBuilder();
861 861
 			$qb->select('synctoken')
862 862
 				->from('addressbooks')
@@ -900,7 +900,7 @@  discard block
 block discarded – undo
900 900
 				} else {
901 901
 					$lastID = $values[array_key_last($values)]['id'];
902 902
 					$result['added'] = array_column($values, 'uri');
903
-					$result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken ;
903
+					$result['syncToken'] = count($result['added']) >= $limit ? "init_{$lastID}_$initialSyncToken" : $initialSyncToken;
904 904
 					$result['result_truncated'] = count($result['added']) >= $limit;
905 905
 				}
906 906
 			} elseif ($syncToken) {
@@ -984,7 +984,7 @@  discard block
 block discarded – undo
984 984
 				}
985 985
 				$lastID = $values[array_key_last($values)]['id'];
986 986
 				if (count($values) >= $limit) {
987
-					$result['syncToken'] = 'init_' . $lastID . '_' . $currentToken;
987
+					$result['syncToken'] = 'init_'.$lastID.'_'.$currentToken;
988 988
 					$result['result_truncated'] = true;
989 989
 				}
990 990
 
@@ -1005,13 +1005,13 @@  discard block
 block discarded – undo
1005 1005
 	 * @return void
1006 1006
 	 */
1007 1007
 	protected function addChange(int $addressBookId, string $objectUri, int $operation): void {
1008
-		$this->atomic(function () use ($addressBookId, $objectUri, $operation): void {
1008
+		$this->atomic(function() use ($addressBookId, $objectUri, $operation): void {
1009 1009
 			$query = $this->db->getQueryBuilder();
1010 1010
 			$query->select('synctoken')
1011 1011
 				->from('addressbooks')
1012 1012
 				->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)));
1013 1013
 			$result = $query->executeQuery();
1014
-			$syncToken = (int)$result->fetchOne();
1014
+			$syncToken = (int) $result->fetchOne();
1015 1015
 			$result->closeCursor();
1016 1016
 
1017 1017
 			$query = $this->db->getQueryBuilder();
@@ -1080,7 +1080,7 @@  discard block
 block discarded – undo
1080 1080
 	 * @param list<string> $remove
1081 1081
 	 */
1082 1082
 	public function updateShares(IShareable $shareable, array $add, array $remove): void {
1083
-		$this->atomic(function () use ($shareable, $add, $remove): void {
1083
+		$this->atomic(function() use ($shareable, $add, $remove): void {
1084 1084
 			$addressBookId = $shareable->getResourceId();
1085 1085
 			$addressBookData = $this->getAddressBookById($addressBookId);
1086 1086
 			$oldShares = $this->getShares($addressBookId);
@@ -1107,7 +1107,7 @@  discard block
 block discarded – undo
1107 1107
 	 * @return array an array of contacts which are arrays of key-value-pairs
1108 1108
 	 */
1109 1109
 	public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1110
-		return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) {
1110
+		return $this->atomic(function() use ($addressBookId, $pattern, $searchProperties, $options) {
1111 1111
 			return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1112 1112
 		}, $this->db);
1113 1113
 	}
@@ -1125,9 +1125,9 @@  discard block
 block discarded – undo
1125 1125
 		string $pattern,
1126 1126
 		array $searchProperties,
1127 1127
 		array $options = []): array {
1128
-		return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) {
1129
-			$addressBookIds = array_map(static function ($row):int {
1130
-				return (int)$row['id'];
1128
+		return $this->atomic(function() use ($principalUri, $pattern, $searchProperties, $options) {
1129
+			$addressBookIds = array_map(static function($row):int {
1130
+				return (int) $row['id'];
1131 1131
 			}, $this->getAddressBooksForUser($principalUri));
1132 1132
 
1133 1133
 			return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
@@ -1162,7 +1162,7 @@  discard block
 block discarded – undo
1162 1162
 		$useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1163 1163
 
1164 1164
 		if ($escapePattern) {
1165
-			$searchProperties = array_filter($searchProperties, function ($property) use ($pattern) {
1165
+			$searchProperties = array_filter($searchProperties, function($property) use ($pattern) {
1166 1166
 				if ($property === 'EMAIL' && str_contains($pattern, ' ')) {
1167 1167
 					// There can be no spaces in emails
1168 1168
 					return false;
@@ -1195,7 +1195,7 @@  discard block
 block discarded – undo
1195 1195
 			} elseif (!$escapePattern) {
1196 1196
 				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1197 1197
 			} else {
1198
-				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1198
+				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%'.$this->db->escapeLikeParameter($pattern).'%')));
1199 1199
 			}
1200 1200
 		}
1201 1201
 		if (isset($options['limit'])) {
@@ -1206,7 +1206,7 @@  discard block
 block discarded – undo
1206 1206
 		}
1207 1207
 
1208 1208
 		if (isset($options['person'])) {
1209
-			$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%')));
1209
+			$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%'.$this->db->escapeLikeParameter($options['person']).'%')));
1210 1210
 		}
1211 1211
 		if (isset($options['since']) || isset($options['until'])) {
1212 1212
 			$query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid');
@@ -1234,8 +1234,8 @@  discard block
 block discarded – undo
1234 1234
 		$result = $query2->executeQuery();
1235 1235
 		$matches = $result->fetchAll();
1236 1236
 		$result->closeCursor();
1237
-		$matches = array_map(function ($match) {
1238
-			return (int)$match['cardid'];
1237
+		$matches = array_map(function($match) {
1238
+			return (int) $match['cardid'];
1239 1239
 		}, $matches);
1240 1240
 
1241 1241
 		$cardResults = [];
@@ -1252,8 +1252,8 @@  discard block
 block discarded – undo
1252 1252
 		}
1253 1253
 
1254 1254
 		$cards = array_merge(...$cardResults);
1255
-		return array_map(function ($array) {
1256
-			$array['addressbookid'] = (int)$array['addressbookid'];
1255
+		return array_map(function($array) {
1256
+			$array['addressbookid'] = (int) $array['addressbookid'];
1257 1257
 			$modified = false;
1258 1258
 			$array['carddata'] = $this->readBlob($array['carddata'], $modified);
1259 1259
 			if ($modified) {
@@ -1299,7 +1299,7 @@  discard block
 block discarded – undo
1299 1299
 		$result->closeCursor();
1300 1300
 
1301 1301
 		if (!isset($uri['uri'])) {
1302
-			throw new \InvalidArgumentException('Card does not exists: ' . $id);
1302
+			throw new \InvalidArgumentException('Card does not exists: '.$id);
1303 1303
 		}
1304 1304
 
1305 1305
 		return $uri['uri'];
@@ -1324,7 +1324,7 @@  discard block
 block discarded – undo
1324 1324
 
1325 1325
 		if (is_array($contact)) {
1326 1326
 			$modified = false;
1327
-			$contact['etag'] = '"' . $contact['etag'] . '"';
1327
+			$contact['etag'] = '"'.$contact['etag'].'"';
1328 1328
 			$contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1329 1329
 			if ($modified) {
1330 1330
 				$contact['size'] = strlen($contact['carddata']);
@@ -1359,7 +1359,7 @@  discard block
 block discarded – undo
1359 1359
 	 * @param string $vCardSerialized
1360 1360
 	 */
1361 1361
 	protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1362
-		$this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void {
1362
+		$this->atomic(function() use ($addressBookId, $cardUri, $vCardSerialized): void {
1363 1363
 			$cardId = $this->getCardId($addressBookId, $cardUri);
1364 1364
 			$vCard = $this->readCard($vCardSerialized);
1365 1365
 
@@ -1434,10 +1434,10 @@  discard block
 block discarded – undo
1434 1434
 		$result->closeCursor();
1435 1435
 
1436 1436
 		if (!isset($cardIds['id'])) {
1437
-			throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1437
+			throw new \InvalidArgumentException('Card does not exists: '.$uri);
1438 1438
 		}
1439 1439
 
1440
-		return (int)$cardIds['id'];
1440
+		return (int) $cardIds['id'];
1441 1441
 	}
1442 1442
 
1443 1443
 	/**
@@ -1465,7 +1465,7 @@  discard block
 block discarded – undo
1465 1465
 			->from('addressbookchanges');
1466 1466
 
1467 1467
 		$result = $query->executeQuery();
1468
-		$maxId = (int)$result->fetchOne();
1468
+		$maxId = (int) $result->fetchOne();
1469 1469
 		$result->closeCursor();
1470 1470
 		if (!$maxId || $maxId < $keep) {
1471 1471
 			return 0;
@@ -1492,8 +1492,8 @@  discard block
 block discarded – undo
1492 1492
 	}
1493 1493
 
1494 1494
 	private function addOwnerPrincipal(array &$addressbookInfo): void {
1495
-		$ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1496
-		$displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1495
+		$ownerPrincipalKey = '{'.\OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD.'}owner-principal';
1496
+		$displaynameKey = '{'.\OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD.'}owner-displayname';
1497 1497
 		if (isset($addressbookInfo[$ownerPrincipalKey])) {
1498 1498
 			$uri = $addressbookInfo[$ownerPrincipalKey];
1499 1499
 		} else {
Please login to merge, or discard this patch.