1<?php 2 3namespace Sabre\CardDAV\Backend; 4 5use Sabre\CardDAV; 6use Sabre\DAV; 7 8/** 9 * PDO CardDAV backend 10 * 11 * This CardDAV backend uses PDO to store addressbooks 12 * 13 * @copyright Copyright (C) fruux GmbH (https://fruux.com/) 14 * @author Evert Pot (http://evertpot.com/) 15 * @license http://sabre.io/license/ Modified BSD License 16 */ 17class PDO extends AbstractBackend implements SyncSupport { 18 19 /** 20 * PDO connection 21 * 22 * @var PDO 23 */ 24 protected $pdo; 25 26 /** 27 * The PDO table name used to store addressbooks 28 */ 29 public $addressBooksTableName = 'addressbooks'; 30 31 /** 32 * The PDO table name used to store cards 33 */ 34 public $cardsTableName = 'cards'; 35 36 /** 37 * The table name that will be used for tracking changes in address books. 38 * 39 * @var string 40 */ 41 public $addressBookChangesTableName = 'addressbookchanges'; 42 43 /** 44 * Sets up the object 45 * 46 * @param \PDO $pdo 47 */ 48 function __construct(\PDO $pdo) { 49 50 $this->pdo = $pdo; 51 52 } 53 54 /** 55 * Returns the list of addressbooks for a specific user. 56 * 57 * @param string $principalUri 58 * @return array 59 */ 60 function getAddressBooksForUser($principalUri) { 61 62 $stmt = $this->pdo->prepare('SELECT id, uri, displayname, principaluri, description, synctoken FROM ' . $this->addressBooksTableName . ' WHERE principaluri = ?'); 63 $stmt->execute([$principalUri]); 64 65 $addressBooks = []; 66 67 foreach ($stmt->fetchAll() as $row) { 68 69 $addressBooks[] = [ 70 'id' => $row['id'], 71 'uri' => $row['uri'], 72 'principaluri' => $row['principaluri'], 73 '{DAV:}displayname' => $row['displayname'], 74 '{' . CardDAV\Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'], 75 '{http://calendarserver.org/ns/}getctag' => $row['synctoken'], 76 '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ? $row['synctoken'] : '0', 77 ]; 78 79 } 80 81 return $addressBooks; 82 83 } 84 85 86 /** 87 * Updates properties for an address book. 88 * 89 * The list of mutations is stored in a Sabre\DAV\PropPatch object. 90 * To do the actual updates, you must tell this object which properties 91 * you're going to process with the handle() method. 92 * 93 * Calling the handle method is like telling the PropPatch object "I 94 * promise I can handle updating this property". 95 * 96 * Read the PropPatch documentation for more info and examples. 97 * 98 * @param string $addressBookId 99 * @param \Sabre\DAV\PropPatch $propPatch 100 * @return void 101 */ 102 function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) { 103 104 $supportedProperties = [ 105 '{DAV:}displayname', 106 '{' . CardDAV\Plugin::NS_CARDDAV . '}addressbook-description', 107 ]; 108 109 $propPatch->handle($supportedProperties, function($mutations) use ($addressBookId) { 110 111 $updates = []; 112 foreach ($mutations as $property => $newValue) { 113 114 switch ($property) { 115 case '{DAV:}displayname' : 116 $updates['displayname'] = $newValue; 117 break; 118 case '{' . CardDAV\Plugin::NS_CARDDAV . '}addressbook-description' : 119 $updates['description'] = $newValue; 120 break; 121 } 122 } 123 $query = 'UPDATE ' . $this->addressBooksTableName . ' SET '; 124 $first = true; 125 foreach ($updates as $key => $value) { 126 if ($first) { 127 $first = false; 128 } else { 129 $query .= ', '; 130 } 131 $query .= ' ' . $key . ' = :' . $key . ' '; 132 } 133 $query .= ' WHERE id = :addressbookid'; 134 135 $stmt = $this->pdo->prepare($query); 136 $updates['addressbookid'] = $addressBookId; 137 138 $stmt->execute($updates); 139 140 $this->addChange($addressBookId, "", 2); 141 142 return true; 143 144 }); 145 146 } 147 148 /** 149 * Creates a new address book 150 * 151 * @param string $principalUri 152 * @param string $url Just the 'basename' of the url. 153 * @param array $properties 154 * @return int Last insert id 155 */ 156 function createAddressBook($principalUri, $url, array $properties) { 157 158 $values = [ 159 'displayname' => null, 160 'description' => null, 161 'principaluri' => $principalUri, 162 'uri' => $url, 163 ]; 164 165 foreach ($properties as $property => $newValue) { 166 167 switch ($property) { 168 case '{DAV:}displayname' : 169 $values['displayname'] = $newValue; 170 break; 171 case '{' . CardDAV\Plugin::NS_CARDDAV . '}addressbook-description' : 172 $values['description'] = $newValue; 173 break; 174 default : 175 throw new DAV\Exception\BadRequest('Unknown property: ' . $property); 176 } 177 178 } 179 180 $query = 'INSERT INTO ' . $this->addressBooksTableName . ' (uri, displayname, description, principaluri, synctoken) VALUES (:uri, :displayname, :description, :principaluri, 1)'; 181 $stmt = $this->pdo->prepare($query); 182 $stmt->execute($values); 183 return $this->pdo->lastInsertId( 184 $this->addressBooksTableName . '_id_seq' 185 ); 186 187 } 188 189 /** 190 * Deletes an entire addressbook and all its contents 191 * 192 * @param int $addressBookId 193 * @return void 194 */ 195 function deleteAddressBook($addressBookId) { 196 197 $stmt = $this->pdo->prepare('DELETE FROM ' . $this->cardsTableName . ' WHERE addressbookid = ?'); 198 $stmt->execute([$addressBookId]); 199 200 $stmt = $this->pdo->prepare('DELETE FROM ' . $this->addressBooksTableName . ' WHERE id = ?'); 201 $stmt->execute([$addressBookId]); 202 203 $stmt = $this->pdo->prepare('DELETE FROM ' . $this->addressBookChangesTableName . ' WHERE addressbookid = ?'); 204 $stmt->execute([$addressBookId]); 205 206 } 207 208 /** 209 * Returns all cards for a specific addressbook id. 210 * 211 * This method should return the following properties for each card: 212 * * carddata - raw vcard data 213 * * uri - Some unique url 214 * * lastmodified - A unix timestamp 215 * 216 * It's recommended to also return the following properties: 217 * * etag - A unique etag. This must change every time the card changes. 218 * * size - The size of the card in bytes. 219 * 220 * If these last two properties are provided, less time will be spent 221 * calculating them. If they are specified, you can also ommit carddata. 222 * This may speed up certain requests, especially with large cards. 223 * 224 * @param mixed $addressbookId 225 * @return array 226 */ 227 function getCards($addressbookId) { 228 229 $stmt = $this->pdo->prepare('SELECT id, uri, lastmodified, etag, size FROM ' . $this->cardsTableName . ' WHERE addressbookid = ?'); 230 $stmt->execute([$addressbookId]); 231 232 $result = []; 233 while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 234 $row['etag'] = '"' . $row['etag'] . '"'; 235 $row['lastmodified'] = (int)$row['lastmodified']; 236 $result[] = $row; 237 } 238 return $result; 239 240 } 241 242 /** 243 * Returns a specific card. 244 * 245 * The same set of properties must be returned as with getCards. The only 246 * exception is that 'carddata' is absolutely required. 247 * 248 * If the card does not exist, you must return false. 249 * 250 * @param mixed $addressBookId 251 * @param string $cardUri 252 * @return array 253 */ 254 function getCard($addressBookId, $cardUri) { 255 256 $stmt = $this->pdo->prepare('SELECT id, carddata, uri, lastmodified, etag, size FROM ' . $this->cardsTableName . ' WHERE addressbookid = ? AND uri = ? LIMIT 1'); 257 $stmt->execute([$addressBookId, $cardUri]); 258 259 $result = $stmt->fetch(\PDO::FETCH_ASSOC); 260 261 if (!$result) return false; 262 263 $result['etag'] = '"' . $result['etag'] . '"'; 264 $result['lastmodified'] = (int)$result['lastmodified']; 265 return $result; 266 267 } 268 269 /** 270 * Returns a list of cards. 271 * 272 * This method should work identical to getCard, but instead return all the 273 * cards in the list as an array. 274 * 275 * If the backend supports this, it may allow for some speed-ups. 276 * 277 * @param mixed $addressBookId 278 * @param array $uris 279 * @return array 280 */ 281 function getMultipleCards($addressBookId, array $uris) { 282 283 $query = 'SELECT id, uri, lastmodified, etag, size, carddata FROM ' . $this->cardsTableName . ' WHERE addressbookid = ? AND uri IN ('; 284 // Inserting a whole bunch of question marks 285 $query .= implode(',', array_fill(0, count($uris), '?')); 286 $query .= ')'; 287 288 $stmt = $this->pdo->prepare($query); 289 $stmt->execute(array_merge([$addressBookId], $uris)); 290 $result = []; 291 while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 292 $row['etag'] = '"' . $row['etag'] . '"'; 293 $row['lastmodified'] = (int)$row['lastmodified']; 294 $result[] = $row; 295 } 296 return $result; 297 298 } 299 300 /** 301 * Creates a new card. 302 * 303 * The addressbook id will be passed as the first argument. This is the 304 * same id as it is returned from the getAddressBooksForUser method. 305 * 306 * The cardUri is a base uri, and doesn't include the full path. The 307 * cardData argument is the vcard body, and is passed as a string. 308 * 309 * It is possible to return an ETag from this method. This ETag is for the 310 * newly created resource, and must be enclosed with double quotes (that 311 * is, the string itself must contain the double quotes). 312 * 313 * You should only return the ETag if you store the carddata as-is. If a 314 * subsequent GET request on the same card does not have the same body, 315 * byte-by-byte and you did return an ETag here, clients tend to get 316 * confused. 317 * 318 * If you don't return an ETag, you can just return null. 319 * 320 * @param mixed $addressBookId 321 * @param string $cardUri 322 * @param string $cardData 323 * @return string|null 324 */ 325 function createCard($addressBookId, $cardUri, $cardData) { 326 327 $stmt = $this->pdo->prepare('INSERT INTO ' . $this->cardsTableName . ' (carddata, uri, lastmodified, addressbookid, size, etag) VALUES (?, ?, ?, ?, ?, ?)'); 328 329 $etag = md5($cardData); 330 331 $stmt->execute([ 332 $cardData, 333 $cardUri, 334 time(), 335 $addressBookId, 336 strlen($cardData), 337 $etag, 338 ]); 339 340 $this->addChange($addressBookId, $cardUri, 1); 341 342 return '"' . $etag . '"'; 343 344 } 345 346 /** 347 * Updates a card. 348 * 349 * The addressbook id will be passed as the first argument. This is the 350 * same id as it is returned from the getAddressBooksForUser method. 351 * 352 * The cardUri is a base uri, and doesn't include the full path. The 353 * cardData argument is the vcard body, and is passed as a string. 354 * 355 * It is possible to return an ETag from this method. This ETag should 356 * match that of the updated resource, and must be enclosed with double 357 * quotes (that is: the string itself must contain the actual quotes). 358 * 359 * You should only return the ETag if you store the carddata as-is. If a 360 * subsequent GET request on the same card does not have the same body, 361 * byte-by-byte and you did return an ETag here, clients tend to get 362 * confused. 363 * 364 * If you don't return an ETag, you can just return null. 365 * 366 * @param mixed $addressBookId 367 * @param string $cardUri 368 * @param string $cardData 369 * @return string|null 370 */ 371 function updateCard($addressBookId, $cardUri, $cardData) { 372 373 $stmt = $this->pdo->prepare('UPDATE ' . $this->cardsTableName . ' SET carddata = ?, lastmodified = ?, size = ?, etag = ? WHERE uri = ? AND addressbookid =?'); 374 375 $etag = md5($cardData); 376 $stmt->execute([ 377 $cardData, 378 time(), 379 strlen($cardData), 380 $etag, 381 $cardUri, 382 $addressBookId 383 ]); 384 385 $this->addChange($addressBookId, $cardUri, 2); 386 387 return '"' . $etag . '"'; 388 389 } 390 391 /** 392 * Deletes a card 393 * 394 * @param mixed $addressBookId 395 * @param string $cardUri 396 * @return bool 397 */ 398 function deleteCard($addressBookId, $cardUri) { 399 400 $stmt = $this->pdo->prepare('DELETE FROM ' . $this->cardsTableName . ' WHERE addressbookid = ? AND uri = ?'); 401 $stmt->execute([$addressBookId, $cardUri]); 402 403 $this->addChange($addressBookId, $cardUri, 3); 404 405 return $stmt->rowCount() === 1; 406 407 } 408 409 /** 410 * The getChanges method returns all the changes that have happened, since 411 * the specified syncToken in the specified address book. 412 * 413 * This function should return an array, such as the following: 414 * 415 * [ 416 * 'syncToken' => 'The current synctoken', 417 * 'added' => [ 418 * 'new.txt', 419 * ], 420 * 'modified' => [ 421 * 'updated.txt', 422 * ], 423 * 'deleted' => [ 424 * 'foo.php.bak', 425 * 'old.txt' 426 * ] 427 * ]; 428 * 429 * The returned syncToken property should reflect the *current* syncToken 430 * of the addressbook, as reported in the {http://sabredav.org/ns}sync-token 431 * property. This is needed here too, to ensure the operation is atomic. 432 * 433 * If the $syncToken argument is specified as null, this is an initial 434 * sync, and all members should be reported. 435 * 436 * The modified property is an array of nodenames that have changed since 437 * the last token. 438 * 439 * The deleted property is an array with nodenames, that have been deleted 440 * from collection. 441 * 442 * The $syncLevel argument is basically the 'depth' of the report. If it's 443 * 1, you only have to report changes that happened only directly in 444 * immediate descendants. If it's 2, it should also include changes from 445 * the nodes below the child collections. (grandchildren) 446 * 447 * The $limit argument allows a client to specify how many results should 448 * be returned at most. If the limit is not specified, it should be treated 449 * as infinite. 450 * 451 * If the limit (infinite or not) is higher than you're willing to return, 452 * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception. 453 * 454 * If the syncToken is expired (due to data cleanup) or unknown, you must 455 * return null. 456 * 457 * The limit is 'suggestive'. You are free to ignore it. 458 * 459 * @param string $addressBookId 460 * @param string $syncToken 461 * @param int $syncLevel 462 * @param int $limit 463 * @return array 464 */ 465 function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) { 466 467 // Current synctoken 468 $stmt = $this->pdo->prepare('SELECT synctoken FROM ' . $this->addressBooksTableName . ' WHERE id = ?'); 469 $stmt->execute([$addressBookId]); 470 $currentToken = $stmt->fetchColumn(0); 471 472 if (is_null($currentToken)) return null; 473 474 $result = [ 475 'syncToken' => $currentToken, 476 'added' => [], 477 'modified' => [], 478 'deleted' => [], 479 ]; 480 481 if ($syncToken) { 482 483 $query = "SELECT uri, operation FROM " . $this->addressBookChangesTableName . " WHERE synctoken >= ? AND synctoken < ? AND addressbookid = ? ORDER BY synctoken"; 484 if ($limit > 0) $query .= " LIMIT " . (int)$limit; 485 486 // Fetching all changes 487 $stmt = $this->pdo->prepare($query); 488 $stmt->execute([$syncToken, $currentToken, $addressBookId]); 489 490 $changes = []; 491 492 // This loop ensures that any duplicates are overwritten, only the 493 // last change on a node is relevant. 494 while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 495 496 $changes[$row['uri']] = $row['operation']; 497 498 } 499 500 foreach ($changes as $uri => $operation) { 501 502 switch ($operation) { 503 case 1: 504 $result['added'][] = $uri; 505 break; 506 case 2: 507 $result['modified'][] = $uri; 508 break; 509 case 3: 510 $result['deleted'][] = $uri; 511 break; 512 } 513 514 } 515 } else { 516 // No synctoken supplied, this is the initial sync. 517 $query = "SELECT uri FROM " . $this->cardsTableName . " WHERE addressbookid = ?"; 518 $stmt = $this->pdo->prepare($query); 519 $stmt->execute([$addressBookId]); 520 521 $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); 522 } 523 return $result; 524 525 } 526 527 /** 528 * Adds a change record to the addressbookchanges table. 529 * 530 * @param mixed $addressBookId 531 * @param string $objectUri 532 * @param int $operation 1 = add, 2 = modify, 3 = delete 533 * @return void 534 */ 535 protected function addChange($addressBookId, $objectUri, $operation) { 536 537 $stmt = $this->pdo->prepare('INSERT INTO ' . $this->addressBookChangesTableName . ' (uri, synctoken, addressbookid, operation) SELECT ?, synctoken, ?, ? FROM ' . $this->addressBooksTableName . ' WHERE id = ?'); 538 $stmt->execute([ 539 $objectUri, 540 $addressBookId, 541 $operation, 542 $addressBookId 543 ]); 544 $stmt = $this->pdo->prepare('UPDATE ' . $this->addressBooksTableName . ' SET synctoken = synctoken + 1 WHERE id = ?'); 545 $stmt->execute([ 546 $addressBookId 547 ]); 548 549 } 550} 551