1<?php 2/** 3 * @author Arthur Schiwon <blizzz@arthur-schiwon.de> 4 * @author Björn Schießle <bjoern@schiessle.org> 5 * @author Georg Ehrke <georg@owncloud.com> 6 * @author Joas Schilling <coding@schilljs.com> 7 * @author Stefan Weil <sw@weilnetz.de> 8 * @author Thomas Müller <thomas.mueller@tmit.eu> 9 * 10 * @copyright Copyright (c) 2018, ownCloud GmbH 11 * @license AGPL-3.0 12 * 13 * This code is free software: you can redistribute it and/or modify 14 * it under the terms of the GNU Affero General Public License, version 3, 15 * as published by the Free Software Foundation. 16 * 17 * This program is distributed in the hope that it will be useful, 18 * but WITHOUT ANY WARRANTY; without even the implied warranty of 19 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 20 * GNU Affero General Public License for more details. 21 * 22 * You should have received a copy of the GNU Affero General Public License, version 3, 23 * along with this program. If not, see <http://www.gnu.org/licenses/> 24 * 25 */ 26 27namespace OCA\DAV\CardDAV; 28 29use OC\Cache\CappedMemoryCache; 30use OCA\DAV\Connector\Sabre\Principal; 31use OCA\DAV\DAV\GroupPrincipalBackend; 32use OCA\DAV\DAV\Sharing\Backend; 33use OCA\DAV\DAV\Sharing\IShareable; 34use OCP\DB\QueryBuilder\IQueryBuilder; 35use OCP\IDBConnection; 36use PDO; 37use Sabre\CardDAV\Backend\BackendInterface; 38use Sabre\CardDAV\Backend\SyncSupport; 39use Sabre\CardDAV\Plugin; 40use Sabre\DAV\Exception\BadRequest; 41use Sabre\VObject\Component\VCard; 42use Sabre\VObject\Reader; 43use Symfony\Component\EventDispatcher\EventDispatcherInterface; 44use Symfony\Component\EventDispatcher\GenericEvent; 45 46class CardDavBackend implements BackendInterface, SyncSupport { 47 48 /** @var Principal */ 49 private $principalBackend; 50 51 /** @var string */ 52 private $dbCardsTable = 'cards'; 53 54 /** @var string */ 55 private $dbCardsPropertiesTable = 'cards_properties'; 56 57 /** @var IDBConnection */ 58 private $db; 59 60 /** @var Backend */ 61 private $sharingBackend; 62 63 /** @var CappedMemoryCache Cache of card URI to db row ids */ 64 private $idCache; 65 66 /** @var array properties to index */ 67 public static $indexProperties = [ 68 'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME', 69 'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO', 'CLOUD']; 70 71 /** @var EventDispatcherInterface */ 72 private $dispatcher; 73 /** @var bool */ 74 private $legacyMode; 75 76 /** 77 * CardDavBackend constructor. 78 * 79 * @param IDBConnection $db 80 * @param Principal $principalBackend 81 * @param GroupPrincipalBackend $groupPrincipalBackend 82 * @param EventDispatcherInterface $dispatcher 83 * @param bool $legacyMode 84 */ 85 public function __construct( 86 IDBConnection $db, 87 Principal $principalBackend, 88 GroupPrincipalBackend $groupPrincipalBackend, 89 EventDispatcherInterface $dispatcher = null, 90 $legacyMode = false 91 ) { 92 $this->db = $db; 93 $this->principalBackend = $principalBackend; 94 $this->dispatcher = $dispatcher; 95 $this->sharingBackend = new Backend($this->db, $principalBackend, $groupPrincipalBackend, 'addressbook'); 96 $this->legacyMode = $legacyMode; 97 $this->idCache = new CappedMemoryCache(); 98 } 99 100 /** 101 * Returns the list of address books for a specific user. 102 * 103 * Every addressbook should have the following properties: 104 * id - an arbitrary unique id 105 * uri - the 'basename' part of the url 106 * principaluri - Same as the passed parameter 107 * 108 * Any additional clark-notation property may be passed besides this. Some 109 * common ones are : 110 * {DAV:}displayname 111 * {urn:ietf:params:xml:ns:carddav}addressbook-description 112 * {http://calendarserver.org/ns/}getctag 113 * 114 * @param string $principalUri 115 * @return array 116 */ 117 public function getUsersOwnAddressBooks($principalUri) { 118 $principalUri = $this->convertPrincipal($principalUri, true); 119 $query = $this->db->getQueryBuilder(); 120 $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) 121 ->from('addressbooks') 122 ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); 123 124 $addressBooks = []; 125 126 $result = $query->execute(); 127 while ($row = $result->fetch()) { 128 $addressBooks[$row['id']] = [ 129 'id' => $row['id'], 130 'uri' => $row['uri'], 131 'principaluri' => $this->convertPrincipal($row['principaluri']), 132 '{DAV:}displayname' => $row['displayname'], 133 '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], 134 '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], 135 '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', 136 ]; 137 } 138 $result->closeCursor(); 139 return \array_values($addressBooks); 140 } 141 142 /** 143 * Returns the list of address books for a specific user, including shared by other users. 144 * 145 * Every addressbook should have the following properties: 146 * id - an arbitrary unique id 147 * uri - the 'basename' part of the url 148 * principaluri - Same as the passed parameter 149 * 150 * Any additional clark-notation property may be passed besides this. Some 151 * common ones are : 152 * {DAV:}displayname 153 * {urn:ietf:params:xml:ns:carddav}addressbook-description 154 * {http://calendarserver.org/ns/}getctag 155 * 156 * @param string $principalUri 157 * @return array 158 * @throws \Sabre\DAV\Exception 159 */ 160 public function getAddressBooksForUser($principalUri) { 161 $addressBooks = $this->getUsersOwnAddressBooks($principalUri); 162 163 // query for shared calendars 164 $principals = $this->principalBackend->getGroupMembership($principalUri, true); 165 $principals[]= $principalUri; 166 167 $addressBooksIds[] = -1; 168 foreach ($addressBooks as $book) { 169 $addressBooksIds[] = $book['id']; 170 } 171 172 $query = $this->db->getQueryBuilder(); 173 $result = $query->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access']) 174 ->from('dav_shares', 's') 175 ->join('s', 'addressbooks', 'a', $query->expr()->eq('s.resourceid', 'a.id')) 176 ->where($query->expr()->in('s.principaluri', $query->createParameter('principaluri'))) 177 ->andWhere($query->expr()->notIn('a.id', $query->createParameter('ownaddressbookids'))) 178 ->andWhere($query->expr()->eq('s.type', $query->createParameter('type'))) 179 ->setParameter('type', 'addressbook') 180 ->setParameter('ownaddressbookids', $addressBooksIds, IQueryBuilder::PARAM_INT_ARRAY) 181 ->setParameter('principaluri', $principals, IQueryBuilder::PARAM_STR_ARRAY) 182 ->execute(); 183 184 while ($row = $result->fetch()) { 185 list(, $name) = \Sabre\Uri\split($row['principaluri']); 186 $uri = $row['uri'] . '_shared_by_' . $name; 187 $displayName = $row['displayname'] . " ($name)"; 188 if (!isset($addressBooks[$row['id']])) { 189 $addressBooks[$row['id']] = [ 190 'id' => $row['id'], 191 'uri' => $uri, 192 'principaluri' => $principalUri, 193 '{DAV:}displayname' => $displayName, 194 '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], 195 '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], 196 '{http://sabredav.org/ns}sync-token' => $row['synctoken']?:'0', 197 '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'], 198 '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only' => (int)$row['access'] === Backend::ACCESS_READ, 199 ]; 200 } 201 } 202 $result->closeCursor(); 203 204 return \array_values($addressBooks); 205 } 206 207 /** 208 * @param int $addressBookId 209 */ 210 public function getAddressBookById($addressBookId) { 211 $query = $this->db->getQueryBuilder(); 212 $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) 213 ->from('addressbooks') 214 ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))) 215 ->execute(); 216 217 $row = $result->fetch(); 218 $result->closeCursor(); 219 if ($row === false) { 220 return null; 221 } 222 223 return [ 224 'id' => $row['id'], 225 'uri' => $row['uri'], 226 'principaluri' => $row['principaluri'], 227 '{DAV:}displayname' => $row['displayname'], 228 '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], 229 '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], 230 '{http://sabredav.org/ns}sync-token' => $row['synctoken']?:'0', 231 ]; 232 } 233 234 /** 235 * @param $addressBookUri 236 * @return array|null 237 */ 238 public function getAddressBooksByUri($principal, $addressBookUri) { 239 $query = $this->db->getQueryBuilder(); 240 $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken']) 241 ->from('addressbooks') 242 ->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri))) 243 ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal))) 244 ->setMaxResults(1) 245 ->execute(); 246 247 $row = $result->fetch(); 248 $result->closeCursor(); 249 if ($row === false) { 250 return null; 251 } 252 253 return [ 254 'id' => $row['id'], 255 'uri' => $row['uri'], 256 'principaluri' => $row['principaluri'], 257 '{DAV:}displayname' => $row['displayname'], 258 '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], 259 '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], 260 '{http://sabredav.org/ns}sync-token' => $row['synctoken']?:'0', 261 ]; 262 } 263 264 /** 265 * Updates properties for an address book. 266 * 267 * The list of mutations is stored in a Sabre\DAV\PropPatch object. 268 * To do the actual updates, you must tell this object which properties 269 * you're going to process with the handle() method. 270 * 271 * Calling the handle method is like telling the PropPatch object "I 272 * promise I can handle updating this property". 273 * 274 * Read the PropPatch documentation for more info and examples. 275 * 276 * @param string $addressBookId 277 * @param \Sabre\DAV\PropPatch $propPatch 278 * @return void 279 */ 280 public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) { 281 $supportedProperties = [ 282 '{DAV:}displayname', 283 '{' . Plugin::NS_CARDDAV . '}addressbook-description', 284 ]; 285 286 $propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) { 287 $updates = []; 288 foreach ($mutations as $property=>$newValue) { 289 switch ($property) { 290 case '{DAV:}displayname': 291 $updates['displayname'] = $newValue; 292 break; 293 case '{' . Plugin::NS_CARDDAV . '}addressbook-description': 294 $updates['description'] = $newValue; 295 break; 296 } 297 } 298 $query = $this->db->getQueryBuilder(); 299 $query->update('addressbooks'); 300 301 foreach ($updates as $key=>$value) { 302 $query->set($key, $query->createNamedParameter($value)); 303 } 304 $query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))) 305 ->execute(); 306 307 $this->addChange($addressBookId, '', 2); 308 309 return true; 310 }); 311 } 312 313 /** 314 * Creates a new address book 315 * 316 * @param string $principalUri 317 * @param string $url Just the 'basename' of the url. 318 * @param array $properties 319 * @return int 320 * @throws \BadMethodCallException 321 * @throws BadRequest 322 */ 323 public function createAddressBook($principalUri, $url, array $properties) { 324 $values = [ 325 'displayname' => null, 326 'description' => null, 327 'principaluri' => $principalUri, 328 'uri' => $url, 329 'synctoken' => 1 330 ]; 331 332 foreach ($properties as $property=>$newValue) { 333 switch ($property) { 334 case '{DAV:}displayname': 335 $values['displayname'] = $newValue; 336 break; 337 case '{' . Plugin::NS_CARDDAV . '}addressbook-description': 338 $values['description'] = $newValue; 339 break; 340 default: 341 throw new BadRequest('Unknown property: ' . $property); 342 } 343 } 344 345 // Fallback to make sure the displayname is set. Some clients may refuse 346 // to work with addressbooks not having a displayname. 347 if ($values['displayname'] === null) { 348 $values['displayname'] = $url; 349 } 350 351 $query = $this->db->getQueryBuilder(); 352 $query->insert('addressbooks') 353 ->values([ 354 'uri' => $query->createParameter('uri'), 355 'displayname' => $query->createParameter('displayname'), 356 'description' => $query->createParameter('description'), 357 'principaluri' => $query->createParameter('principaluri'), 358 'synctoken' => $query->createParameter('synctoken'), 359 ]) 360 ->setParameters($values) 361 ->execute(); 362 363 return $query->getLastInsertId(); 364 } 365 366 /** 367 * Deletes an entire addressbook and all its contents 368 * 369 * @param mixed $addressBookId 370 * @return void 371 */ 372 public function deleteAddressBook($addressBookId) { 373 $query = $this->db->getQueryBuilder(); 374 $query->delete('cards') 375 ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) 376 ->setParameter('addressbookid', $addressBookId) 377 ->execute(); 378 379 $query->delete('addressbookchanges') 380 ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid'))) 381 ->setParameter('addressbookid', $addressBookId) 382 ->execute(); 383 384 $query->delete('addressbooks') 385 ->where($query->expr()->eq('id', $query->createParameter('id'))) 386 ->setParameter('id', $addressBookId) 387 ->execute(); 388 389 $this->sharingBackend->deleteAllShares($addressBookId); 390 391 $query->delete($this->dbCardsPropertiesTable) 392 ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) 393 ->execute(); 394 } 395 396 /** 397 * Returns all cards for a specific addressbook id. 398 * 399 * This method should return the following properties for each card: 400 * * carddata - raw vcard data 401 * * uri - Some unique url 402 * * lastmodified - A unix timestamp 403 * 404 * It's recommended to also return the following properties: 405 * * etag - A unique etag. This must change every time the card changes. 406 * * size - The size of the card in bytes. 407 * 408 * If these last two properties are provided, less time will be spent 409 * calculating them. If they are specified, you can also ommit carddata. 410 * This may speed up certain requests, especially with large cards. 411 * 412 * @param mixed $addressBookId 413 * @return array 414 */ 415 public function getCards($addressBookId) { 416 $query = $this->db->getQueryBuilder(); 417 $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata']) 418 ->from('cards') 419 ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); 420 421 $cards = []; 422 423 $result = $query->execute(); 424 while ($row = $result->fetch()) { 425 $row['etag'] = '"' . $row['etag'] . '"'; 426 $row['carddata'] = $this->readBlob($row['carddata']); 427 $cards[] = $row; 428 } 429 $result->closeCursor(); 430 431 return $cards; 432 } 433 434 /** 435 * Returns a specific card. 436 * 437 * The same set of properties must be returned as with getCards. The only 438 * exception is that 'carddata' is absolutely required. 439 * 440 * If the card does not exist, you must return false. 441 * 442 * @param mixed $addressBookId 443 * @param string $cardUri 444 * @return array|false 445 */ 446 public function getCard($addressBookId, $cardUri) { 447 $query = $this->db->getQueryBuilder(); 448 $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata']) 449 ->from('cards') 450 ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) 451 ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) 452 ->setMaxResults(1); 453 454 $result = $query->execute(); 455 $row = $result->fetch(); 456 if (!$row) { 457 return false; 458 } 459 $row['etag'] = '"' . $row['etag'] . '"'; 460 $row['carddata'] = $this->readBlob($row['carddata']); 461 462 return $row; 463 } 464 465 /** 466 * Returns a list of cards. 467 * 468 * This method should work identical to getCard, but instead return all the 469 * cards in the list as an array. 470 * 471 * If the backend supports this, it may allow for some speed-ups. 472 * 473 * @param mixed $addressBookId 474 * @param string[] $uris 475 * @return array 476 */ 477 public function getMultipleCards($addressBookId, array $uris) { 478 $chunkSize = 998; 479 if (\count($uris) <= $chunkSize) { 480 $query = $this->db->getQueryBuilder(); 481 $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata']) 482 ->from('cards') 483 ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) 484 ->andWhere($query->expr()->in('uri', $query->createParameter('uri'))) 485 ->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY); 486 487 $cards = []; 488 489 $result = $query->execute(); 490 while ($row = $result->fetch()) { 491 $row['etag'] = '"' . $row['etag'] . '"'; 492 $row['carddata'] = $this->readBlob($row['carddata']); 493 $cards[] = $row; 494 } 495 $result->closeCursor(); 496 497 return $cards; 498 } 499 $chunks = \array_chunk($uris, $chunkSize); 500 $results = \array_map(function ($chunk) use ($addressBookId) { 501 return $this->getMultipleCards($addressBookId, $chunk); 502 }, $chunks); 503 504 return \array_merge(...$results); 505 } 506 507 /** 508 * Creates a new card. 509 * 510 * The addressbook id will be passed as the first argument. This is the 511 * same id as it is returned from the getAddressBooksForUser method. 512 * 513 * The cardUri is a base uri, and doesn't include the full path. The 514 * cardData argument is the vcard body, and is passed as a string. 515 * 516 * It is possible to return an ETag from this method. This ETag is for the 517 * newly created resource, and must be enclosed with double quotes (that 518 * is, the string itself must contain the double quotes). 519 * 520 * You should only return the ETag if you store the carddata as-is. If a 521 * subsequent GET request on the same card does not have the same body, 522 * byte-by-byte and you did return an ETag here, clients tend to get 523 * confused. 524 * 525 * If you don't return an ETag, you can just return null. 526 * 527 * @param mixed $addressBookId 528 * @param string $cardUri 529 * @param string $cardData 530 * @return string 531 * @throws \BadMethodCallException 532 */ 533 public function createCard($addressBookId, $cardUri, $cardData) { 534 $etag = \md5($cardData); 535 536 $query = $this->db->getQueryBuilder(); 537 $query->insert('cards') 538 ->values([ 539 'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB), 540 'uri' => $query->createNamedParameter($cardUri), 541 'lastmodified' => $query->createNamedParameter(\time()), 542 'addressbookid' => $query->createNamedParameter($addressBookId), 543 'size' => $query->createNamedParameter(\strlen($cardData)), 544 'etag' => $query->createNamedParameter($etag), 545 ]) 546 ->execute(); 547 548 // Cache the ID so that it can be used for the updateProperties method 549 $this->idCache->set($addressBookId.$cardUri, $query->getLastInsertId()); 550 551 $this->addChange($addressBookId, $cardUri, 1); 552 $this->updateProperties($addressBookId, $cardUri, $cardData); 553 554 if ($this->dispatcher !== null) { 555 $this->dispatcher->dispatch( 556 '\OCA\DAV\CardDAV\CardDavBackend::createCard', 557 new GenericEvent(null, [ 558 'addressBookId' => $addressBookId, 559 'cardUri' => $cardUri, 560 'cardData' => $cardData]) 561 ); 562 } 563 564 return '"' . $etag . '"'; 565 } 566 567 /** 568 * Updates a card. 569 * 570 * The addressbook id will be passed as the first argument. This is the 571 * same id as it is returned from the getAddressBooksForUser method. 572 * 573 * The cardUri is a base uri, and doesn't include the full path. The 574 * cardData argument is the vcard body, and is passed as a string. 575 * 576 * It is possible to return an ETag from this method. This ETag should 577 * match that of the updated resource, and must be enclosed with double 578 * quotes (that is: the string itself must contain the actual quotes). 579 * 580 * You should only return the ETag if you store the carddata as-is. If a 581 * subsequent GET request on the same card does not have the same body, 582 * byte-by-byte and you did return an ETag here, clients tend to get 583 * confused. 584 * 585 * If you don't return an ETag, you can just return null. 586 * 587 * @param mixed $addressBookId 588 * @param string $cardUri 589 * @param string $cardData 590 * @return string 591 */ 592 public function updateCard($addressBookId, $cardUri, $cardData) { 593 $etag = \md5($cardData); 594 $query = $this->db->getQueryBuilder(); 595 $query->update('cards') 596 ->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB)) 597 ->set('lastmodified', $query->createNamedParameter(\time())) 598 ->set('size', $query->createNamedParameter(\strlen($cardData))) 599 ->set('etag', $query->createNamedParameter($etag)) 600 ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) 601 ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) 602 ->execute(); 603 604 $this->addChange($addressBookId, $cardUri, 2); 605 $this->updateProperties($addressBookId, $cardUri, $cardData); 606 607 if ($this->dispatcher !== null) { 608 $this->dispatcher->dispatch( 609 '\OCA\DAV\CardDAV\CardDavBackend::updateCard', 610 new GenericEvent(null, [ 611 'addressBookId' => $addressBookId, 612 'cardUri' => $cardUri, 613 'cardData' => $cardData]) 614 ); 615 } 616 617 return '"' . $etag . '"'; 618 } 619 620 /** 621 * Deletes a card 622 * 623 * @param mixed $addressBookId 624 * @param string $cardUri 625 * @return bool 626 */ 627 public function deleteCard($addressBookId, $cardUri) { 628 try { 629 $cardId = $this->getCardId($addressBookId, $cardUri); 630 } catch (\InvalidArgumentException $e) { 631 $cardId = null; 632 } 633 $query = $this->db->getQueryBuilder(); 634 $ret = $query->delete('cards') 635 ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))) 636 ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) 637 ->execute(); 638 639 $this->addChange($addressBookId, $cardUri, 3); 640 641 if ($this->dispatcher !== null) { 642 $this->dispatcher->dispatch( 643 '\OCA\DAV\CardDAV\CardDavBackend::deleteCard', 644 new GenericEvent(null, [ 645 'addressBookId' => $addressBookId, 646 'cardUri' => $cardUri]) 647 ); 648 } 649 650 if ($ret === 1) { 651 if ($cardId !== null) { 652 $this->purgeProperties($addressBookId, $cardId); 653 } 654 return true; 655 } 656 657 return false; 658 } 659 660 /** 661 * The getChanges method returns all the changes that have happened, since 662 * the specified syncToken in the specified address book. 663 * 664 * This function should return an array, such as the following: 665 * 666 * [ 667 * 'syncToken' => 'The current synctoken', 668 * 'added' => [ 669 * 'new.txt', 670 * ], 671 * 'modified' => [ 672 * 'modified.txt', 673 * ], 674 * 'deleted' => [ 675 * 'foo.php.bak', 676 * 'old.txt' 677 * ] 678 * ]; 679 * 680 * The returned syncToken property should reflect the *current* syncToken 681 * of the calendar, as reported in the {http://sabredav.org/ns}sync-token 682 * property. This is needed here too, to ensure the operation is atomic. 683 * 684 * If the $syncToken argument is specified as null, this is an initial 685 * sync, and all members should be reported. 686 * 687 * The modified property is an array of nodenames that have changed since 688 * the last token. 689 * 690 * The deleted property is an array with nodenames, that have been deleted 691 * from collection. 692 * 693 * The $syncLevel argument is basically the 'depth' of the report. If it's 694 * 1, you only have to report changes that happened only directly in 695 * immediate descendants. If it's 2, it should also include changes from 696 * the nodes below the child collections. (grandchildren) 697 * 698 * The $limit argument allows a client to specify how many results should 699 * be returned at most. If the limit is not specified, it should be treated 700 * as infinite. 701 * 702 * If the limit (infinite or not) is higher than you're willing to return, 703 * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception. 704 * 705 * If the syncToken is expired (due to data cleanup) or unknown, you must 706 * return null. 707 * 708 * The limit is 'suggestive'. You are free to ignore it. 709 * 710 * @param string $addressBookId 711 * @param string $syncToken 712 * @param int $syncLevel 713 * @param int $limit 714 * @return array 715 */ 716 public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) { 717 // Current synctoken 718 $stmt = $this->db->prepare('SELECT `synctoken` FROM `*PREFIX*addressbooks` WHERE `id` = ?'); 719 $stmt->execute([ $addressBookId ]); 720 $currentToken = $stmt->fetchColumn(); 721 722 if ($currentToken === null) { 723 return null; 724 } 725 726 $result = [ 727 'syncToken' => $currentToken, 728 'added' => [], 729 'modified' => [], 730 'deleted' => [], 731 ]; 732 733 if ($syncToken) { 734 $query = 'SELECT `uri`, `operation` FROM `*PREFIX*addressbookchanges` WHERE `synctoken` >= ? AND `synctoken` < ? AND `addressbookid` = ? ORDER BY `synctoken`'; 735 736 // Fetching all changes 737 $stmt = $this->db->prepare($query, $limit ?: null, $limit ? 0 : null); 738 $stmt->execute([$syncToken, $currentToken, $addressBookId]); 739 740 $changes = []; 741 742 // This loop ensures that any duplicates are overwritten, only the 743 // last change on a node is relevant. 744 while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 745 $changes[$row['uri']] = $row['operation']; 746 } 747 748 foreach ($changes as $uri => $operation) { 749 switch ($operation) { 750 case 1: 751 $result['added'][] = $uri; 752 break; 753 case 2: 754 $result['modified'][] = $uri; 755 break; 756 case 3: 757 $result['deleted'][] = $uri; 758 break; 759 } 760 } 761 } else { 762 // No synctoken supplied, this is the initial sync. 763 $query = 'SELECT `uri` FROM `*PREFIX*cards` WHERE `addressbookid` = ?'; 764 $stmt = $this->db->prepare($query); 765 $stmt->execute([$addressBookId]); 766 767 $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); 768 } 769 return $result; 770 } 771 772 /** 773 * Adds a change record to the addressbookchanges table. 774 * 775 * @param mixed $addressBookId 776 * @param string $objectUri 777 * @param int $operation 1 = add, 2 = modify, 3 = delete 778 * @return void 779 */ 780 protected function addChange($addressBookId, $objectUri, $operation) { 781 $sql = 'INSERT INTO `*PREFIX*addressbookchanges`(`uri`, `synctoken`, `addressbookid`, `operation`) SELECT ?, `synctoken`, ?, ? FROM `*PREFIX*addressbooks` WHERE `id` = ?'; 782 $stmt = $this->db->prepare($sql); 783 $stmt->execute([ 784 $objectUri, 785 $addressBookId, 786 $operation, 787 $addressBookId 788 ]); 789 $stmt = $this->db->prepare('UPDATE `*PREFIX*addressbooks` SET `synctoken` = `synctoken` + 1 WHERE `id` = ?'); 790 $stmt->execute([ 791 $addressBookId 792 ]); 793 } 794 795 private function readBlob($cardData) { 796 if (\is_resource($cardData)) { 797 return \stream_get_contents($cardData); 798 } 799 800 return $cardData; 801 } 802 803 /** 804 * @param IShareable $shareable 805 * @param string[] $add 806 * @param string[] $remove 807 */ 808 public function updateShares(IShareable $shareable, $add, $remove) { 809 $this->sharingBackend->updateShares($shareable, $add, $remove); 810 } 811 812 /** 813 * search contact 814 * 815 * @param int $addressBookId 816 * @param string $pattern which should match within the $searchProperties 817 * @param array $searchProperties defines the properties within the query pattern should match 818 * @param int $limit 819 * @param int $offset 820 * @return array an array of contacts which are arrays of key-value-pairs 821 */ 822 public function search($addressBookId, $pattern, $searchProperties, $limit = 100, $offset = 0) { 823 return $this->searchEx($addressBookId, $pattern, $searchProperties, [], $limit, $offset); 824 } 825 826 /** 827 * search contact with options 828 * 829 * @param int $addressBookId 830 * @param string $pattern which should match within the $searchProperties 831 * @param array $searchProperties defines the properties within the query pattern should match 832 * @param array $options 833 * available options: 834 * 'matchMode' 835 * - 'ANY' (default) - pattern can be anywhere in property value 836 * - 'START' - property value should start with pattern 837 * - 'END' - property value should end with pattern 838 * - 'EXACT' - property value should match the pattern exactly 839 * @param int $limit 840 * @param int $offset 841 * @return array an array of contacts which are arrays of key-value-pairs 842 */ 843 public function searchEx($addressBookId, $pattern, $searchProperties, $options, $limit = 100, $offset = 0) { 844 $query = $this->db->getQueryBuilder(); 845 $query2 = $this->db->getQueryBuilder(); 846 $query2->selectDistinct('cp.cardid')->from($this->dbCardsPropertiesTable, 'cp'); 847 848 $matchMode = $options['matchMode'] ?? 'any'; 849 switch ($matchMode) { 850 case 'START': 851 $searchPattern = $this->db->escapeLikeParameter($pattern) . '%'; 852 break; 853 case 'END': 854 $searchPattern = '%' . $this->db->escapeLikeParameter($pattern); 855 break; 856 case 'EXACT': 857 $searchPattern = $this->db->escapeLikeParameter($pattern); 858 break; 859 case 'ANY': 860 default: 861 $searchPattern = '%' . $this->db->escapeLikeParameter($pattern) . '%'; 862 } 863 864 foreach ($searchProperties as $property) { 865 $query2->orWhere( 866 $query2->expr()->andX( 867 $query2->expr()->eq('cp.name', $query->createNamedParameter($property)), 868 $query2->expr()->iLike('cp.value', $query->createNamedParameter($searchPattern)) 869 ) 870 ); 871 } 872 $query2->andWhere($query2->expr()->eq('cp.addressbookid', $query->createNamedParameter($addressBookId))); 873 874 $query->select('c.carddata', 'c.uri')->from($this->dbCardsTable, 'c') 875 ->where($query->expr()->in('c.id', $query->createFunction($query2->getSQL()))); 876 877 $query->setFirstResult($offset)->setMaxResults($limit); 878 $query->orderBy('c.uri'); 879 880 $result = $query->execute(); 881 $cards = $result->fetchAll(); 882 883 $result->closeCursor(); 884 885 return \array_map(function ($array) { 886 $array['carddata'] = $this->readBlob($array['carddata']); 887 return $array; 888 }, $cards); 889 } 890 891 /** 892 * @param int $bookId 893 * @param string $name 894 * @return array 895 */ 896 public function collectCardProperties($bookId, $name) { 897 $query = $this->db->getQueryBuilder(); 898 $result = $query->selectDistinct('value') 899 ->from($this->dbCardsPropertiesTable) 900 ->where($query->expr()->eq('name', $query->createNamedParameter($name))) 901 ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId))) 902 ->execute(); 903 904 $all = $result->fetchAll(PDO::FETCH_COLUMN); 905 $result->closeCursor(); 906 907 return $all; 908 } 909 910 /** 911 * get URI from a given contact 912 * 913 * @param int $id 914 * @return string 915 * @throws \InvalidArgumentException 916 */ 917 public function getCardUri($id) { 918 $query = $this->db->getQueryBuilder(); 919 $query->select('uri')->from($this->dbCardsTable) 920 ->where($query->expr()->eq('id', $query->createParameter('id'))) 921 ->setParameter('id', $id); 922 923 $result = $query->execute(); 924 $uri = $result->fetch(); 925 $result->closeCursor(); 926 927 if (!isset($uri['uri'])) { 928 throw new \InvalidArgumentException('Card does not exists: ' . $id); 929 } 930 931 return $uri['uri']; 932 } 933 934 /** 935 * return contact with the given URI 936 * 937 * @param int $addressBookId 938 * @param string $uri 939 * @return array 940 */ 941 public function getContact($addressBookId, $uri) { 942 $result = []; 943 $query = $this->db->getQueryBuilder(); 944 $query->select('*')->from($this->dbCardsTable) 945 ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) 946 ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); 947 $queryResult = $query->execute(); 948 $contact = $queryResult->fetch(); 949 $queryResult->closeCursor(); 950 951 if (\is_array($contact)) { 952 $result = $contact; 953 } 954 955 return $result; 956 } 957 958 /** 959 * Returns the list of people whom this address book is shared with. 960 * 961 * Every element in this array should have the following properties: 962 * * href - Often a mailto: address 963 * * commonName - Optional, for example a first + last name 964 * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. 965 * * readOnly - boolean 966 * * summary - Optional, a description for the share 967 * 968 * @return array 969 */ 970 public function getShares($addressBookId) { 971 return $this->sharingBackend->getShares($addressBookId); 972 } 973 974 /** 975 * update properties table 976 * 977 * @param int $addressBookId 978 * @param string $cardUri 979 * @param string $vCardSerialized 980 */ 981 protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) { 982 $cardId = $this->getCardId($addressBookId, $cardUri); 983 $vCard = $this->readCard($vCardSerialized); 984 985 $this->purgeProperties($addressBookId, $cardId); 986 987 $query = $this->db->getQueryBuilder(); 988 $query->insert($this->dbCardsPropertiesTable) 989 ->values( 990 [ 991 'addressbookid' => $query->createNamedParameter($addressBookId), 992 'cardid' => $query->createNamedParameter($cardId), 993 'name' => $query->createParameter('name'), 994 'value' => $query->createParameter('value'), 995 'preferred' => $query->createParameter('preferred') 996 ] 997 ); 998 999 foreach ($vCard->children() as $property) { 1000 if (!\in_array($property->name, self::$indexProperties)) { 1001 continue; 1002 } 1003 $preferred = 0; 1004 foreach ($property->parameters as $parameter) { 1005 if ($parameter->name == 'TYPE' && \strtoupper($parameter->getValue()) == 'PREF') { 1006 $preferred = 1; 1007 break; 1008 } 1009 } 1010 $query->setParameter('name', $property->name); 1011 $query->setParameter('value', \substr($property->getValue(), 0, 254)); 1012 $query->setParameter('preferred', $preferred); 1013 $query->execute(); 1014 } 1015 } 1016 1017 /** 1018 * read vCard data into a vCard object 1019 * 1020 * @param string $cardData 1021 * @return VCard 1022 */ 1023 protected function readCard($cardData) { 1024 return Reader::read($cardData); 1025 } 1026 1027 /** 1028 * delete all properties from a given card 1029 * 1030 * @param int $addressBookId 1031 * @param int $cardId 1032 */ 1033 protected function purgeProperties($addressBookId, $cardId) { 1034 $query = $this->db->getQueryBuilder(); 1035 $query->delete($this->dbCardsPropertiesTable) 1036 ->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId))) 1037 ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); 1038 $query->execute(); 1039 } 1040 1041 /** 1042 * get ID from a given contact 1043 * 1044 * @param int $addressBookId 1045 * @param string $uri 1046 * @return int 1047 * @throws \InvalidArgumentException 1048 */ 1049 protected function getCardId($addressBookId, $uri) { 1050 // Try to find cardId from own cache to avoid issue with db cluster 1051 if ($this->idCache->hasKey($addressBookId.$uri)) { 1052 return $this->idCache->get($addressBookId.$uri); 1053 } 1054 1055 $query = $this->db->getQueryBuilder(); 1056 $query->select('id')->from($this->dbCardsTable) 1057 ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) 1058 ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); 1059 1060 $result = $query->execute(); 1061 $cardIds = $result->fetch(); 1062 $result->closeCursor(); 1063 1064 if (!isset($cardIds['id'])) { 1065 throw new \InvalidArgumentException('Card does not exists: ' . $uri); 1066 } 1067 1068 return (int)$cardIds['id']; 1069 } 1070 1071 /** 1072 * For shared address books the sharee is set in the ACL of the address book 1073 * @param $addressBookId 1074 * @param $acl 1075 * @return array 1076 */ 1077 public function applyShareAcl($addressBookId, $acl) { 1078 return $this->sharingBackend->applyShareAcl($addressBookId, $acl); 1079 } 1080 1081 private function convertPrincipal($principalUri, $toV2 = null) { 1082 if ($this->principalBackend->getPrincipalPrefix() === 'principals') { 1083 list(, $name) = \Sabre\Uri\split($principalUri); 1084 $toV2 = $toV2 === null ? !$this->legacyMode : $toV2; 1085 if ($toV2) { 1086 return "principals/users/$name"; 1087 } 1088 return "principals/$name"; 1089 } 1090 return $principalUri; 1091 } 1092} 1093