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