PDO.php 18 KB

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