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