Plugin.php 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886
  1. <?php
  2. declare(strict_types=1);
  3. namespace Sabre\CardDAV;
  4. use Sabre\DAV;
  5. use Sabre\DAV\Exception\ReportNotSupported;
  6. use Sabre\DAV\Xml\Property\LocalHref;
  7. use Sabre\DAVACL;
  8. use Sabre\HTTP;
  9. use Sabre\HTTP\RequestInterface;
  10. use Sabre\HTTP\ResponseInterface;
  11. use Sabre\Uri;
  12. use Sabre\VObject;
  13. /**
  14. * CardDAV plugin.
  15. *
  16. * The CardDAV plugin adds CardDAV functionality to the WebDAV server
  17. *
  18. * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
  19. * @author Evert Pot (http://evertpot.com/)
  20. * @license http://sabre.io/license/ Modified BSD License
  21. */
  22. class Plugin extends DAV\ServerPlugin
  23. {
  24. /**
  25. * Url to the addressbooks.
  26. */
  27. const ADDRESSBOOK_ROOT = 'addressbooks';
  28. /**
  29. * xml namespace for CardDAV elements.
  30. */
  31. const NS_CARDDAV = 'urn:ietf:params:xml:ns:carddav';
  32. /**
  33. * Add urls to this property to have them automatically exposed as
  34. * 'directories' to the user.
  35. *
  36. * @var array
  37. */
  38. public $directories = [];
  39. /**
  40. * Server class.
  41. *
  42. * @var DAV\Server
  43. */
  44. protected $server;
  45. /**
  46. * The default PDO storage uses a MySQL MEDIUMBLOB for iCalendar data,
  47. * which can hold up to 2^24 = 16777216 bytes. This is plenty. We're
  48. * capping it to 10M here.
  49. */
  50. protected $maxResourceSize = 10000000;
  51. /**
  52. * Initializes the plugin.
  53. */
  54. public function initialize(DAV\Server $server)
  55. {
  56. /* Events */
  57. $server->on('propFind', [$this, 'propFindEarly']);
  58. $server->on('propFind', [$this, 'propFindLate'], 150);
  59. $server->on('report', [$this, 'report']);
  60. $server->on('onHTMLActionsPanel', [$this, 'htmlActionsPanel']);
  61. $server->on('beforeWriteContent', [$this, 'beforeWriteContent']);
  62. $server->on('beforeCreateFile', [$this, 'beforeCreateFile']);
  63. $server->on('afterMethod:GET', [$this, 'httpAfterGet']);
  64. $server->xml->namespaceMap[self::NS_CARDDAV] = 'card';
  65. $server->xml->elementMap['{'.self::NS_CARDDAV.'}addressbook-query'] = 'Sabre\\CardDAV\\Xml\\Request\\AddressBookQueryReport';
  66. $server->xml->elementMap['{'.self::NS_CARDDAV.'}addressbook-multiget'] = 'Sabre\\CardDAV\\Xml\\Request\\AddressBookMultiGetReport';
  67. /* Mapping Interfaces to {DAV:}resourcetype values */
  68. $server->resourceTypeMapping['Sabre\\CardDAV\\IAddressBook'] = '{'.self::NS_CARDDAV.'}addressbook';
  69. $server->resourceTypeMapping['Sabre\\CardDAV\\IDirectory'] = '{'.self::NS_CARDDAV.'}directory';
  70. /* Adding properties that may never be changed */
  71. $server->protectedProperties[] = '{'.self::NS_CARDDAV.'}supported-address-data';
  72. $server->protectedProperties[] = '{'.self::NS_CARDDAV.'}max-resource-size';
  73. $server->protectedProperties[] = '{'.self::NS_CARDDAV.'}addressbook-home-set';
  74. $server->protectedProperties[] = '{'.self::NS_CARDDAV.'}supported-collation-set';
  75. $server->xml->elementMap['{http://calendarserver.org/ns/}me-card'] = 'Sabre\\DAV\\Xml\\Property\\Href';
  76. $this->server = $server;
  77. }
  78. /**
  79. * Returns a list of supported features.
  80. *
  81. * This is used in the DAV: header in the OPTIONS and PROPFIND requests.
  82. *
  83. * @return array
  84. */
  85. public function getFeatures()
  86. {
  87. return ['addressbook'];
  88. }
  89. /**
  90. * Returns a list of reports this plugin supports.
  91. *
  92. * This will be used in the {DAV:}supported-report-set property.
  93. * Note that you still need to subscribe to the 'report' event to actually
  94. * implement them
  95. *
  96. * @param string $uri
  97. *
  98. * @return array
  99. */
  100. public function getSupportedReportSet($uri)
  101. {
  102. $node = $this->server->tree->getNodeForPath($uri);
  103. if ($node instanceof IAddressBook || $node instanceof ICard) {
  104. return [
  105. '{'.self::NS_CARDDAV.'}addressbook-multiget',
  106. '{'.self::NS_CARDDAV.'}addressbook-query',
  107. ];
  108. }
  109. return [];
  110. }
  111. /**
  112. * Adds all CardDAV-specific properties.
  113. */
  114. public function propFindEarly(DAV\PropFind $propFind, DAV\INode $node)
  115. {
  116. $ns = '{'.self::NS_CARDDAV.'}';
  117. if ($node instanceof IAddressBook) {
  118. $propFind->handle($ns.'max-resource-size', $this->maxResourceSize);
  119. $propFind->handle($ns.'supported-address-data', function () {
  120. return new Xml\Property\SupportedAddressData();
  121. });
  122. $propFind->handle($ns.'supported-collation-set', function () {
  123. return new Xml\Property\SupportedCollationSet();
  124. });
  125. }
  126. if ($node instanceof DAVACL\IPrincipal) {
  127. $path = $propFind->getPath();
  128. $propFind->handle('{'.self::NS_CARDDAV.'}addressbook-home-set', function () use ($path) {
  129. return new LocalHref($this->getAddressBookHomeForPrincipal($path).'/');
  130. });
  131. if ($this->directories) {
  132. $propFind->handle('{'.self::NS_CARDDAV.'}directory-gateway', function () {
  133. return new LocalHref($this->directories);
  134. });
  135. }
  136. }
  137. if ($node instanceof ICard) {
  138. // The address-data property is not supposed to be a 'real'
  139. // property, but in large chunks of the spec it does act as such.
  140. // Therefore we simply expose it as a property.
  141. $propFind->handle('{'.self::NS_CARDDAV.'}address-data', function () use ($node) {
  142. $val = $node->get();
  143. if (is_resource($val)) {
  144. $val = stream_get_contents($val);
  145. }
  146. return $val;
  147. });
  148. }
  149. }
  150. /**
  151. * This functions handles REPORT requests specific to CardDAV.
  152. *
  153. * @param string $reportName
  154. * @param \DOMNode $dom
  155. * @param mixed $path
  156. *
  157. * @return bool
  158. */
  159. public function report($reportName, $dom, $path)
  160. {
  161. switch ($reportName) {
  162. case '{'.self::NS_CARDDAV.'}addressbook-multiget':
  163. $this->server->transactionType = 'report-addressbook-multiget';
  164. $this->addressbookMultiGetReport($dom);
  165. return false;
  166. case '{'.self::NS_CARDDAV.'}addressbook-query':
  167. $this->server->transactionType = 'report-addressbook-query';
  168. $this->addressBookQueryReport($dom);
  169. return false;
  170. default:
  171. return;
  172. }
  173. }
  174. /**
  175. * Returns the addressbook home for a given principal.
  176. *
  177. * @param string $principal
  178. *
  179. * @return string
  180. */
  181. protected function getAddressbookHomeForPrincipal($principal)
  182. {
  183. list(, $principalId) = Uri\split($principal);
  184. return self::ADDRESSBOOK_ROOT.'/'.$principalId;
  185. }
  186. /**
  187. * This function handles the addressbook-multiget REPORT.
  188. *
  189. * This report is used by the client to fetch the content of a series
  190. * of urls. Effectively avoiding a lot of redundant requests.
  191. *
  192. * @param Xml\Request\AddressBookMultiGetReport $report
  193. */
  194. public function addressbookMultiGetReport($report)
  195. {
  196. $contentType = $report->contentType;
  197. $version = $report->version;
  198. if ($version) {
  199. $contentType .= '; version='.$version;
  200. }
  201. $vcardType = $this->negotiateVCard(
  202. $contentType
  203. );
  204. $propertyList = [];
  205. $paths = array_map(
  206. [$this->server, 'calculateUri'],
  207. $report->hrefs
  208. );
  209. foreach ($this->server->getPropertiesForMultiplePaths($paths, $report->properties) as $props) {
  210. if (isset($props['200']['{'.self::NS_CARDDAV.'}address-data'])) {
  211. $props['200']['{'.self::NS_CARDDAV.'}address-data'] = $this->convertVCard(
  212. $props[200]['{'.self::NS_CARDDAV.'}address-data'],
  213. $vcardType
  214. );
  215. }
  216. $propertyList[] = $props;
  217. }
  218. $prefer = $this->server->getHTTPPrefer();
  219. $this->server->httpResponse->setStatus(207);
  220. $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8');
  221. $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer');
  222. $this->server->httpResponse->setBody($this->server->generateMultiStatus($propertyList, 'minimal' === $prefer['return']));
  223. }
  224. /**
  225. * This method is triggered before a file gets updated with new content.
  226. *
  227. * This plugin uses this method to ensure that Card nodes receive valid
  228. * vcard data.
  229. *
  230. * @param string $path
  231. * @param resource $data
  232. * @param bool $modified should be set to true, if this event handler
  233. * changed &$data
  234. */
  235. public function beforeWriteContent($path, DAV\IFile $node, &$data, &$modified)
  236. {
  237. if (!$node instanceof ICard) {
  238. return;
  239. }
  240. $this->validateVCard($data, $modified);
  241. }
  242. /**
  243. * This method is triggered before a new file is created.
  244. *
  245. * This plugin uses this method to ensure that Card nodes receive valid
  246. * vcard data.
  247. *
  248. * @param string $path
  249. * @param resource $data
  250. * @param bool $modified should be set to true, if this event handler
  251. * changed &$data
  252. */
  253. public function beforeCreateFile($path, &$data, DAV\ICollection $parentNode, &$modified)
  254. {
  255. if (!$parentNode instanceof IAddressBook) {
  256. return;
  257. }
  258. $this->validateVCard($data, $modified);
  259. }
  260. /**
  261. * Checks if the submitted iCalendar data is in fact, valid.
  262. *
  263. * An exception is thrown if it's not.
  264. *
  265. * @param resource|string $data
  266. * @param bool $modified should be set to true, if this event handler
  267. * changed &$data
  268. */
  269. protected function validateVCard(&$data, &$modified)
  270. {
  271. // If it's a stream, we convert it to a string first.
  272. if (is_resource($data)) {
  273. $data = stream_get_contents($data);
  274. }
  275. $before = $data;
  276. try {
  277. // If the data starts with a [, we can reasonably assume we're dealing
  278. // with a jCal object.
  279. if ('[' === substr($data, 0, 1)) {
  280. $vobj = VObject\Reader::readJson($data);
  281. // Converting $data back to iCalendar, as that's what we
  282. // technically support everywhere.
  283. $data = $vobj->serialize();
  284. $modified = true;
  285. } else {
  286. $vobj = VObject\Reader::read($data);
  287. }
  288. } catch (VObject\ParseException $e) {
  289. throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid vCard or jCard data. Parse error: '.$e->getMessage());
  290. }
  291. if ('VCARD' !== $vobj->name) {
  292. throw new DAV\Exception\UnsupportedMediaType('This collection can only support vcard objects.');
  293. }
  294. $options = VObject\Node::PROFILE_CARDDAV;
  295. $prefer = $this->server->getHTTPPrefer();
  296. if ('strict' !== $prefer['handling']) {
  297. $options |= VObject\Node::REPAIR;
  298. }
  299. $messages = $vobj->validate($options);
  300. $highestLevel = 0;
  301. $warningMessage = null;
  302. // $messages contains a list of problems with the vcard, along with
  303. // their severity.
  304. foreach ($messages as $message) {
  305. if ($message['level'] > $highestLevel) {
  306. // Recording the highest reported error level.
  307. $highestLevel = $message['level'];
  308. $warningMessage = $message['message'];
  309. }
  310. switch ($message['level']) {
  311. case 1:
  312. // Level 1 means that there was a problem, but it was repaired.
  313. $modified = true;
  314. break;
  315. case 2:
  316. // Level 2 means a warning, but not critical
  317. break;
  318. case 3:
  319. // Level 3 means a critical error
  320. throw new DAV\Exception\UnsupportedMediaType('Validation error in vCard: '.$message['message']);
  321. }
  322. }
  323. if ($warningMessage) {
  324. $this->server->httpResponse->setHeader(
  325. 'X-Sabre-Ew-Gross',
  326. 'vCard validation warning: '.$warningMessage
  327. );
  328. // Re-serializing object.
  329. $data = $vobj->serialize();
  330. if (!$modified && 0 !== strcmp($data, $before)) {
  331. // This ensures that the system does not send an ETag back.
  332. $modified = true;
  333. }
  334. }
  335. // Destroy circular references to PHP will GC the object.
  336. $vobj->destroy();
  337. }
  338. /**
  339. * This function handles the addressbook-query REPORT.
  340. *
  341. * This report is used by the client to filter an addressbook based on a
  342. * complex query.
  343. *
  344. * @param Xml\Request\AddressBookQueryReport $report
  345. */
  346. protected function addressbookQueryReport($report)
  347. {
  348. $depth = $this->server->getHTTPDepth(0);
  349. if (0 == $depth) {
  350. $candidateNodes = [
  351. $this->server->tree->getNodeForPath($this->server->getRequestUri()),
  352. ];
  353. if (!$candidateNodes[0] instanceof ICard) {
  354. throw new ReportNotSupported('The addressbook-query report is not supported on this url with Depth: 0');
  355. }
  356. } else {
  357. $candidateNodes = $this->server->tree->getChildren($this->server->getRequestUri());
  358. }
  359. $contentType = $report->contentType;
  360. if ($report->version) {
  361. $contentType .= '; version='.$report->version;
  362. }
  363. $vcardType = $this->negotiateVCard(
  364. $contentType
  365. );
  366. $validNodes = [];
  367. foreach ($candidateNodes as $node) {
  368. if (!$node instanceof ICard) {
  369. continue;
  370. }
  371. $blob = $node->get();
  372. if (is_resource($blob)) {
  373. $blob = stream_get_contents($blob);
  374. }
  375. if (!$this->validateFilters($blob, $report->filters, $report->test)) {
  376. continue;
  377. }
  378. $validNodes[] = $node;
  379. if ($report->limit && $report->limit <= count($validNodes)) {
  380. // We hit the maximum number of items, we can stop now.
  381. break;
  382. }
  383. }
  384. $result = [];
  385. foreach ($validNodes as $validNode) {
  386. if (0 == $depth) {
  387. $href = $this->server->getRequestUri();
  388. } else {
  389. $href = $this->server->getRequestUri().'/'.$validNode->getName();
  390. }
  391. list($props) = $this->server->getPropertiesForPath($href, $report->properties, 0);
  392. if (isset($props[200]['{'.self::NS_CARDDAV.'}address-data'])) {
  393. $props[200]['{'.self::NS_CARDDAV.'}address-data'] = $this->convertVCard(
  394. $props[200]['{'.self::NS_CARDDAV.'}address-data'],
  395. $vcardType,
  396. $report->addressDataProperties
  397. );
  398. }
  399. $result[] = $props;
  400. }
  401. $prefer = $this->server->getHTTPPrefer();
  402. $this->server->httpResponse->setStatus(207);
  403. $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8');
  404. $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer');
  405. $this->server->httpResponse->setBody($this->server->generateMultiStatus($result, 'minimal' === $prefer['return']));
  406. }
  407. /**
  408. * Validates if a vcard makes it throught a list of filters.
  409. *
  410. * @param string $vcardData
  411. * @param string $test anyof or allof (which means OR or AND)
  412. *
  413. * @return bool
  414. */
  415. public function validateFilters($vcardData, array $filters, $test)
  416. {
  417. if (!$filters) {
  418. return true;
  419. }
  420. $vcard = VObject\Reader::read($vcardData);
  421. foreach ($filters as $filter) {
  422. $isDefined = isset($vcard->{$filter['name']});
  423. if ($filter['is-not-defined']) {
  424. if ($isDefined) {
  425. $success = false;
  426. } else {
  427. $success = true;
  428. }
  429. } elseif ((!$filter['param-filters'] && !$filter['text-matches']) || !$isDefined) {
  430. // We only need to check for existence
  431. $success = $isDefined;
  432. } else {
  433. $vProperties = $vcard->select($filter['name']);
  434. $results = [];
  435. if ($filter['param-filters']) {
  436. $results[] = $this->validateParamFilters($vProperties, $filter['param-filters'], $filter['test']);
  437. }
  438. if ($filter['text-matches']) {
  439. $texts = [];
  440. foreach ($vProperties as $vProperty) {
  441. $texts[] = $vProperty->getValue();
  442. }
  443. $results[] = $this->validateTextMatches($texts, $filter['text-matches'], $filter['test']);
  444. }
  445. if (1 === count($results)) {
  446. $success = $results[0];
  447. } else {
  448. if ('anyof' === $filter['test']) {
  449. $success = $results[0] || $results[1];
  450. } else {
  451. $success = $results[0] && $results[1];
  452. }
  453. }
  454. } // else
  455. // There are two conditions where we can already determine whether
  456. // or not this filter succeeds.
  457. if ('anyof' === $test && $success) {
  458. // Destroy circular references to PHP will GC the object.
  459. $vcard->destroy();
  460. return true;
  461. }
  462. if ('allof' === $test && !$success) {
  463. // Destroy circular references to PHP will GC the object.
  464. $vcard->destroy();
  465. return false;
  466. }
  467. } // foreach
  468. // Destroy circular references to PHP will GC the object.
  469. $vcard->destroy();
  470. // If we got all the way here, it means we haven't been able to
  471. // determine early if the test failed or not.
  472. //
  473. // This implies for 'anyof' that the test failed, and for 'allof' that
  474. // we succeeded. Sounds weird, but makes sense.
  475. return 'allof' === $test;
  476. }
  477. /**
  478. * Validates if a param-filter can be applied to a specific property.
  479. *
  480. * @todo currently we're only validating the first parameter of the passed
  481. * property. Any subsequence parameters with the same name are
  482. * ignored.
  483. *
  484. * @param string $test
  485. *
  486. * @return bool
  487. */
  488. protected function validateParamFilters(array $vProperties, array $filters, $test)
  489. {
  490. foreach ($filters as $filter) {
  491. $isDefined = false;
  492. foreach ($vProperties as $vProperty) {
  493. $isDefined = isset($vProperty[$filter['name']]);
  494. if ($isDefined) {
  495. break;
  496. }
  497. }
  498. if ($filter['is-not-defined']) {
  499. if ($isDefined) {
  500. $success = false;
  501. } else {
  502. $success = true;
  503. }
  504. // If there's no text-match, we can just check for existence
  505. } elseif (!$filter['text-match'] || !$isDefined) {
  506. $success = $isDefined;
  507. } else {
  508. $success = false;
  509. foreach ($vProperties as $vProperty) {
  510. // If we got all the way here, we'll need to validate the
  511. // text-match filter.
  512. if (isset($vProperty[$filter['name']])) {
  513. $success = DAV\StringUtil::textMatch(
  514. $vProperty[$filter['name']]->getValue(),
  515. $filter['text-match']['value'],
  516. $filter['text-match']['collation'],
  517. $filter['text-match']['match-type']
  518. );
  519. if ($filter['text-match']['negate-condition']) {
  520. $success = !$success;
  521. }
  522. }
  523. if ($success) {
  524. break;
  525. }
  526. }
  527. } // else
  528. // There are two conditions where we can already determine whether
  529. // or not this filter succeeds.
  530. if ('anyof' === $test && $success) {
  531. return true;
  532. }
  533. if ('allof' === $test && !$success) {
  534. return false;
  535. }
  536. }
  537. // If we got all the way here, it means we haven't been able to
  538. // determine early if the test failed or not.
  539. //
  540. // This implies for 'anyof' that the test failed, and for 'allof' that
  541. // we succeeded. Sounds weird, but makes sense.
  542. return 'allof' === $test;
  543. }
  544. /**
  545. * Validates if a text-filter can be applied to a specific property.
  546. *
  547. * @param string $test
  548. *
  549. * @return bool
  550. */
  551. protected function validateTextMatches(array $texts, array $filters, $test)
  552. {
  553. foreach ($filters as $filter) {
  554. $success = false;
  555. foreach ($texts as $haystack) {
  556. $success = DAV\StringUtil::textMatch($haystack, $filter['value'], $filter['collation'], $filter['match-type']);
  557. if ($filter['negate-condition']) {
  558. $success = !$success;
  559. }
  560. // Breaking on the first match
  561. if ($success) {
  562. break;
  563. }
  564. }
  565. if ($success && 'anyof' === $test) {
  566. return true;
  567. }
  568. if (!$success && 'allof' == $test) {
  569. return false;
  570. }
  571. }
  572. // If we got all the way here, it means we haven't been able to
  573. // determine early if the test failed or not.
  574. //
  575. // This implies for 'anyof' that the test failed, and for 'allof' that
  576. // we succeeded. Sounds weird, but makes sense.
  577. return 'allof' === $test;
  578. }
  579. /**
  580. * This event is triggered when fetching properties.
  581. *
  582. * This event is scheduled late in the process, after most work for
  583. * propfind has been done.
  584. */
  585. public function propFindLate(DAV\PropFind $propFind, DAV\INode $node)
  586. {
  587. // If the request was made using the SOGO connector, we must rewrite
  588. // the content-type property. By default SabreDAV will send back
  589. // text/x-vcard; charset=utf-8, but for SOGO we must strip that last
  590. // part.
  591. if (false === strpos((string) $this->server->httpRequest->getHeader('User-Agent'), 'Thunderbird')) {
  592. return;
  593. }
  594. $contentType = $propFind->get('{DAV:}getcontenttype');
  595. if (null !== $contentType) {
  596. list($part) = explode(';', $contentType);
  597. if ('text/x-vcard' === $part || 'text/vcard' === $part) {
  598. $propFind->set('{DAV:}getcontenttype', 'text/x-vcard');
  599. }
  600. }
  601. }
  602. /**
  603. * This method is used to generate HTML output for the
  604. * Sabre\DAV\Browser\Plugin. This allows us to generate an interface users
  605. * can use to create new addressbooks.
  606. *
  607. * @param string $output
  608. *
  609. * @return bool
  610. */
  611. public function htmlActionsPanel(DAV\INode $node, &$output)
  612. {
  613. if (!$node instanceof AddressBookHome) {
  614. return;
  615. }
  616. $output .= '<tr><td colspan="2"><form method="post" action="">
  617. <h3>Create new address book</h3>
  618. <input type="hidden" name="sabreAction" value="mkcol" />
  619. <input type="hidden" name="resourceType" value="{DAV:}collection,{'.self::NS_CARDDAV.'}addressbook" />
  620. <label>Name (uri):</label> <input type="text" name="name" /><br />
  621. <label>Display name:</label> <input type="text" name="{DAV:}displayname" /><br />
  622. <input type="submit" value="create" />
  623. </form>
  624. </td></tr>';
  625. return false;
  626. }
  627. /**
  628. * This event is triggered after GET requests.
  629. *
  630. * This is used to transform data into jCal, if this was requested.
  631. */
  632. public function httpAfterGet(RequestInterface $request, ResponseInterface $response)
  633. {
  634. $contentType = $response->getHeader('Content-Type');
  635. if (null === $contentType || false === strpos($contentType, 'text/vcard')) {
  636. return;
  637. }
  638. $target = $this->negotiateVCard($request->getHeader('Accept'), $mimeType);
  639. $newBody = $this->convertVCard(
  640. $response->getBody(),
  641. $target
  642. );
  643. $response->setBody($newBody);
  644. $response->setHeader('Content-Type', $mimeType.'; charset=utf-8');
  645. $response->setHeader('Content-Length', strlen($newBody));
  646. }
  647. /**
  648. * This helper function performs the content-type negotiation for vcards.
  649. *
  650. * It will return one of the following strings:
  651. * 1. vcard3
  652. * 2. vcard4
  653. * 3. jcard
  654. *
  655. * It defaults to vcard3.
  656. *
  657. * @param string $input
  658. * @param string $mimeType
  659. *
  660. * @return string
  661. */
  662. protected function negotiateVCard($input, &$mimeType = null)
  663. {
  664. $result = HTTP\negotiateContentType(
  665. $input,
  666. [
  667. // Most often used mime-type. Version 3
  668. 'text/x-vcard',
  669. // The correct standard mime-type. Defaults to version 3 as
  670. // well.
  671. 'text/vcard',
  672. // vCard 4
  673. 'text/vcard; version=4.0',
  674. // vCard 3
  675. 'text/vcard; version=3.0',
  676. // jCard
  677. 'application/vcard+json',
  678. ]
  679. );
  680. $mimeType = $result;
  681. switch ($result) {
  682. default:
  683. case 'text/x-vcard':
  684. case 'text/vcard':
  685. case 'text/vcard; version=3.0':
  686. $mimeType = 'text/vcard';
  687. return 'vcard3';
  688. case 'text/vcard; version=4.0':
  689. return 'vcard4';
  690. case 'application/vcard+json':
  691. return 'jcard';
  692. // @codeCoverageIgnoreStart
  693. }
  694. // @codeCoverageIgnoreEnd
  695. }
  696. /**
  697. * Converts a vcard blob to a different version, or jcard.
  698. *
  699. * @param string|resource $data
  700. * @param string $target
  701. * @param array $propertiesFilter
  702. *
  703. * @return string
  704. */
  705. protected function convertVCard($data, $target, array $propertiesFilter = null)
  706. {
  707. if (is_resource($data)) {
  708. $data = stream_get_contents($data);
  709. }
  710. $input = VObject\Reader::read($data);
  711. if (!empty($propertiesFilter)) {
  712. $propertiesFilter = array_merge(['UID', 'VERSION', 'FN'], $propertiesFilter);
  713. $keys = array_unique(array_map(function ($child) {
  714. return $child->name;
  715. }, $input->children()));
  716. $keys = array_diff($keys, $propertiesFilter);
  717. foreach ($keys as $key) {
  718. unset($input->$key);
  719. }
  720. $data = $input->serialize();
  721. }
  722. $output = null;
  723. try {
  724. switch ($target) {
  725. default:
  726. case 'vcard3':
  727. if (VObject\Document::VCARD30 === $input->getDocumentType()) {
  728. // Do nothing
  729. return $data;
  730. }
  731. $output = $input->convert(VObject\Document::VCARD30);
  732. return $output->serialize();
  733. case 'vcard4':
  734. if (VObject\Document::VCARD40 === $input->getDocumentType()) {
  735. // Do nothing
  736. return $data;
  737. }
  738. $output = $input->convert(VObject\Document::VCARD40);
  739. return $output->serialize();
  740. case 'jcard':
  741. $output = $input->convert(VObject\Document::VCARD40);
  742. return json_encode($output);
  743. }
  744. } finally {
  745. // Destroy circular references to PHP will GC the object.
  746. $input->destroy();
  747. if (!is_null($output)) {
  748. $output->destroy();
  749. }
  750. }
  751. }
  752. /**
  753. * Returns a plugin name.
  754. *
  755. * Using this name other plugins will be able to access other plugins
  756. * using DAV\Server::getPlugin
  757. *
  758. * @return string
  759. */
  760. public function getPluginName()
  761. {
  762. return 'carddav';
  763. }
  764. /**
  765. * Returns a bunch of meta-data about the plugin.
  766. *
  767. * Providing this information is optional, and is mainly displayed by the
  768. * Browser plugin.
  769. *
  770. * The description key in the returned array may contain html and will not
  771. * be sanitized.
  772. *
  773. * @return array
  774. */
  775. public function getPluginInfo()
  776. {
  777. return [
  778. 'name' => $this->getPluginName(),
  779. 'description' => 'Adds support for CardDAV (rfc6352)',
  780. 'link' => 'http://sabre.io/dav/carddav/',
  781. ];
  782. }
  783. }