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) 2007-2015 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 documenation 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 void 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 185 } 186 187 /** 188 * Deletes an entire addressbook and all its contents 189 * 190 * @param int $addressBookId 191 * @return void 192 */ 193 function deleteAddressBook($addressBookId) { 194 195 $stmt = $this->pdo->prepare('DELETE FROM ' . $this->cardsTableName . ' WHERE addressbookid = ?'); 196 $stmt->execute([$addressBookId]); 197 198 $stmt = $this->pdo->prepare('DELETE FROM ' . $this->addressBooksTableName . ' WHERE id = ?'); 199 $stmt->execute([$addressBookId]); 200 201 $stmt = $this->pdo->prepare('DELETE FROM ' . $this->addressBookChangesTableName . ' WHERE id = ?'); 202 $stmt->execute([$addressBookId]); 203 204 } 205 206 /** 207 * Returns all cards for a specific addressbook id. 208 * 209 * This method should return the following properties for each card: 210 * * carddata - raw vcard data 211 * * uri - Some unique url 212 * * lastmodified - A unix timestamp 213 * 214 * It's recommended to also return the following properties: 215 * * etag - A unique etag. This must change every time the card changes. 216 * * size - The size of the card in bytes. 217 * 218 * If these last two properties are provided, less time will be spent 219 * calculating them. If they are specified, you can also ommit carddata. 220 * This may speed up certain requests, especially with large cards. 221 * 222 * @param mixed $addressbookId 223 * @return array 224 */ 225 function getCards($addressbookId) { 226 227 $stmt = $this->pdo->prepare('SELECT id, uri, lastmodified, etag, size FROM ' . $this->cardsTableName . ' WHERE addressbookid = ?'); 228 $stmt->execute([$addressbookId]); 229 230 $result = []; 231 while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 232 $row['etag'] = '"' . $row['etag'] . '"'; 233 $result[] = $row; 234 } 235 return $result; 236 237 } 238 239 /** 240 * Returns a specfic card. 241 * 242 * The same set of properties must be returned as with getCards. The only 243 * exception is that 'carddata' is absolutely required. 244 * 245 * If the card does not exist, you must return false. 246 * 247 * @param mixed $addressBookId 248 * @param string $cardUri 249 * @return array 250 */ 251 function getCard($addressBookId, $cardUri) { 252 253 $stmt = $this->pdo->prepare('SELECT id, carddata, uri, lastmodified, etag, size FROM ' . $this->cardsTableName . ' WHERE addressbookid = ? AND uri = ? LIMIT 1'); 254 $stmt->execute([$addressBookId, $cardUri]); 255 256 $result = $stmt->fetch(\PDO::FETCH_ASSOC); 257 258 if (!$result) return false; 259 260 $result['etag'] = '"' . $result['etag'] . '"'; 261 return $result; 262 263 } 264 265 /** 266 * Returns a list of cards. 267 * 268 * This method should work identical to getCard, but instead return all the 269 * cards in the list as an array. 270 * 271 * If the backend supports this, it may allow for some speed-ups. 272 * 273 * @param mixed $addressBookId 274 * @param array $uris 275 * @return array 276 */ 277 function getMultipleCards($addressBookId, array $uris) { 278 279 $query = 'SELECT id, uri, lastmodified, etag, size, carddata FROM ' . $this->cardsTableName . ' WHERE addressbookid = ? AND uri IN ('; 280 // Inserting a whole bunch of question marks 281 $query .= implode(',', array_fill(0, count($uris), '?')); 282 $query .= ')'; 283 284 $stmt = $this->pdo->prepare($query); 285 $stmt->execute(array_merge([$addressBookId], $uris)); 286 $result = []; 287 while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 288 $row['etag'] = '"' . $row['etag'] . '"'; 289 $result[] = $row; 290 } 291 return $result; 292 293 } 294 295 /** 296 * Creates a new card. 297 * 298 * The addressbook id will be passed as the first argument. This is the 299 * same id as it is returned from the getAddressBooksForUser method. 300 * 301 * The cardUri is a base uri, and doesn't include the full path. The 302 * cardData argument is the vcard body, and is passed as a string. 303 * 304 * It is possible to return an ETag from this method. This ETag is for the 305 * newly created resource, and must be enclosed with double quotes (that 306 * is, the string itself must contain the double quotes). 307 * 308 * You should only return the ETag if you store the carddata as-is. If a 309 * subsequent GET request on the same card does not have the same body, 310 * byte-by-byte and you did return an ETag here, clients tend to get 311 * confused. 312 * 313 * If you don't return an ETag, you can just return null. 314 * 315 * @param mixed $addressBookId 316 * @param string $cardUri 317 * @param string $cardData 318 * @return string|null 319 */ 320 function createCard($addressBookId, $cardUri, $cardData) { 321 322 $stmt = $this->pdo->prepare('INSERT INTO ' . $this->cardsTableName . ' (carddata, uri, lastmodified, addressbookid, size, etag) VALUES (?, ?, ?, ?, ?, ?)'); 323 324 $etag = md5($cardData); 325 326 $stmt->execute([ 327 $cardData, 328 $cardUri, 329 time(), 330 $addressBookId, 331 strlen($cardData), 332 $etag, 333 ]); 334 335 $this->addChange($addressBookId, $cardUri, 1); 336 337 return '"' . $etag . '"'; 338 339 } 340 341 /** 342 * Updates a card. 343 * 344 * The addressbook id will be passed as the first argument. This is the 345 * same id as it is returned from the getAddressBooksForUser method. 346 * 347 * The cardUri is a base uri, and doesn't include the full path. The 348 * cardData argument is the vcard body, and is passed as a string. 349 * 350 * It is possible to return an ETag from this method. This ETag should 351 * match that of the updated resource, and must be enclosed with double 352 * quotes (that is: the string itself must contain the actual quotes). 353 * 354 * You should only return the ETag if you store the carddata as-is. If a 355 * subsequent GET request on the same card does not have the same body, 356 * byte-by-byte and you did return an ETag here, clients tend to get 357 * confused. 358 * 359 * If you don't return an ETag, you can just return null. 360 * 361 * @param mixed $addressBookId 362 * @param string $cardUri 363 * @param string $cardData 364 * @return string|null 365 */ 366 function updateCard($addressBookId, $cardUri, $cardData) { 367 368 $stmt = $this->pdo->prepare('UPDATE ' . $this->cardsTableName . ' SET carddata = ?, lastmodified = ?, size = ?, etag = ? WHERE uri = ? AND addressbookid =?'); 369 370 $etag = md5($cardData); 371 $stmt->execute([ 372 $cardData, 373 time(), 374 strlen($cardData), 375 $etag, 376 $cardUri, 377 $addressBookId 378 ]); 379 380 $this->addChange($addressBookId, $cardUri, 2); 381 382 return '"' . $etag . '"'; 383 384 } 385 386 /** 387 * Deletes a card 388 * 389 * @param mixed $addressBookId 390 * @param string $cardUri 391 * @return bool 392 */ 393 function deleteCard($addressBookId, $cardUri) { 394 395 $stmt = $this->pdo->prepare('DELETE FROM ' . $this->cardsTableName . ' WHERE addressbookid = ? AND uri = ?'); 396 $stmt->execute([$addressBookId, $cardUri]); 397 398 $this->addChange($addressBookId, $cardUri, 3); 399 400 return $stmt->rowCount() === 1; 401 402 } 403 404 /** 405 * The getChanges method returns all the changes that have happened, since 406 * the specified syncToken in the specified address book. 407 * 408 * This function should return an array, such as the following: 409 * 410 * [ 411 * 'syncToken' => 'The current synctoken', 412 * 'added' => [ 413 * 'new.txt', 414 * ], 415 * 'modified' => [ 416 * 'updated.txt', 417 * ], 418 * 'deleted' => [ 419 * 'foo.php.bak', 420 * 'old.txt' 421 * ] 422 * ]; 423 * 424 * The returned syncToken property should reflect the *current* syncToken 425 * of the addressbook, as reported in the {http://sabredav.org/ns}sync-token 426 * property. This is needed here too, to ensure the operation is atomic. 427 * 428 * If the $syncToken argument is specified as null, this is an initial 429 * sync, and all members should be reported. 430 * 431 * The modified property is an array of nodenames that have changed since 432 * the last token. 433 * 434 * The deleted property is an array with nodenames, that have been deleted 435 * from collection. 436 * 437 * The $syncLevel argument is basically the 'depth' of the report. If it's 438 * 1, you only have to report changes that happened only directly in 439 * immediate descendants. If it's 2, it should also include changes from 440 * the nodes below the child collections. (grandchildren) 441 * 442 * The $limit argument allows a client to specify how many results should 443 * be returned at most. If the limit is not specified, it should be treated 444 * as infinite. 445 * 446 * If the limit (infinite or not) is higher than you're willing to return, 447 * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception. 448 * 449 * If the syncToken is expired (due to data cleanup) or unknown, you must 450 * return null. 451 * 452 * The limit is 'suggestive'. You are free to ignore it. 453 * 454 * @param string $addressBookId 455 * @param string $syncToken 456 * @param int $syncLevel 457 * @param int $limit 458 * @return array 459 */ 460 function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) { 461 462 // Current synctoken 463 $stmt = $this->pdo->prepare('SELECT synctoken FROM ' . $this->addressBooksTableName . ' WHERE id = ?'); 464 $stmt->execute([ $addressBookId ]); 465 $currentToken = $stmt->fetchColumn(0); 466 467 if (is_null($currentToken)) return null; 468 469 $result = [ 470 'syncToken' => $currentToken, 471 'added' => [], 472 'modified' => [], 473 'deleted' => [], 474 ]; 475 476 if ($syncToken) { 477 478 $query = "SELECT uri, operation FROM " . $this->addressBookChangesTableName . " WHERE synctoken >= ? AND synctoken < ? AND addressbookid = ? ORDER BY synctoken"; 479 if ($limit > 0) $query .= " LIMIT " . (int)$limit; 480 481 // Fetching all changes 482 $stmt = $this->pdo->prepare($query); 483 $stmt->execute([$syncToken, $currentToken, $addressBookId]); 484 485 $changes = []; 486 487 // This loop ensures that any duplicates are overwritten, only the 488 // last change on a node is relevant. 489 while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 490 491 $changes[$row['uri']] = $row['operation']; 492 493 } 494 495 foreach ($changes as $uri => $operation) { 496 497 switch ($operation) { 498 case 1: 499 $result['added'][] = $uri; 500 break; 501 case 2: 502 $result['modified'][] = $uri; 503 break; 504 case 3: 505 $result['deleted'][] = $uri; 506 break; 507 } 508 509 } 510 } else { 511 // No synctoken supplied, this is the initial sync. 512 $query = "SELECT uri FROM " . $this->cardsTableName . " WHERE addressbookid = ?"; 513 $stmt = $this->pdo->prepare($query); 514 $stmt->execute([$addressBookId]); 515 516 $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); 517 } 518 return $result; 519 520 } 521 522 /** 523 * Adds a change record to the addressbookchanges table. 524 * 525 * @param mixed $addressBookId 526 * @param string $objectUri 527 * @param int $operation 1 = add, 2 = modify, 3 = delete 528 * @return void 529 */ 530 protected function addChange($addressBookId, $objectUri, $operation) { 531 532 $stmt = $this->pdo->prepare('INSERT INTO ' . $this->addressBookChangesTableName . ' (uri, synctoken, addressbookid, operation) SELECT ?, synctoken, ?, ? FROM ' . $this->addressBooksTableName . ' WHERE id = ?'); 533 $stmt->execute([ 534 $objectUri, 535 $addressBookId, 536 $operation, 537 $addressBookId 538 ]); 539 $stmt = $this->pdo->prepare('UPDATE ' . $this->addressBooksTableName . ' SET synctoken = synctoken + 1 WHERE id = ?'); 540 $stmt->execute([ 541 $addressBookId 542 ]); 543 544 } 545} 546