Passed
Push — master ( 613b2a...08e11d )
by Christoph
14:08 queued 13s
created

CardDavBackend::pruneOutdatedSyncTokens()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 7
nc 2
nop 1
dl 0
loc 9
rs 10
c 0
b 0
f 0
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\AppFramework\Db\TTransactional;
49
use OCP\DB\QueryBuilder\IQueryBuilder;
50
use OCP\EventDispatcher\IEventDispatcher;
51
use OCP\IDBConnection;
52
use OCP\IGroupManager;
53
use OCP\IUser;
54
use OCP\IUserManager;
55
use PDO;
56
use Sabre\CardDAV\Backend\BackendInterface;
57
use Sabre\CardDAV\Backend\SyncSupport;
58
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...
59
use Sabre\DAV\Exception\BadRequest;
60
use Sabre\VObject\Component\VCard;
61
use Sabre\VObject\Reader;
62
63
class CardDavBackend implements BackendInterface, SyncSupport {
64
65
	use TTransactional;
66
67
	public const PERSONAL_ADDRESSBOOK_URI = 'contacts';
68
	public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts';
69
70
	private Principal $principalBackend;
71
	private string $dbCardsTable = 'cards';
72
	private string $dbCardsPropertiesTable = 'cards_properties';
73
	private IDBConnection $db;
74
	private Backend $sharingBackend;
75
76
	/** @var array properties to index */
77
	public static array $indexProperties = [
78
		'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME',
79
		'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO',
80
		'CLOUD', 'X-SOCIALPROFILE'];
81
82
	/**
83
	 * @var string[] Map of uid => display name
84
	 */
85
	protected array $userDisplayNames;
86
	private IUserManager $userManager;
87
	private IEventDispatcher $dispatcher;
88
	private array $etagCache = [];
89
90
	/**
91
	 * CardDavBackend constructor.
92
	 *
93
	 * @param IDBConnection $db
94
	 * @param Principal $principalBackend
95
	 * @param IUserManager $userManager
96
	 * @param IGroupManager $groupManager
97
	 * @param IEventDispatcher $dispatcher
98
	 */
99
	public function __construct(IDBConnection $db,
100
								Principal $principalBackend,
101
								IUserManager $userManager,
102
								IGroupManager $groupManager,
103
								IEventDispatcher $dispatcher) {
104
		$this->db = $db;
105
		$this->principalBackend = $principalBackend;
106
		$this->userManager = $userManager;
107
		$this->dispatcher = $dispatcher;
108
		$this->sharingBackend = new Backend($this->db, $this->userManager, $groupManager, $principalBackend, 'addressbook');
109
	}
110
111
	/**
112
	 * Return the number of address books for a principal
113
	 *
114
	 * @param $principalUri
115
	 * @return int
116
	 */
117
	public function getAddressBooksForUserCount($principalUri) {
118
		$principalUri = $this->convertPrincipal($principalUri, true);
119
		$query = $this->db->getQueryBuilder();
120
		$query->select($query->func()->count('*'))
121
			->from('addressbooks')
122
			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
123
124
		$result = $query->executeQuery();
125
		$column = (int) $result->fetchOne();
126
		$result->closeCursor();
127
		return $column;
128
	}
129
130
	/**
131
	 * Returns the list of address books for a specific user.
132
	 *
133
	 * Every addressbook should have the following properties:
134
	 *   id - an arbitrary unique id
135
	 *   uri - the 'basename' part of the url
136
	 *   principaluri - Same as the passed parameter
137
	 *
138
	 * Any additional clark-notation property may be passed besides this. Some
139
	 * common ones are :
140
	 *   {DAV:}displayname
141
	 *   {urn:ietf:params:xml:ns:carddav}addressbook-description
142
	 *   {http://calendarserver.org/ns/}getctag
143
	 *
144
	 * @param string $principalUri
145
	 * @return array
146
	 */
147
	public function getAddressBooksForUser($principalUri) {
148
		$principalUriOriginal = $principalUri;
149
		$principalUri = $this->convertPrincipal($principalUri, true);
150
		$query = $this->db->getQueryBuilder();
151
		$query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
152
			->from('addressbooks')
153
			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
154
155
		$addressBooks = [];
156
157
		$result = $query->execute();
158
		while ($row = $result->fetch()) {
159
			$addressBooks[$row['id']] = [
160
				'id' => $row['id'],
161
				'uri' => $row['uri'],
162
				'principaluri' => $this->convertPrincipal($row['principaluri'], false),
163
				'{DAV:}displayname' => $row['displayname'],
164
				'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
165
				'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
166
				'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
167
			];
168
169
			$this->addOwnerPrincipal($addressBooks[$row['id']]);
170
		}
171
		$result->closeCursor();
172
173
		// query for shared addressbooks
174
		$principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
175
		$principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUriOriginal));
176
177
		$principals[] = $principalUri;
178
179
		$query = $this->db->getQueryBuilder();
180
		$result = $query->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
181
			->from('dav_shares', 's')
182
			->join('s', 'addressbooks', 'a', $query->expr()->eq('s.resourceid', 'a.id'))
183
			->where($query->expr()->in('s.principaluri', $query->createParameter('principaluri')))
184
			->andWhere($query->expr()->eq('s.type', $query->createParameter('type')))
185
			->setParameter('type', 'addressbook')
186
			->setParameter('principaluri', $principals, IQueryBuilder::PARAM_STR_ARRAY)
187
			->execute();
188
189
		$readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
190
		while ($row = $result->fetch()) {
191
			if ($row['principaluri'] === $principalUri) {
192
				continue;
193
			}
194
195
			$readOnly = (int)$row['access'] === Backend::ACCESS_READ;
196
			if (isset($addressBooks[$row['id']])) {
197
				if ($readOnly) {
198
					// New share can not have more permissions then the old one.
199
					continue;
200
				}
201
				if (isset($addressBooks[$row['id']][$readOnlyPropertyName]) &&
202
					$addressBooks[$row['id']][$readOnlyPropertyName] === 0) {
203
					// Old share is already read-write, no more permissions can be gained
204
					continue;
205
				}
206
			}
207
208
			[, $name] = \Sabre\Uri\split($row['principaluri']);
209
			$uri = $row['uri'] . '_shared_by_' . $name;
210
			$displayName = $row['displayname'] . ' (' . $this->getUserDisplayName($name) . ')';
211
212
			$addressBooks[$row['id']] = [
213
				'id' => $row['id'],
214
				'uri' => $uri,
215
				'principaluri' => $principalUriOriginal,
216
				'{DAV:}displayname' => $displayName,
217
				'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
218
				'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
219
				'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
220
				'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
221
				$readOnlyPropertyName => $readOnly,
222
			];
223
224
			$this->addOwnerPrincipal($addressBooks[$row['id']]);
225
		}
226
		$result->closeCursor();
227
228
		return array_values($addressBooks);
229
	}
230
231
	public function getUsersOwnAddressBooks($principalUri) {
232
		$principalUri = $this->convertPrincipal($principalUri, true);
233
		$query = $this->db->getQueryBuilder();
234
		$query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
235
			->from('addressbooks')
236
			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
237
238
		$addressBooks = [];
239
240
		$result = $query->execute();
241
		while ($row = $result->fetch()) {
242
			$addressBooks[$row['id']] = [
243
				'id' => $row['id'],
244
				'uri' => $row['uri'],
245
				'principaluri' => $this->convertPrincipal($row['principaluri'], false),
246
				'{DAV:}displayname' => $row['displayname'],
247
				'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
248
				'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
249
				'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
250
			];
251
252
			$this->addOwnerPrincipal($addressBooks[$row['id']]);
253
		}
254
		$result->closeCursor();
255
256
		return array_values($addressBooks);
257
	}
258
259
	private function getUserDisplayName($uid) {
260
		if (!isset($this->userDisplayNames[$uid])) {
261
			$user = $this->userManager->get($uid);
262
263
			if ($user instanceof IUser) {
264
				$this->userDisplayNames[$uid] = $user->getDisplayName();
265
			} else {
266
				$this->userDisplayNames[$uid] = $uid;
267
			}
268
		}
269
270
		return $this->userDisplayNames[$uid];
271
	}
272
273
	/**
274
	 * @param int $addressBookId
275
	 */
276
	public function getAddressBookById(int $addressBookId): ?array {
277
		$query = $this->db->getQueryBuilder();
278
		$result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
279
			->from('addressbooks')
280
			->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
281
			->executeQuery();
282
		$row = $result->fetch();
283
		$result->closeCursor();
284
		if (!$row) {
285
			return null;
286
		}
287
288
		$addressBook = [
289
			'id' => $row['id'],
290
			'uri' => $row['uri'],
291
			'principaluri' => $row['principaluri'],
292
			'{DAV:}displayname' => $row['displayname'],
293
			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
294
			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
295
			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
296
		];
297
298
		$this->addOwnerPrincipal($addressBook);
299
300
		return $addressBook;
301
	}
302
303
	public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array {
304
		$query = $this->db->getQueryBuilder();
305
		$result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
306
			->from('addressbooks')
307
			->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri)))
308
			->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
309
			->setMaxResults(1)
310
			->executeQuery();
311
312
		$row = $result->fetch();
313
		$result->closeCursor();
314
		if ($row === false) {
315
			return null;
316
		}
317
318
		$addressBook = [
319
			'id' => $row['id'],
320
			'uri' => $row['uri'],
321
			'principaluri' => $row['principaluri'],
322
			'{DAV:}displayname' => $row['displayname'],
323
			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
324
			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
325
			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
326
		];
327
328
		$this->addOwnerPrincipal($addressBook);
329
330
		return $addressBook;
331
	}
332
333
	/**
334
	 * Updates properties for an address book.
335
	 *
336
	 * The list of mutations is stored in a Sabre\DAV\PropPatch object.
337
	 * To do the actual updates, you must tell this object which properties
338
	 * you're going to process with the handle() method.
339
	 *
340
	 * Calling the handle method is like telling the PropPatch object "I
341
	 * promise I can handle updating this property".
342
	 *
343
	 * Read the PropPatch documentation for more info and examples.
344
	 *
345
	 * @param string $addressBookId
346
	 * @param \Sabre\DAV\PropPatch $propPatch
347
	 * @return void
348
	 */
349
	public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
350
		$supportedProperties = [
351
			'{DAV:}displayname',
352
			'{' . Plugin::NS_CARDDAV . '}addressbook-description',
353
		];
354
355
		$propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
356
			$updates = [];
357
			foreach ($mutations as $property => $newValue) {
358
				switch ($property) {
359
					case '{DAV:}displayname':
360
						$updates['displayname'] = $newValue;
361
						break;
362
					case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
363
						$updates['description'] = $newValue;
364
						break;
365
				}
366
			}
367
			$query = $this->db->getQueryBuilder();
368
			$query->update('addressbooks');
369
370
			foreach ($updates as $key => $value) {
371
				$query->set($key, $query->createNamedParameter($value));
372
			}
373
			$query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
374
				->executeStatement();
375
376
			$this->addChange($addressBookId, "", 2);
377
378
			$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...
379
			$shares = $this->getShares((int)$addressBookId);
380
			$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

380
			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, /** @scrutinizer ignore-type */ $addressBookRow, $shares, $mutations));
Loading history...
381
382
			return true;
383
		});
384
	}
385
386
	/**
387
	 * Creates a new address book
388
	 *
389
	 * @param string $principalUri
390
	 * @param string $url Just the 'basename' of the url.
391
	 * @param array $properties
392
	 * @return int
393
	 * @throws BadRequest
394
	 */
395
	public function createAddressBook($principalUri, $url, array $properties) {
396
		if (strlen($url) > 255) {
397
			throw new BadRequest('URI too long. Address book not created');
398
		}
399
400
		$values = [
401
			'displayname' => null,
402
			'description' => null,
403
			'principaluri' => $principalUri,
404
			'uri' => $url,
405
			'synctoken' => 1
406
		];
407
408
		foreach ($properties as $property => $newValue) {
409
			switch ($property) {
410
				case '{DAV:}displayname':
411
					$values['displayname'] = $newValue;
412
					break;
413
				case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
414
					$values['description'] = $newValue;
415
					break;
416
				default:
417
					throw new BadRequest('Unknown property: ' . $property);
418
			}
419
		}
420
421
		// Fallback to make sure the displayname is set. Some clients may refuse
422
		// to work with addressbooks not having a displayname.
423
		if (is_null($values['displayname'])) {
424
			$values['displayname'] = $url;
425
		}
426
427
		[$addressBookId, $addressBookRow] = $this->atomic(function() use ($values) {
428
			$query = $this->db->getQueryBuilder();
429
			$query->insert('addressbooks')
430
				->values([
431
					'uri' => $query->createParameter('uri'),
432
					'displayname' => $query->createParameter('displayname'),
433
					'description' => $query->createParameter('description'),
434
					'principaluri' => $query->createParameter('principaluri'),
435
					'synctoken' => $query->createParameter('synctoken'),
436
				])
437
				->setParameters($values)
438
				->execute();
439
440
			$addressBookId = $query->getLastInsertId();
441
			return [
442
				$addressBookId,
443
				$this->getAddressBookById($addressBookId),
0 ignored issues
show
Bug introduced by
Are you sure the usage of $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 used.

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

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

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

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

683
		$this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, /** @scrutinizer ignore-type */ $addressBookData, $shares, $objectRow));
Loading history...
684
685
		return '"' . $etag . '"';
686
	}
687
688
	/**
689
	 * Updates a card.
690
	 *
691
	 * The addressbook id will be passed as the first argument. This is the
692
	 * same id as it is returned from the getAddressBooksForUser method.
693
	 *
694
	 * The cardUri is a base uri, and doesn't include the full path. The
695
	 * cardData argument is the vcard body, and is passed as a string.
696
	 *
697
	 * It is possible to return an ETag from this method. This ETag should
698
	 * match that of the updated resource, and must be enclosed with double
699
	 * quotes (that is: the string itself must contain the actual quotes).
700
	 *
701
	 * You should only return the ETag if you store the carddata as-is. If a
702
	 * subsequent GET request on the same card does not have the same body,
703
	 * byte-by-byte and you did return an ETag here, clients tend to get
704
	 * confused.
705
	 *
706
	 * If you don't return an ETag, you can just return null.
707
	 *
708
	 * @param mixed $addressBookId
709
	 * @param string $cardUri
710
	 * @param string $cardData
711
	 * @return string
712
	 */
713
	public function updateCard($addressBookId, $cardUri, $cardData) {
714
		$uid = $this->getUID($cardData);
715
		$etag = md5($cardData);
716
		$query = $this->db->getQueryBuilder();
717
718
		// check for recently stored etag and stop if it is the same
719
		$etagCacheKey = "$addressBookId#$cardUri";
720
		if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
721
			return '"' . $etag . '"';
722
		}
723
724
		$query->update($this->dbCardsTable)
725
			->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB))
726
			->set('lastmodified', $query->createNamedParameter(time()))
727
			->set('size', $query->createNamedParameter(strlen($cardData)))
728
			->set('etag', $query->createNamedParameter($etag))
729
			->set('uid', $query->createNamedParameter($uid))
730
			->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
731
			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
732
			->execute();
733
734
		$this->etagCache[$etagCacheKey] = $etag;
735
736
		$this->addChange($addressBookId, $cardUri, 2);
737
		$this->updateProperties($addressBookId, $cardUri, $cardData);
738
739
		$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...
740
		$shares = $this->getShares($addressBookId);
741
		$objectRow = $this->getCard($addressBookId, $cardUri);
742
		$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

742
		$this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, /** @scrutinizer ignore-type */ $addressBookData, $shares, $objectRow));
Loading history...
743
		return '"' . $etag . '"';
744
	}
745
746
	/**
747
	 * Deletes a card
748
	 *
749
	 * @param mixed $addressBookId
750
	 * @param string $cardUri
751
	 * @return bool
752
	 */
753
	public function deleteCard($addressBookId, $cardUri) {
754
		$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...
755
		$shares = $this->getShares($addressBookId);
756
		$objectRow = $this->getCard($addressBookId, $cardUri);
757
758
		try {
759
			$cardId = $this->getCardId($addressBookId, $cardUri);
760
		} catch (\InvalidArgumentException $e) {
761
			$cardId = null;
762
		}
763
		$query = $this->db->getQueryBuilder();
764
		$ret = $query->delete($this->dbCardsTable)
765
			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
766
			->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
767
			->executeStatement();
768
769
		$this->addChange($addressBookId, $cardUri, 3);
770
771
		if ($ret === 1) {
772
			if ($cardId !== null) {
773
				$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

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

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