1<?php 2 3namespace Sabre\CardDAV; 4 5use Sabre\DAV; 6use Sabre\DAV\Exception\ReportNotSupported; 7use Sabre\DAV\Xml\Property\Href; 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) 2007-2015 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 Sabre\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 Href($this->getAddressBookHomeForPrincipal($path) . '/'); 160 }); 161 162 if ($this->directories) $propFind->handle('{' . self::NS_CARDDAV . '}directory-gateway', function() { 163 return new Href($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 * @return bool 192 */ 193 function report($reportName, $dom) { 194 195 switch ($reportName) { 196 case '{' . self::NS_CARDDAV . '}addressbook-multiget' : 197 $this->server->transactionType = 'report-addressbook-multiget'; 198 $this->addressbookMultiGetReport($dom); 199 return false; 200 case '{' . self::NS_CARDDAV . '}addressbook-query' : 201 $this->server->transactionType = 'report-addressbook-query'; 202 $this->addressBookQueryReport($dom); 203 return false; 204 default : 205 return; 206 207 } 208 209 210 } 211 212 /** 213 * Returns the addressbook home for a given principal 214 * 215 * @param string $principal 216 * @return string 217 */ 218 protected function getAddressbookHomeForPrincipal($principal) { 219 220 list(, $principalId) = \Sabre\HTTP\URLUtil::splitPath($principal); 221 return self::ADDRESSBOOK_ROOT . '/' . $principalId; 222 223 } 224 225 226 /** 227 * This function handles the addressbook-multiget REPORT. 228 * 229 * This report is used by the client to fetch the content of a series 230 * of urls. Effectively avoiding a lot of redundant requests. 231 * 232 * @param Xml\Request\AddressBookMultiGetReport $report 233 * @return void 234 */ 235 function addressbookMultiGetReport($report) { 236 237 $contentType = $report->contentType; 238 $version = $report->version; 239 if ($version) { 240 $contentType .= '; version=' . $version; 241 } 242 243 $vcardType = $this->negotiateVCard( 244 $contentType 245 ); 246 247 $propertyList = []; 248 $paths = array_map( 249 [$this->server, 'calculateUri'], 250 $report->hrefs 251 ); 252 foreach ($this->server->getPropertiesForMultiplePaths($paths, $report->properties) as $props) { 253 254 if (isset($props['200']['{' . self::NS_CARDDAV . '}address-data'])) { 255 256 $props['200']['{' . self::NS_CARDDAV . '}address-data'] = $this->convertVCard( 257 $props[200]['{' . self::NS_CARDDAV . '}address-data'], 258 $vcardType 259 ); 260 261 } 262 $propertyList[] = $props; 263 264 } 265 266 $prefer = $this->server->getHTTPPrefer(); 267 268 $this->server->httpResponse->setStatus(207); 269 $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); 270 $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer'); 271 $this->server->httpResponse->setBody($this->server->generateMultiStatus($propertyList, $prefer['return'] === 'minimal')); 272 273 } 274 275 /** 276 * This method is triggered before a file gets updated with new content. 277 * 278 * This plugin uses this method to ensure that Card nodes receive valid 279 * vcard data. 280 * 281 * @param string $path 282 * @param DAV\IFile $node 283 * @param resource $data 284 * @param bool $modified Should be set to true, if this event handler 285 * changed &$data. 286 * @return void 287 */ 288 function beforeWriteContent($path, DAV\IFile $node, &$data, &$modified) { 289 290 if (!$node instanceof ICard) 291 return; 292 293 $this->validateVCard($data, $modified); 294 295 } 296 297 /** 298 * This method is triggered before a new file is created. 299 * 300 * This plugin uses this method to ensure that Card nodes receive valid 301 * vcard data. 302 * 303 * @param string $path 304 * @param resource $data 305 * @param DAV\ICollection $parentNode 306 * @param bool $modified Should be set to true, if this event handler 307 * changed &$data. 308 * @return void 309 */ 310 function beforeCreateFile($path, &$data, DAV\ICollection $parentNode, &$modified) { 311 312 if (!$parentNode instanceof IAddressBook) 313 return; 314 315 $this->validateVCard($data, $modified); 316 317 } 318 319 /** 320 * Checks if the submitted iCalendar data is in fact, valid. 321 * 322 * An exception is thrown if it's not. 323 * 324 * @param resource|string $data 325 * @param bool $modified Should be set to true, if this event handler 326 * changed &$data. 327 * @return void 328 */ 329 protected function validateVCard(&$data, &$modified) { 330 331 // If it's a stream, we convert it to a string first. 332 if (is_resource($data)) { 333 $data = stream_get_contents($data); 334 } 335 336 $before = md5($data); 337 338 // Converting the data to unicode, if needed. 339 $data = DAV\StringUtil::ensureUTF8($data); 340 341 if (md5($data) !== $before) $modified = true; 342 343 try { 344 345 // If the data starts with a [, we can reasonably assume we're dealing 346 // with a jCal object. 347 if (substr($data, 0, 1) === '[') { 348 $vobj = VObject\Reader::readJson($data); 349 350 // Converting $data back to iCalendar, as that's what we 351 // technically support everywhere. 352 $data = $vobj->serialize(); 353 $modified = true; 354 } else { 355 $vobj = VObject\Reader::read($data); 356 } 357 358 } catch (VObject\ParseException $e) { 359 360 throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid vCard or jCard data. Parse error: ' . $e->getMessage()); 361 362 } 363 364 if ($vobj->name !== 'VCARD') { 365 throw new DAV\Exception\UnsupportedMediaType('This collection can only support vcard objects.'); 366 } 367 368 if (!isset($vobj->UID)) { 369 // No UID in vcards is invalid, but we'll just add it in anyway. 370 $vobj->add('UID', DAV\UUIDUtil::getUUID()); 371 $data = $vobj->serialize(); 372 $modified = true; 373 } 374 375 } 376 377 378 /** 379 * This function handles the addressbook-query REPORT 380 * 381 * This report is used by the client to filter an addressbook based on a 382 * complex query. 383 * 384 * @param Xml\Request\AddressBookQueryReport $report 385 * @return void 386 */ 387 protected function addressbookQueryReport($report) { 388 389 $depth = $this->server->getHTTPDepth(0); 390 391 if ($depth == 0) { 392 $candidateNodes = [ 393 $this->server->tree->getNodeForPath($this->server->getRequestUri()) 394 ]; 395 if (!$candidateNodes[0] instanceof ICard) { 396 throw new ReportNotSupported('The addressbook-query report is not supported on this url with Depth: 0'); 397 } 398 } else { 399 $candidateNodes = $this->server->tree->getChildren($this->server->getRequestUri()); 400 } 401 402 $contentType = $report->contentType; 403 if ($report->version) { 404 $contentType .= '; version=' . $report->version; 405 } 406 407 $vcardType = $this->negotiateVCard( 408 $contentType 409 ); 410 411 $validNodes = []; 412 foreach ($candidateNodes as $node) { 413 414 if (!$node instanceof ICard) 415 continue; 416 417 $blob = $node->get(); 418 if (is_resource($blob)) { 419 $blob = stream_get_contents($blob); 420 } 421 422 if (!$this->validateFilters($blob, $report->filters, $report->test)) { 423 continue; 424 } 425 426 $validNodes[] = $node; 427 428 if ($report->limit && $report->limit <= count($validNodes)) { 429 // We hit the maximum number of items, we can stop now. 430 break; 431 } 432 433 } 434 435 $result = []; 436 foreach ($validNodes as $validNode) { 437 438 if ($depth == 0) { 439 $href = $this->server->getRequestUri(); 440 } else { 441 $href = $this->server->getRequestUri() . '/' . $validNode->getName(); 442 } 443 444 list($props) = $this->server->getPropertiesForPath($href, $report->properties, 0); 445 446 if (isset($props[200]['{' . self::NS_CARDDAV . '}address-data'])) { 447 448 $props[200]['{' . self::NS_CARDDAV . '}address-data'] = $this->convertVCard( 449 $props[200]['{' . self::NS_CARDDAV . '}address-data'], 450 $vcardType 451 ); 452 453 } 454 $result[] = $props; 455 456 } 457 458 $prefer = $this->server->getHTTPPrefer(); 459 460 $this->server->httpResponse->setStatus(207); 461 $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); 462 $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer'); 463 $this->server->httpResponse->setBody($this->server->generateMultiStatus($result, $prefer['return'] === 'minimal')); 464 465 } 466 467 /** 468 * Validates if a vcard makes it throught a list of filters. 469 * 470 * @param string $vcardData 471 * @param array $filters 472 * @param string $test anyof or allof (which means OR or AND) 473 * @return bool 474 */ 475 function validateFilters($vcardData, array $filters, $test) { 476 477 $vcard = VObject\Reader::read($vcardData); 478 479 if (!$filters) return true; 480 481 foreach ($filters as $filter) { 482 483 $isDefined = isset($vcard->{$filter['name']}); 484 if ($filter['is-not-defined']) { 485 if ($isDefined) { 486 $success = false; 487 } else { 488 $success = true; 489 } 490 } elseif ((!$filter['param-filters'] && !$filter['text-matches']) || !$isDefined) { 491 492 // We only need to check for existence 493 $success = $isDefined; 494 495 } else { 496 497 $vProperties = $vcard->select($filter['name']); 498 499 $results = []; 500 if ($filter['param-filters']) { 501 $results[] = $this->validateParamFilters($vProperties, $filter['param-filters'], $filter['test']); 502 } 503 if ($filter['text-matches']) { 504 $texts = []; 505 foreach ($vProperties as $vProperty) 506 $texts[] = $vProperty->getValue(); 507 508 $results[] = $this->validateTextMatches($texts, $filter['text-matches'], $filter['test']); 509 } 510 511 if (count($results) === 1) { 512 $success = $results[0]; 513 } else { 514 if ($filter['test'] === 'anyof') { 515 $success = $results[0] || $results[1]; 516 } else { 517 $success = $results[0] && $results[1]; 518 } 519 } 520 521 } // else 522 523 // There are two conditions where we can already determine whether 524 // or not this filter succeeds. 525 if ($test === 'anyof' && $success) { 526 return true; 527 } 528 if ($test === 'allof' && !$success) { 529 return false; 530 } 531 532 } // foreach 533 534 // If we got all the way here, it means we haven't been able to 535 // determine early if the test failed or not. 536 // 537 // This implies for 'anyof' that the test failed, and for 'allof' that 538 // we succeeded. Sounds weird, but makes sense. 539 return $test === 'allof'; 540 541 } 542 543 /** 544 * Validates if a param-filter can be applied to a specific property. 545 * 546 * @todo currently we're only validating the first parameter of the passed 547 * property. Any subsequence parameters with the same name are 548 * ignored. 549 * @param array $vProperties 550 * @param array $filters 551 * @param string $test 552 * @return bool 553 */ 554 protected function validateParamFilters(array $vProperties, array $filters, $test) { 555 556 foreach ($filters as $filter) { 557 558 $isDefined = false; 559 foreach ($vProperties as $vProperty) { 560 $isDefined = isset($vProperty[$filter['name']]); 561 if ($isDefined) break; 562 } 563 564 if ($filter['is-not-defined']) { 565 if ($isDefined) { 566 $success = false; 567 } else { 568 $success = true; 569 } 570 571 // If there's no text-match, we can just check for existence 572 } elseif (!$filter['text-match'] || !$isDefined) { 573 574 $success = $isDefined; 575 576 } else { 577 578 $success = false; 579 foreach ($vProperties as $vProperty) { 580 // If we got all the way here, we'll need to validate the 581 // text-match filter. 582 $success = DAV\StringUtil::textMatch($vProperty[$filter['name']]->getValue(), $filter['text-match']['value'], $filter['text-match']['collation'], $filter['text-match']['match-type']); 583 if ($success) break; 584 } 585 if ($filter['text-match']['negate-condition']) { 586 $success = !$success; 587 } 588 589 } // else 590 591 // There are two conditions where we can already determine whether 592 // or not this filter succeeds. 593 if ($test === 'anyof' && $success) { 594 return true; 595 } 596 if ($test === 'allof' && !$success) { 597 return false; 598 } 599 600 } 601 602 // If we got all the way here, it means we haven't been able to 603 // determine early if the test failed or not. 604 // 605 // This implies for 'anyof' that the test failed, and for 'allof' that 606 // we succeeded. Sounds weird, but makes sense. 607 return $test === 'allof'; 608 609 } 610 611 /** 612 * Validates if a text-filter can be applied to a specific property. 613 * 614 * @param array $texts 615 * @param array $filters 616 * @param string $test 617 * @return bool 618 */ 619 protected function validateTextMatches(array $texts, array $filters, $test) { 620 621 foreach ($filters as $filter) { 622 623 $success = false; 624 foreach ($texts as $haystack) { 625 $success = DAV\StringUtil::textMatch($haystack, $filter['value'], $filter['collation'], $filter['match-type']); 626 627 // Breaking on the first match 628 if ($success) break; 629 } 630 if ($filter['negate-condition']) { 631 $success = !$success; 632 } 633 634 if ($success && $test === 'anyof') 635 return true; 636 637 if (!$success && $test == 'allof') 638 return false; 639 640 641 } 642 643 // If we got all the way here, it means we haven't been able to 644 // determine early if the test failed or not. 645 // 646 // This implies for 'anyof' that the test failed, and for 'allof' that 647 // we succeeded. Sounds weird, but makes sense. 648 return $test === 'allof'; 649 650 } 651 652 /** 653 * This event is triggered when fetching properties. 654 * 655 * This event is scheduled late in the process, after most work for 656 * propfind has been done. 657 * 658 * @param DAV\PropFind $propFind 659 * @param DAV\INode $node 660 * @return void 661 */ 662 function propFindLate(DAV\PropFind $propFind, DAV\INode $node) { 663 664 // If the request was made using the SOGO connector, we must rewrite 665 // the content-type property. By default SabreDAV will send back 666 // text/x-vcard; charset=utf-8, but for SOGO we must strip that last 667 // part. 668 if (strpos($this->server->httpRequest->getHeader('User-Agent'), 'Thunderbird') === false) { 669 return; 670 } 671 $contentType = $propFind->get('{DAV:}getcontenttype'); 672 list($part) = explode(';', $contentType); 673 if ($part === 'text/x-vcard' || $part === 'text/vcard') { 674 $propFind->set('{DAV:}getcontenttype', 'text/x-vcard'); 675 } 676 677 } 678 679 /** 680 * This method is used to generate HTML output for the 681 * Sabre\DAV\Browser\Plugin. This allows us to generate an interface users 682 * can use to create new addressbooks. 683 * 684 * @param DAV\INode $node 685 * @param string $output 686 * @return bool 687 */ 688 function htmlActionsPanel(DAV\INode $node, &$output) { 689 690 if (!$node instanceof AddressBookHome) 691 return; 692 693 $output .= '<tr><td colspan="2"><form method="post" action=""> 694 <h3>Create new address book</h3> 695 <input type="hidden" name="sabreAction" value="mkcol" /> 696 <input type="hidden" name="resourceType" value="{DAV:}collection,{' . self::NS_CARDDAV . '}addressbook" /> 697 <label>Name (uri):</label> <input type="text" name="name" /><br /> 698 <label>Display name:</label> <input type="text" name="{DAV:}displayname" /><br /> 699 <input type="submit" value="create" /> 700 </form> 701 </td></tr>'; 702 703 return false; 704 705 } 706 707 /** 708 * This event is triggered after GET requests. 709 * 710 * This is used to transform data into jCal, if this was requested. 711 * 712 * @param RequestInterface $request 713 * @param ResponseInterface $response 714 * @return void 715 */ 716 function httpAfterGet(RequestInterface $request, ResponseInterface $response) { 717 718 if (strpos($response->getHeader('Content-Type'), 'text/vcard') === false) { 719 return; 720 } 721 722 $target = $this->negotiateVCard($request->getHeader('Accept'), $mimeType); 723 724 $newBody = $this->convertVCard( 725 $response->getBody(), 726 $target 727 ); 728 729 $response->setBody($newBody); 730 $response->setHeader('Content-Type', $mimeType . '; charset=utf-8'); 731 $response->setHeader('Content-Length', strlen($newBody)); 732 733 } 734 735 /** 736 * This helper function performs the content-type negotiation for vcards. 737 * 738 * It will return one of the following strings: 739 * 1. vcard3 740 * 2. vcard4 741 * 3. jcard 742 * 743 * It defaults to vcard3. 744 * 745 * @param string $input 746 * @param string $mimeType 747 * @return string 748 */ 749 protected function negotiateVCard($input, &$mimeType = null) { 750 751 $result = HTTP\Util::negotiate( 752 $input, 753 [ 754 // Most often used mime-type. Version 3 755 'text/x-vcard', 756 // The correct standard mime-type. Defaults to version 3 as 757 // well. 758 'text/vcard', 759 // vCard 4 760 'text/vcard; version=4.0', 761 // vCard 3 762 'text/vcard; version=3.0', 763 // jCard 764 'application/vcard+json', 765 ] 766 ); 767 768 $mimeType = $result; 769 switch ($result) { 770 771 default : 772 case 'text/x-vcard' : 773 case 'text/vcard' : 774 case 'text/vcard; version=3.0' : 775 $mimeType = 'text/vcard'; 776 return 'vcard3'; 777 case 'text/vcard; version=4.0' : 778 return 'vcard4'; 779 case 'application/vcard+json' : 780 return 'jcard'; 781 782 // @codeCoverageIgnoreStart 783 } 784 // @codeCoverageIgnoreEnd 785 786 } 787 788 /** 789 * Converts a vcard blob to a different version, or jcard. 790 * 791 * @param string $data 792 * @param string $target 793 * @return string 794 */ 795 protected function convertVCard($data, $target) { 796 797 $data = VObject\Reader::read($data); 798 switch ($target) { 799 default : 800 case 'vcard3' : 801 $data = $data->convert(VObject\Document::VCARD30); 802 return $data->serialize(); 803 case 'vcard4' : 804 $data = $data->convert(VObject\Document::VCARD40); 805 return $data->serialize(); 806 case 'jcard' : 807 $data = $data->convert(VObject\Document::VCARD40); 808 return json_encode($data->jsonSerialize()); 809 810 // @codeCoverageIgnoreStart 811 } 812 // @codeCoverageIgnoreEnd 813 814 } 815 816 /** 817 * Returns a plugin name. 818 * 819 * Using this name other plugins will be able to access other plugins 820 * using DAV\Server::getPlugin 821 * 822 * @return string 823 */ 824 function getPluginName() { 825 826 return 'carddav'; 827 828 } 829 830 /** 831 * Returns a bunch of meta-data about the plugin. 832 * 833 * Providing this information is optional, and is mainly displayed by the 834 * Browser plugin. 835 * 836 * The description key in the returned array may contain html and will not 837 * be sanitized. 838 * 839 * @return array 840 */ 841 function getPluginInfo() { 842 843 return [ 844 'name' => $this->getPluginName(), 845 'description' => 'Adds support for CardDAV (rfc6352)', 846 'link' => 'http://sabre.io/dav/carddav/', 847 ]; 848 849 } 850 851} 852