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