Passed
Push — master ( aa150b...fa466a )
by Joas
18:29 queued 11s
created

CardDavBackend::createAddressBook()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 49
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 34
c 1
b 0
f 0
nc 8
nop 3
dl 0
loc 49
rs 8.7537
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, ownCloud, Inc.
4
 *
5
 * @author Arne Hamann <[email protected]>
6
 * @author Arthur Schiwon <[email protected]>
7
 * @author Björn Schießle <[email protected]>
8
 * @author Chih-Hsuan Yen <[email protected]>
9
 * @author Christoph Wurst <[email protected]>
10
 * @author Georg Ehrke <[email protected]>
11
 * @author Joas Schilling <[email protected]>
12
 * @author John Molakvoæ <[email protected]>
13
 * @author matt <[email protected]>
14
 * @author Morris Jobke <[email protected]>
15
 * @author Robin Appelman <[email protected]>
16
 * @author Roeland Jago Douma <[email protected]>
17
 * @author Stefan Weil <[email protected]>
18
 * @author Thomas Citharel <[email protected]>
19
 * @author Thomas Müller <[email protected]>
20
 *
21
 * @license AGPL-3.0
22
 *
23
 * This code is free software: you can redistribute it and/or modify
24
 * it under the terms of the GNU Affero General Public License, version 3,
25
 * as published by the Free Software Foundation.
26
 *
27
 * This program is distributed in the hope that it will be useful,
28
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
29
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
30
 * GNU Affero General Public License for more details.
31
 *
32
 * You should have received a copy of the GNU Affero General Public License, version 3,
33
 * along with this program. If not, see <http://www.gnu.org/licenses/>
34
 *
35
 */
36
namespace OCA\DAV\CardDAV;
37
38
use OCA\DAV\Connector\Sabre\Principal;
39
use OCA\DAV\DAV\Sharing\Backend;
40
use OCA\DAV\DAV\Sharing\IShareable;
41
use OCA\DAV\Events\AddressBookCreatedEvent;
42
use OCA\DAV\Events\AddressBookDeletedEvent;
43
use OCA\DAV\Events\AddressBookShareUpdatedEvent;
44
use OCA\DAV\Events\AddressBookUpdatedEvent;
45
use OCA\DAV\Events\CardCreatedEvent;
46
use OCA\DAV\Events\CardDeletedEvent;
47
use OCA\DAV\Events\CardUpdatedEvent;
48
use OCP\DB\QueryBuilder\IQueryBuilder;
49
use OCP\EventDispatcher\IEventDispatcher;
50
use OCP\IDBConnection;
51
use OCP\IGroupManager;
52
use OCP\IUser;
53
use OCP\IUserManager;
54
use PDO;
55
use Sabre\CardDAV\Backend\BackendInterface;
56
use Sabre\CardDAV\Backend\SyncSupport;
57
use Sabre\CardDAV\Plugin;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, OCA\DAV\CardDAV\Plugin. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
58
use Sabre\DAV\Exception\BadRequest;
59
use Sabre\VObject\Component\VCard;
60
use Sabre\VObject\Reader;
61
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);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $addressBookRow is correct as $this->getAddressBookById((int)$addressBookId) targeting OCA\DAV\CardDAV\CardDavB...d::getAddressBookById() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
375
			$shares = $this->getShares((int)$addressBookId);
376
			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
0 ignored issues
show
Bug introduced by
$addressBookRow of type null is incompatible with the type array expected by parameter $addressBookData of OCA\DAV\Events\AddressBo...tedEvent::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

376
			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, /** @scrutinizer ignore-type */ $addressBookRow, $shares, $mutations));
Loading history...
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
		if (strlen($url) > 255) {
393
			throw new BadRequest('URI too long. Address book not created');
394
		}
395
396
		$values = [
397
			'displayname' => null,
398
			'description' => null,
399
			'principaluri' => $principalUri,
400
			'uri' => $url,
401
			'synctoken' => 1
402
		];
403
404
		foreach ($properties as $property => $newValue) {
405
			switch ($property) {
406
				case '{DAV:}displayname':
407
					$values['displayname'] = $newValue;
408
					break;
409
				case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
410
					$values['description'] = $newValue;
411
					break;
412
				default:
413
					throw new BadRequest('Unknown property: ' . $property);
414
			}
415
		}
416
417
		// Fallback to make sure the displayname is set. Some clients may refuse
418
		// to work with addressbooks not having a displayname.
419
		if (is_null($values['displayname'])) {
420
			$values['displayname'] = $url;
421
		}
422
423
		$query = $this->db->getQueryBuilder();
424
		$query->insert('addressbooks')
425
			->values([
426
				'uri' => $query->createParameter('uri'),
427
				'displayname' => $query->createParameter('displayname'),
428
				'description' => $query->createParameter('description'),
429
				'principaluri' => $query->createParameter('principaluri'),
430
				'synctoken' => $query->createParameter('synctoken'),
431
			])
432
			->setParameters($values)
433
			->execute();
434
435
		$addressBookId = $query->getLastInsertId();
436
		$addressBookRow = $this->getAddressBookById($addressBookId);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $addressBookRow is correct as $this->getAddressBookById($addressBookId) targeting OCA\DAV\CardDAV\CardDavB...d::getAddressBookById() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
437
		$this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow));
0 ignored issues
show
Bug introduced by
$addressBookRow of type null is incompatible with the type array expected by parameter $addressBookData of OCA\DAV\Events\AddressBo...tedEvent::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

437
		$this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, /** @scrutinizer ignore-type */ $addressBookRow));
Loading history...
438
439
		return $addressBookId;
440
	}
441
442
	/**
443
	 * Deletes an entire addressbook and all its contents
444
	 *
445
	 * @param mixed $addressBookId
446
	 * @return void
447
	 */
448
	public function deleteAddressBook($addressBookId) {
449
		$addressBookId = (int)$addressBookId;
450
		$addressBookData = $this->getAddressBookById($addressBookId);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $addressBookData is correct as $this->getAddressBookById($addressBookId) targeting OCA\DAV\CardDAV\CardDavB...d::getAddressBookById() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
451
		$shares = $this->getShares($addressBookId);
452
453
		$query = $this->db->getQueryBuilder();
454
		$query->delete($this->dbCardsTable)
455
			->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
456
			->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
457
			->executeStatement();
458
459
		$query->delete('addressbookchanges')
460
			->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
461
			->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
462
			->executeStatement();
463
464
		$query->delete('addressbooks')
465
			->where($query->expr()->eq('id', $query->createParameter('id')))
466
			->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT)
467
			->executeStatement();
468
469
		$this->sharingBackend->deleteAllShares($addressBookId);
470
471
		$query->delete($this->dbCardsPropertiesTable)
472
			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
473
			->executeStatement();
474
475
		if ($addressBookData) {
0 ignored issues
show
introduced by
$addressBookData is of type null, thus it always evaluated to false.
Loading history...
476
			$this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares));
477
		}
478
	}
479
480
	/**
481
	 * Returns all cards for a specific addressbook id.
482
	 *
483
	 * This method should return the following properties for each card:
484
	 *   * carddata - raw vcard data
485
	 *   * uri - Some unique url
486
	 *   * lastmodified - A unix timestamp
487
	 *
488
	 * It's recommended to also return the following properties:
489
	 *   * etag - A unique etag. This must change every time the card changes.
490
	 *   * size - The size of the card in bytes.
491
	 *
492
	 * If these last two properties are provided, less time will be spent
493
	 * calculating them. If they are specified, you can also omit carddata.
494
	 * This may speed up certain requests, especially with large cards.
495
	 *
496
	 * @param mixed $addressbookId
497
	 * @return array
498
	 */
499
	public function getCards($addressbookId) {
500
		$query = $this->db->getQueryBuilder();
501
		$query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
502
			->from($this->dbCardsTable)
503
			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId)));
504
505
		$cards = [];
506
507
		$result = $query->execute();
508
		while ($row = $result->fetch()) {
509
			$row['etag'] = '"' . $row['etag'] . '"';
510
511
			$modified = false;
512
			$row['carddata'] = $this->readBlob($row['carddata'], $modified);
513
			if ($modified) {
514
				$row['size'] = strlen($row['carddata']);
515
			}
516
517
			$cards[] = $row;
518
		}
519
		$result->closeCursor();
520
521
		return $cards;
522
	}
523
524
	/**
525
	 * Returns a specific card.
526
	 *
527
	 * The same set of properties must be returned as with getCards. The only
528
	 * exception is that 'carddata' is absolutely required.
529
	 *
530
	 * If the card does not exist, you must return false.
531
	 *
532
	 * @param mixed $addressBookId
533
	 * @param string $cardUri
534
	 * @return array
535
	 */
536
	public function getCard($addressBookId, $cardUri) {
537
		$query = $this->db->getQueryBuilder();
538
		$query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
539
			->from($this->dbCardsTable)
540
			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
541
			->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
542
			->setMaxResults(1);
543
544
		$result = $query->execute();
545
		$row = $result->fetch();
546
		if (!$row) {
547
			return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type array.
Loading history...
548
		}
549
		$row['etag'] = '"' . $row['etag'] . '"';
550
551
		$modified = false;
552
		$row['carddata'] = $this->readBlob($row['carddata'], $modified);
553
		if ($modified) {
554
			$row['size'] = strlen($row['carddata']);
555
		}
556
557
		return $row;
558
	}
559
560
	/**
561
	 * Returns a list of cards.
562
	 *
563
	 * This method should work identical to getCard, but instead return all the
564
	 * cards in the list as an array.
565
	 *
566
	 * If the backend supports this, it may allow for some speed-ups.
567
	 *
568
	 * @param mixed $addressBookId
569
	 * @param array $uris
570
	 * @return array
571
	 */
572
	public function getMultipleCards($addressBookId, array $uris) {
573
		if (empty($uris)) {
574
			return [];
575
		}
576
577
		$chunks = array_chunk($uris, 100);
578
		$cards = [];
579
580
		$query = $this->db->getQueryBuilder();
581
		$query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
582
			->from($this->dbCardsTable)
583
			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
584
			->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
585
586
		foreach ($chunks as $uris) {
587
			$query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY);
588
			$result = $query->execute();
589
590
			while ($row = $result->fetch()) {
591
				$row['etag'] = '"' . $row['etag'] . '"';
592
593
				$modified = false;
594
				$row['carddata'] = $this->readBlob($row['carddata'], $modified);
595
				if ($modified) {
596
					$row['size'] = strlen($row['carddata']);
597
				}
598
599
				$cards[] = $row;
600
			}
601
			$result->closeCursor();
602
		}
603
		return $cards;
604
	}
605
606
	/**
607
	 * Creates a new card.
608
	 *
609
	 * The addressbook id will be passed as the first argument. This is the
610
	 * same id as it is returned from the getAddressBooksForUser method.
611
	 *
612
	 * The cardUri is a base uri, and doesn't include the full path. The
613
	 * cardData argument is the vcard body, and is passed as a string.
614
	 *
615
	 * It is possible to return an ETag from this method. This ETag is for the
616
	 * newly created resource, and must be enclosed with double quotes (that
617
	 * is, the string itself must contain the double quotes).
618
	 *
619
	 * You should only return the ETag if you store the carddata as-is. If a
620
	 * subsequent GET request on the same card does not have the same body,
621
	 * byte-by-byte and you did return an ETag here, clients tend to get
622
	 * confused.
623
	 *
624
	 * If you don't return an ETag, you can just return null.
625
	 *
626
	 * @param mixed $addressBookId
627
	 * @param string $cardUri
628
	 * @param string $cardData
629
	 * @param bool $checkAlreadyExists
630
	 * @return string
631
	 */
632
	public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
633
		$etag = md5($cardData);
634
		$uid = $this->getUID($cardData);
635
636
		if ($checkAlreadyExists) {
637
			$q = $this->db->getQueryBuilder();
638
			$q->select('uid')
639
				->from($this->dbCardsTable)
640
				->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
641
				->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
642
				->setMaxResults(1);
643
			$result = $q->executeQuery();
644
			$count = (bool)$result->fetchOne();
645
			$result->closeCursor();
646
			if ($count) {
647
				throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
648
			}
649
		}
650
651
		$query = $this->db->getQueryBuilder();
652
		$query->insert('cards')
653
			->values([
654
				'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB),
655
				'uri' => $query->createNamedParameter($cardUri),
656
				'lastmodified' => $query->createNamedParameter(time()),
657
				'addressbookid' => $query->createNamedParameter($addressBookId),
658
				'size' => $query->createNamedParameter(strlen($cardData)),
659
				'etag' => $query->createNamedParameter($etag),
660
				'uid' => $query->createNamedParameter($uid),
661
			])
662
			->execute();
663
664
		$etagCacheKey = "$addressBookId#$cardUri";
665
		$this->etagCache[$etagCacheKey] = $etag;
666
667
		$this->addChange($addressBookId, $cardUri, 1);
668
		$this->updateProperties($addressBookId, $cardUri, $cardData);
669
670
		$addressBookData = $this->getAddressBookById($addressBookId);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $addressBookData is correct as $this->getAddressBookById($addressBookId) targeting OCA\DAV\CardDAV\CardDavB...d::getAddressBookById() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
671
		$shares = $this->getShares($addressBookId);
672
		$objectRow = $this->getCard($addressBookId, $cardUri);
673
		$this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
0 ignored issues
show
Bug introduced by
$addressBookData of type null is incompatible with the type array expected by parameter $addressBookData of OCA\DAV\Events\CardCreatedEvent::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

673
		$this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, /** @scrutinizer ignore-type */ $addressBookData, $shares, $objectRow));
Loading history...
674
675
		return '"' . $etag . '"';
676
	}
677
678
	/**
679
	 * Updates a card.
680
	 *
681
	 * The addressbook id will be passed as the first argument. This is the
682
	 * same id as it is returned from the getAddressBooksForUser method.
683
	 *
684
	 * The cardUri is a base uri, and doesn't include the full path. The
685
	 * cardData argument is the vcard body, and is passed as a string.
686
	 *
687
	 * It is possible to return an ETag from this method. This ETag should
688
	 * match that of the updated resource, and must be enclosed with double
689
	 * quotes (that is: the string itself must contain the actual quotes).
690
	 *
691
	 * You should only return the ETag if you store the carddata as-is. If a
692
	 * subsequent GET request on the same card does not have the same body,
693
	 * byte-by-byte and you did return an ETag here, clients tend to get
694
	 * confused.
695
	 *
696
	 * If you don't return an ETag, you can just return null.
697
	 *
698
	 * @param mixed $addressBookId
699
	 * @param string $cardUri
700
	 * @param string $cardData
701
	 * @return string
702
	 */
703
	public function updateCard($addressBookId, $cardUri, $cardData) {
704
		$uid = $this->getUID($cardData);
705
		$etag = md5($cardData);
706
		$query = $this->db->getQueryBuilder();
707
708
		// check for recently stored etag and stop if it is the same
709
		$etagCacheKey = "$addressBookId#$cardUri";
710
		if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
711
			return '"' . $etag . '"';
712
		}
713
714
		$query->update($this->dbCardsTable)
715
			->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB))
716
			->set('lastmodified', $query->createNamedParameter(time()))
717
			->set('size', $query->createNamedParameter(strlen($cardData)))
718
			->set('etag', $query->createNamedParameter($etag))
719
			->set('uid', $query->createNamedParameter($uid))
720
			->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
721
			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
722
			->execute();
723
724
		$this->etagCache[$etagCacheKey] = $etag;
725
726
		$this->addChange($addressBookId, $cardUri, 2);
727
		$this->updateProperties($addressBookId, $cardUri, $cardData);
728
729
		$addressBookData = $this->getAddressBookById($addressBookId);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $addressBookData is correct as $this->getAddressBookById($addressBookId) targeting OCA\DAV\CardDAV\CardDavB...d::getAddressBookById() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
730
		$shares = $this->getShares($addressBookId);
731
		$objectRow = $this->getCard($addressBookId, $cardUri);
732
		$this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
0 ignored issues
show
Bug introduced by
$addressBookData of type null is incompatible with the type array expected by parameter $addressBookData of OCA\DAV\Events\CardUpdatedEvent::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

732
		$this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, /** @scrutinizer ignore-type */ $addressBookData, $shares, $objectRow));
Loading history...
733
		return '"' . $etag . '"';
734
	}
735
736
	/**
737
	 * Deletes a card
738
	 *
739
	 * @param mixed $addressBookId
740
	 * @param string $cardUri
741
	 * @return bool
742
	 */
743
	public function deleteCard($addressBookId, $cardUri) {
744
		$addressBookData = $this->getAddressBookById($addressBookId);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $addressBookData is correct as $this->getAddressBookById($addressBookId) targeting OCA\DAV\CardDAV\CardDavB...d::getAddressBookById() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
745
		$shares = $this->getShares($addressBookId);
746
		$objectRow = $this->getCard($addressBookId, $cardUri);
747
748
		try {
749
			$cardId = $this->getCardId($addressBookId, $cardUri);
750
		} catch (\InvalidArgumentException $e) {
751
			$cardId = null;
752
		}
753
		$query = $this->db->getQueryBuilder();
754
		$ret = $query->delete($this->dbCardsTable)
755
			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
756
			->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
757
			->executeStatement();
758
759
		$this->addChange($addressBookId, $cardUri, 3);
760
761
		if ($ret === 1) {
762
			if ($cardId !== null) {
763
				$this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow));
0 ignored issues
show
Bug introduced by
$addressBookData of type null is incompatible with the type array expected by parameter $addressBookData of OCA\DAV\Events\CardDeletedEvent::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

763
				$this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, /** @scrutinizer ignore-type */ $addressBookData, $shares, $objectRow));
Loading history...
764
				$this->purgeProperties($addressBookId, $cardId);
765
			}
766
			return true;
767
		}
768
769
		return false;
770
	}
771
772
	/**
773
	 * The getChanges method returns all the changes that have happened, since
774
	 * the specified syncToken in the specified address book.
775
	 *
776
	 * This function should return an array, such as the following:
777
	 *
778
	 * [
779
	 *   'syncToken' => 'The current synctoken',
780
	 *   'added'   => [
781
	 *      'new.txt',
782
	 *   ],
783
	 *   'modified'   => [
784
	 *      'modified.txt',
785
	 *   ],
786
	 *   'deleted' => [
787
	 *      'foo.php.bak',
788
	 *      'old.txt'
789
	 *   ]
790
	 * ];
791
	 *
792
	 * The returned syncToken property should reflect the *current* syncToken
793
	 * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
794
	 * property. This is needed here too, to ensure the operation is atomic.
795
	 *
796
	 * If the $syncToken argument is specified as null, this is an initial
797
	 * sync, and all members should be reported.
798
	 *
799
	 * The modified property is an array of nodenames that have changed since
800
	 * the last token.
801
	 *
802
	 * The deleted property is an array with nodenames, that have been deleted
803
	 * from collection.
804
	 *
805
	 * The $syncLevel argument is basically the 'depth' of the report. If it's
806
	 * 1, you only have to report changes that happened only directly in
807
	 * immediate descendants. If it's 2, it should also include changes from
808
	 * the nodes below the child collections. (grandchildren)
809
	 *
810
	 * The $limit argument allows a client to specify how many results should
811
	 * be returned at most. If the limit is not specified, it should be treated
812
	 * as infinite.
813
	 *
814
	 * If the limit (infinite or not) is higher than you're willing to return,
815
	 * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
816
	 *
817
	 * If the syncToken is expired (due to data cleanup) or unknown, you must
818
	 * return null.
819
	 *
820
	 * The limit is 'suggestive'. You are free to ignore it.
821
	 *
822
	 * @param string $addressBookId
823
	 * @param string $syncToken
824
	 * @param int $syncLevel
825
	 * @param int|null $limit
826
	 * @return array
827
	 */
828
	public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
829
		// Current synctoken
830
		$qb = $this->db->getQueryBuilder();
831
		$qb->select('synctoken')
832
			->from('addressbooks')
833
			->where(
834
				$qb->expr()->eq('id', $qb->createNamedParameter($addressBookId))
835
			);
836
		$stmt = $qb->executeQuery();
837
		$currentToken = $stmt->fetchOne();
838
		$stmt->closeCursor();
839
840
		if (is_null($currentToken)) {
841
			return [];
842
		}
843
844
		$result = [
845
			'syncToken' => $currentToken,
846
			'added' => [],
847
			'modified' => [],
848
			'deleted' => [],
849
		];
850
851
		if ($syncToken) {
852
			$qb = $this->db->getQueryBuilder();
853
			$qb->select('uri', 'operation')
854
				->from('addressbookchanges')
855
				->where(
856
					$qb->expr()->andX(
857
						$qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)),
858
						$qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)),
859
						$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
860
					)
861
				)->orderBy('synctoken');
862
863
			if (is_int($limit) && $limit > 0) {
864
				$qb->setMaxResults($limit);
865
			}
866
867
			// Fetching all changes
868
			$stmt = $qb->executeQuery();
869
870
			$changes = [];
871
872
			// This loop ensures that any duplicates are overwritten, only the
873
			// last change on a node is relevant.
874
			while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
875
				$changes[$row['uri']] = $row['operation'];
876
			}
877
			$stmt->closeCursor();
878
879
			foreach ($changes as $uri => $operation) {
880
				switch ($operation) {
881
					case 1:
882
						$result['added'][] = $uri;
883
						break;
884
					case 2:
885
						$result['modified'][] = $uri;
886
						break;
887
					case 3:
888
						$result['deleted'][] = $uri;
889
						break;
890
				}
891
			}
892
		} else {
893
			$qb = $this->db->getQueryBuilder();
894
			$qb->select('uri')
895
				->from('cards')
896
				->where(
897
					$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
898
				);
899
			// No synctoken supplied, this is the initial sync.
900
			$stmt = $qb->executeQuery();
901
			$result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN);
902
			$stmt->closeCursor();
903
		}
904
		return $result;
905
	}
906
907
	/**
908
	 * Adds a change record to the addressbookchanges table.
909
	 *
910
	 * @param mixed $addressBookId
911
	 * @param string $objectUri
912
	 * @param int $operation 1 = add, 2 = modify, 3 = delete
913
	 * @return void
914
	 */
915
	protected function addChange($addressBookId, $objectUri, $operation) {
916
		$sql = 'INSERT INTO `*PREFIX*addressbookchanges`(`uri`, `synctoken`, `addressbookid`, `operation`) SELECT ?, `synctoken`, ?, ? FROM `*PREFIX*addressbooks` WHERE `id` = ?';
917
		$stmt = $this->db->prepare($sql);
918
		$stmt->execute([
919
			$objectUri,
920
			$addressBookId,
921
			$operation,
922
			$addressBookId
923
		]);
924
		$stmt = $this->db->prepare('UPDATE `*PREFIX*addressbooks` SET `synctoken` = `synctoken` + 1 WHERE `id` = ?');
925
		$stmt->execute([
926
			$addressBookId
927
		]);
928
	}
929
930
	/**
931
	 * @param resource|string $cardData
932
	 * @param bool $modified
933
	 * @return string
934
	 */
935
	private function readBlob($cardData, &$modified = false) {
936
		if (is_resource($cardData)) {
937
			$cardData = stream_get_contents($cardData);
938
		}
939
940
		// Micro optimisation
941
		// don't loop through
942
		if (strpos($cardData, 'PHOTO:data:') === 0) {
943
			return $cardData;
944
		}
945
946
		$cardDataArray = explode("\r\n", $cardData);
947
948
		$cardDataFiltered = [];
949
		$removingPhoto = false;
950
		foreach ($cardDataArray as $line) {
951
			if (strpos($line, 'PHOTO:data:') === 0
952
				&& strpos($line, 'PHOTO:data:image/') !== 0) {
953
				// Filter out PHOTO data of non-images
954
				$removingPhoto = true;
955
				$modified = true;
956
				continue;
957
			}
958
959
			if ($removingPhoto) {
960
				if (strpos($line, ' ') === 0) {
961
					continue;
962
				}
963
				// No leading space means this is a new property
964
				$removingPhoto = false;
965
			}
966
967
			$cardDataFiltered[] = $line;
968
		}
969
		return implode("\r\n", $cardDataFiltered);
970
	}
971
972
	/**
973
	 * @param list<array{href: string, commonName: string, readOnly: bool}> $add
974
	 * @param list<string> $remove
0 ignored issues
show
Bug introduced by
The type OCA\DAV\CardDAV\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
975
	 */
976
	public function updateShares(IShareable $shareable, array $add, array $remove): void {
977
		$addressBookId = $shareable->getResourceId();
978
		$addressBookData = $this->getAddressBookById($addressBookId);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $addressBookData is correct as $this->getAddressBookById($addressBookId) targeting OCA\DAV\CardDAV\CardDavB...d::getAddressBookById() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
979
		$oldShares = $this->getShares($addressBookId);
980
981
		$this->sharingBackend->updateShares($shareable, $add, $remove);
982
983
		$this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove));
0 ignored issues
show
Bug introduced by
$addressBookData of type null is incompatible with the type array expected by parameter $addressBookData of OCA\DAV\Events\AddressBo...tedEvent::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

983
		$this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, /** @scrutinizer ignore-type */ $addressBookData, $oldShares, $add, $remove));
Loading history...
984
	}
985
986
	/**
987
	 * Search contacts in a specific address-book
988
	 *
989
	 * @param int $addressBookId
990
	 * @param string $pattern which should match within the $searchProperties
991
	 * @param array $searchProperties defines the properties within the query pattern should match
992
	 * @param array $options = array() to define the search behavior
993
	 *    - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are
994
	 *    - 'limit' - Set a numeric limit for the search results
995
	 *    - 'offset' - Set the offset for the limited search results
996
	 *    - 'wildcard' - Whether the search should use wildcards
997
	 * @psalm-param array{escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options
998
	 * @return array an array of contacts which are arrays of key-value-pairs
999
	 */
1000
	public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1001
		return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1002
	}
1003
1004
	/**
1005
	 * Search contacts in all address-books accessible by a user
1006
	 *
1007
	 * @param string $principalUri
1008
	 * @param string $pattern
1009
	 * @param array $searchProperties
1010
	 * @param array $options
1011
	 * @return array
1012
	 */
1013
	public function searchPrincipalUri(string $principalUri,
1014
									   string $pattern,
1015
									   array $searchProperties,
1016
									   array $options = []): array {
1017
		$addressBookIds = array_map(static function ($row):int {
1018
			return (int) $row['id'];
1019
		}, $this->getAddressBooksForUser($principalUri));
1020
1021
		return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
1022
	}
1023
1024
	/**
1025
	 * @param array $addressBookIds
1026
	 * @param string $pattern
1027
	 * @param array $searchProperties
1028
	 * @param array $options
1029
	 * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options
1030
	 * @return array
1031
	 */
1032
	private function searchByAddressBookIds(array $addressBookIds,
1033
											string $pattern,
1034
											array $searchProperties,
1035
											array $options = []): array {
1036
		$escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
1037
		$useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1038
1039
		$query2 = $this->db->getQueryBuilder();
1040
1041
		$addressBookOr = $query2->expr()->orX();
1042
		foreach ($addressBookIds as $addressBookId) {
1043
			$addressBookOr->add($query2->expr()->eq('cp.addressbookid', $query2->createNamedParameter($addressBookId)));
1044
		}
1045
1046
		if ($addressBookOr->count() === 0) {
1047
			return [];
1048
		}
1049
1050
		$propertyOr = $query2->expr()->orX();
1051
		foreach ($searchProperties as $property) {
1052
			if ($escapePattern) {
1053
				if ($property === 'EMAIL' && strpos($pattern, ' ') !== false) {
1054
					// There can be no spaces in emails
1055
					continue;
1056
				}
1057
1058
				if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) {
1059
					// There can be no chars in cloud ids which are not valid for user ids plus :/
1060
					// worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/
1061
					continue;
1062
				}
1063
			}
1064
1065
			$propertyOr->add($query2->expr()->eq('cp.name', $query2->createNamedParameter($property)));
1066
		}
1067
1068
		if ($propertyOr->count() === 0) {
1069
			return [];
1070
		}
1071
1072
		$query2->selectDistinct('cp.cardid')
1073
			->from($this->dbCardsPropertiesTable, 'cp')
1074
			->andWhere($addressBookOr)
1075
			->andWhere($propertyOr);
1076
1077
		// No need for like when the pattern is empty
1078
		if ('' !== $pattern) {
1079
			if (!$useWildcards) {
1080
				$query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern)));
1081
			} elseif (!$escapePattern) {
1082
				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1083
			} else {
1084
				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1085
			}
1086
		}
1087
1088
		if (isset($options['limit'])) {
1089
			$query2->setMaxResults($options['limit']);
1090
		}
1091
		if (isset($options['offset'])) {
1092
			$query2->setFirstResult($options['offset']);
1093
		}
1094
1095
		$result = $query2->execute();
1096
		$matches = $result->fetchAll();
1097
		$result->closeCursor();
1098
		$matches = array_map(function ($match) {
1099
			return (int)$match['cardid'];
1100
		}, $matches);
1101
1102
		$cards = [];
1103
		$query = $this->db->getQueryBuilder();
1104
		$query->select('c.addressbookid', 'c.carddata', 'c.uri')
1105
			->from($this->dbCardsTable, 'c')
1106
			->where($query->expr()->in('c.id', $query->createParameter('matches')));
1107
1108
		foreach (array_chunk($matches, 1000) as $matchesChunk) {
1109
			$query->setParameter('matches', $matchesChunk, IQueryBuilder::PARAM_INT_ARRAY);
1110
			$result = $query->executeQuery();
1111
			$cards = array_merge($cards, $result->fetchAll());
1112
			$result->closeCursor();
1113
		}
1114
1115
		return array_map(function ($array) {
1116
			$array['addressbookid'] = (int) $array['addressbookid'];
1117
			$modified = false;
1118
			$array['carddata'] = $this->readBlob($array['carddata'], $modified);
1119
			if ($modified) {
1120
				$array['size'] = strlen($array['carddata']);
1121
			}
1122
			return $array;
1123
		}, $cards);
1124
	}
1125
1126
	/**
1127
	 * @param int $bookId
1128
	 * @param string $name
1129
	 * @return array
1130
	 */
1131
	public function collectCardProperties($bookId, $name) {
1132
		$query = $this->db->getQueryBuilder();
1133
		$result = $query->selectDistinct('value')
1134
			->from($this->dbCardsPropertiesTable)
1135
			->where($query->expr()->eq('name', $query->createNamedParameter($name)))
1136
			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId)))
1137
			->execute();
1138
1139
		$all = $result->fetchAll(PDO::FETCH_COLUMN);
1140
		$result->closeCursor();
1141
1142
		return $all;
1143
	}
1144
1145
	/**
1146
	 * get URI from a given contact
1147
	 *
1148
	 * @param int $id
1149
	 * @return string
1150
	 */
1151
	public function getCardUri($id) {
1152
		$query = $this->db->getQueryBuilder();
1153
		$query->select('uri')->from($this->dbCardsTable)
1154
			->where($query->expr()->eq('id', $query->createParameter('id')))
1155
			->setParameter('id', $id);
1156
1157
		$result = $query->execute();
1158
		$uri = $result->fetch();
1159
		$result->closeCursor();
1160
1161
		if (!isset($uri['uri'])) {
1162
			throw new \InvalidArgumentException('Card does not exists: ' . $id);
1163
		}
1164
1165
		return $uri['uri'];
1166
	}
1167
1168
	/**
1169
	 * return contact with the given URI
1170
	 *
1171
	 * @param int $addressBookId
1172
	 * @param string $uri
1173
	 * @returns array
1174
	 */
1175
	public function getContact($addressBookId, $uri) {
1176
		$result = [];
1177
		$query = $this->db->getQueryBuilder();
1178
		$query->select('*')->from($this->dbCardsTable)
1179
			->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1180
			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1181
		$queryResult = $query->execute();
1182
		$contact = $queryResult->fetch();
1183
		$queryResult->closeCursor();
1184
1185
		if (is_array($contact)) {
1186
			$modified = false;
1187
			$contact['etag'] = '"' . $contact['etag'] . '"';
1188
			$contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1189
			if ($modified) {
1190
				$contact['size'] = strlen($contact['carddata']);
1191
			}
1192
1193
			$result = $contact;
1194
		}
1195
1196
		return $result;
1197
	}
1198
1199
	/**
1200
	 * Returns the list of people whom this address book is shared with.
1201
	 *
1202
	 * Every element in this array should have the following properties:
1203
	 *   * href - Often a mailto: address
1204
	 *   * commonName - Optional, for example a first + last name
1205
	 *   * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants.
1206
	 *   * readOnly - boolean
1207
	 *
1208
	 * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
1209
	 */
1210
	public function getShares(int $addressBookId): array {
1211
		return $this->sharingBackend->getShares($addressBookId);
1212
	}
1213
1214
	/**
1215
	 * update properties table
1216
	 *
1217
	 * @param int $addressBookId
1218
	 * @param string $cardUri
1219
	 * @param string $vCardSerialized
1220
	 */
1221
	protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1222
		$cardId = $this->getCardId($addressBookId, $cardUri);
1223
		$vCard = $this->readCard($vCardSerialized);
1224
1225
		$this->purgeProperties($addressBookId, $cardId);
1226
1227
		$query = $this->db->getQueryBuilder();
1228
		$query->insert($this->dbCardsPropertiesTable)
1229
			->values(
1230
				[
1231
					'addressbookid' => $query->createNamedParameter($addressBookId),
1232
					'cardid' => $query->createNamedParameter($cardId),
1233
					'name' => $query->createParameter('name'),
1234
					'value' => $query->createParameter('value'),
1235
					'preferred' => $query->createParameter('preferred')
1236
				]
1237
			);
1238
1239
1240
		$this->db->beginTransaction();
1241
1242
		try {
1243
			foreach ($vCard->children() as $property) {
1244
				if (!in_array($property->name, self::$indexProperties)) {
1245
					continue;
1246
				}
1247
				$preferred = 0;
1248
				foreach ($property->parameters as $parameter) {
1249
					if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') {
1250
						$preferred = 1;
1251
						break;
1252
					}
1253
				}
1254
				$query->setParameter('name', $property->name);
1255
				$query->setParameter('value', mb_strcut($property->getValue(), 0, 254));
1256
				$query->setParameter('preferred', $preferred);
1257
				$query->execute();
1258
			}
1259
			$this->db->commit();
1260
		} catch (\Exception $e) {
1261
			$this->db->rollBack();
1262
		}
1263
	}
1264
1265
	/**
1266
	 * read vCard data into a vCard object
1267
	 *
1268
	 * @param string $cardData
1269
	 * @return VCard
1270
	 */
1271
	protected function readCard($cardData) {
1272
		return Reader::read($cardData);
1273
	}
1274
1275
	/**
1276
	 * delete all properties from a given card
1277
	 *
1278
	 * @param int $addressBookId
1279
	 * @param int $cardId
1280
	 */
1281
	protected function purgeProperties($addressBookId, $cardId) {
1282
		$query = $this->db->getQueryBuilder();
1283
		$query->delete($this->dbCardsPropertiesTable)
1284
			->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId)))
1285
			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1286
		$query->execute();
1287
	}
1288
1289
	/**
1290
	 * Get ID from a given contact
1291
	 */
1292
	protected function getCardId(int $addressBookId, string $uri): int {
1293
		$query = $this->db->getQueryBuilder();
1294
		$query->select('id')->from($this->dbCardsTable)
1295
			->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1296
			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1297
1298
		$result = $query->execute();
1299
		$cardIds = $result->fetch();
1300
		$result->closeCursor();
1301
1302
		if (!isset($cardIds['id'])) {
1303
			throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1304
		}
1305
1306
		return (int)$cardIds['id'];
1307
	}
1308
1309
	/**
1310
	 * For shared address books the sharee is set in the ACL of the address book
1311
	 *
1312
	 * @param int $addressBookId
1313
	 * @param list<array{privilege: string, principal: string, protected: bool}> $acl
1314
	 * @return list<array{privilege: string, principal: string, protected: bool}>
1315
	 */
1316
	public function applyShareAcl(int $addressBookId, array $acl): array {
1317
		return $this->sharingBackend->applyShareAcl($addressBookId, $acl);
1318
	}
1319
1320
	private function convertPrincipal(string $principalUri, bool $toV2): string {
1321
		if ($this->principalBackend->getPrincipalPrefix() === 'principals') {
1322
			[, $name] = \Sabre\Uri\split($principalUri);
1323
			if ($toV2 === true) {
1324
				return "principals/users/$name";
1325
			}
1326
			return "principals/$name";
1327
		}
1328
		return $principalUri;
1329
	}
1330
1331
	private function addOwnerPrincipal(array &$addressbookInfo): void {
1332
		$ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1333
		$displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1334
		if (isset($addressbookInfo[$ownerPrincipalKey])) {
1335
			$uri = $addressbookInfo[$ownerPrincipalKey];
1336
		} else {
1337
			$uri = $addressbookInfo['principaluri'];
1338
		}
1339
1340
		$principalInformation = $this->principalBackend->getPrincipalByPath($uri);
1341
		if (isset($principalInformation['{DAV:}displayname'])) {
1342
			$addressbookInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
1343
		}
1344
	}
1345
1346
	/**
1347
	 * Extract UID from vcard
1348
	 *
1349
	 * @param string $cardData the vcard raw data
1350
	 * @return string the uid
1351
	 * @throws BadRequest if no UID is available or vcard is empty
1352
	 */
1353
	private function getUID(string $cardData): string {
1354
		if ($cardData !== '') {
1355
			$vCard = Reader::read($cardData);
1356
			if ($vCard->UID) {
1357
				$uid = $vCard->UID->getValue();
1358
				return $uid;
1359
			}
1360
			// should already be handled, but just in case
1361
			throw new BadRequest('vCards on CardDAV servers MUST have a UID property');
1362
		}
1363
		// should already be handled, but just in case
1364
		throw new BadRequest('vCard can not be empty');
1365
	}
1366
}
1367