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