diff options
Diffstat (limited to 'vendor/sabre/dav/lib/CardDAV/Plugin.php')
-rw-r--r-- | vendor/sabre/dav/lib/CardDAV/Plugin.php | 871 |
1 files changed, 871 insertions, 0 deletions
diff --git a/vendor/sabre/dav/lib/CardDAV/Plugin.php b/vendor/sabre/dav/lib/CardDAV/Plugin.php new file mode 100644 index 000000000..b8bded098 --- /dev/null +++ b/vendor/sabre/dav/lib/CardDAV/Plugin.php @@ -0,0 +1,871 @@ +<?php + +namespace Sabre\CardDAV; + +use Sabre\DAV; +use Sabre\DAV\Exception\ReportNotSupported; +use Sabre\DAV\Xml\Property\Href; +use Sabre\DAVACL; +use Sabre\HTTP; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Sabre\VObject; + +/** + * CardDAV plugin + * + * The CardDAV plugin adds CardDAV functionality to the WebDAV server + * + * @copyright Copyright (C) fruux GmbH (https://fruux.com/) + * @author Evert Pot (http://evertpot.com/) + * @license http://sabre.io/license/ Modified BSD License + */ +class Plugin extends DAV\ServerPlugin { + + /** + * Url to the addressbooks + */ + const ADDRESSBOOK_ROOT = 'addressbooks'; + + /** + * xml namespace for CardDAV elements + */ + const NS_CARDDAV = 'urn:ietf:params:xml:ns:carddav'; + + /** + * Add urls to this property to have them automatically exposed as + * 'directories' to the user. + * + * @var array + */ + public $directories = []; + + /** + * Server class + * + * @var Sabre\DAV\Server + */ + protected $server; + + /** + * The default PDO storage uses a MySQL MEDIUMBLOB for iCalendar data, + * which can hold up to 2^24 = 16777216 bytes. This is plenty. We're + * capping it to 10M here. + */ + protected $maxResourceSize = 10000000; + + /** + * Initializes the plugin + * + * @param DAV\Server $server + * @return void + */ + function initialize(DAV\Server $server) { + + /* Events */ + $server->on('propFind', [$this, 'propFindEarly']); + $server->on('propFind', [$this, 'propFindLate'], 150); + $server->on('report', [$this, 'report']); + $server->on('onHTMLActionsPanel', [$this, 'htmlActionsPanel']); + $server->on('beforeWriteContent', [$this, 'beforeWriteContent']); + $server->on('beforeCreateFile', [$this, 'beforeCreateFile']); + $server->on('afterMethod:GET', [$this, 'httpAfterGet']); + + $server->xml->namespaceMap[self::NS_CARDDAV] = 'card'; + + $server->xml->elementMap['{' . self::NS_CARDDAV . '}addressbook-query'] = 'Sabre\\CardDAV\\Xml\\Request\\AddressBookQueryReport'; + $server->xml->elementMap['{' . self::NS_CARDDAV . '}addressbook-multiget'] = 'Sabre\\CardDAV\\Xml\\Request\\AddressBookMultiGetReport'; + + /* Mapping Interfaces to {DAV:}resourcetype values */ + $server->resourceTypeMapping['Sabre\\CardDAV\\IAddressBook'] = '{' . self::NS_CARDDAV . '}addressbook'; + $server->resourceTypeMapping['Sabre\\CardDAV\\IDirectory'] = '{' . self::NS_CARDDAV . '}directory'; + + /* Adding properties that may never be changed */ + $server->protectedProperties[] = '{' . self::NS_CARDDAV . '}supported-address-data'; + $server->protectedProperties[] = '{' . self::NS_CARDDAV . '}max-resource-size'; + $server->protectedProperties[] = '{' . self::NS_CARDDAV . '}addressbook-home-set'; + $server->protectedProperties[] = '{' . self::NS_CARDDAV . '}supported-collation-set'; + + $server->xml->elementMap['{http://calendarserver.org/ns/}me-card'] = 'Sabre\\DAV\\Xml\\Property\\Href'; + + $this->server = $server; + + } + + /** + * Returns a list of supported features. + * + * This is used in the DAV: header in the OPTIONS and PROPFIND requests. + * + * @return array + */ + function getFeatures() { + + return ['addressbook']; + + } + + /** + * Returns a list of reports this plugin supports. + * + * This will be used in the {DAV:}supported-report-set property. + * Note that you still need to subscribe to the 'report' event to actually + * implement them + * + * @param string $uri + * @return array + */ + function getSupportedReportSet($uri) { + + $node = $this->server->tree->getNodeForPath($uri); + if ($node instanceof IAddressBook || $node instanceof ICard) { + return [ + '{' . self::NS_CARDDAV . '}addressbook-multiget', + '{' . self::NS_CARDDAV . '}addressbook-query', + ]; + } + return []; + + } + + + /** + * Adds all CardDAV-specific properties + * + * @param DAV\PropFind $propFind + * @param DAV\INode $node + * @return void + */ + function propFindEarly(DAV\PropFind $propFind, DAV\INode $node) { + + $ns = '{' . self::NS_CARDDAV . '}'; + + if ($node instanceof IAddressBook) { + + $propFind->handle($ns . 'max-resource-size', $this->maxResourceSize); + $propFind->handle($ns . 'supported-address-data', function() { + return new Xml\Property\SupportedAddressData(); + }); + $propFind->handle($ns . 'supported-collation-set', function() { + return new Xml\Property\SupportedCollationSet(); + }); + + } + if ($node instanceof DAVACL\IPrincipal) { + + $path = $propFind->getPath(); + + $propFind->handle('{' . self::NS_CARDDAV . '}addressbook-home-set', function() use ($path) { + return new Href($this->getAddressBookHomeForPrincipal($path) . '/'); + }); + + if ($this->directories) $propFind->handle('{' . self::NS_CARDDAV . '}directory-gateway', function() { + return new Href($this->directories); + }); + + } + + if ($node instanceof ICard) { + + // The address-data property is not supposed to be a 'real' + // property, but in large chunks of the spec it does act as such. + // Therefore we simply expose it as a property. + $propFind->handle('{' . self::NS_CARDDAV . '}address-data', function() use ($node) { + $val = $node->get(); + if (is_resource($val)) + $val = stream_get_contents($val); + + return $val; + + }); + + } + + } + + /** + * This functions handles REPORT requests specific to CardDAV + * + * @param string $reportName + * @param \DOMNode $dom + * @param mixed $path + * @return bool + */ + function report($reportName, $dom, $path) { + + switch ($reportName) { + case '{' . self::NS_CARDDAV . '}addressbook-multiget' : + $this->server->transactionType = 'report-addressbook-multiget'; + $this->addressbookMultiGetReport($dom); + return false; + case '{' . self::NS_CARDDAV . '}addressbook-query' : + $this->server->transactionType = 'report-addressbook-query'; + $this->addressBookQueryReport($dom); + return false; + default : + return; + + } + + + } + + /** + * Returns the addressbook home for a given principal + * + * @param string $principal + * @return string + */ + protected function getAddressbookHomeForPrincipal($principal) { + + list(, $principalId) = \Sabre\HTTP\URLUtil::splitPath($principal); + return self::ADDRESSBOOK_ROOT . '/' . $principalId; + + } + + + /** + * This function handles the addressbook-multiget REPORT. + * + * This report is used by the client to fetch the content of a series + * of urls. Effectively avoiding a lot of redundant requests. + * + * @param Xml\Request\AddressBookMultiGetReport $report + * @return void + */ + function addressbookMultiGetReport($report) { + + $contentType = $report->contentType; + $version = $report->version; + if ($version) { + $contentType .= '; version=' . $version; + } + + $vcardType = $this->negotiateVCard( + $contentType + ); + + $propertyList = []; + $paths = array_map( + [$this->server, 'calculateUri'], + $report->hrefs + ); + foreach ($this->server->getPropertiesForMultiplePaths($paths, $report->properties) as $props) { + + if (isset($props['200']['{' . self::NS_CARDDAV . '}address-data'])) { + + $props['200']['{' . self::NS_CARDDAV . '}address-data'] = $this->convertVCard( + $props[200]['{' . self::NS_CARDDAV . '}address-data'], + $vcardType + ); + + } + $propertyList[] = $props; + + } + + $prefer = $this->server->getHTTPPrefer(); + + $this->server->httpResponse->setStatus(207); + $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); + $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer'); + $this->server->httpResponse->setBody($this->server->generateMultiStatus($propertyList, $prefer['return'] === 'minimal')); + + } + + /** + * This method is triggered before a file gets updated with new content. + * + * This plugin uses this method to ensure that Card nodes receive valid + * vcard data. + * + * @param string $path + * @param DAV\IFile $node + * @param resource $data + * @param bool $modified Should be set to true, if this event handler + * changed &$data. + * @return void + */ + function beforeWriteContent($path, DAV\IFile $node, &$data, &$modified) { + + if (!$node instanceof ICard) + return; + + $this->validateVCard($data, $modified); + + } + + /** + * This method is triggered before a new file is created. + * + * This plugin uses this method to ensure that Card nodes receive valid + * vcard data. + * + * @param string $path + * @param resource $data + * @param DAV\ICollection $parentNode + * @param bool $modified Should be set to true, if this event handler + * changed &$data. + * @return void + */ + function beforeCreateFile($path, &$data, DAV\ICollection $parentNode, &$modified) { + + if (!$parentNode instanceof IAddressBook) + return; + + $this->validateVCard($data, $modified); + + } + + /** + * Checks if the submitted iCalendar data is in fact, valid. + * + * An exception is thrown if it's not. + * + * @param resource|string $data + * @param bool $modified Should be set to true, if this event handler + * changed &$data. + * @return void + */ + protected function validateVCard(&$data, &$modified) { + + // If it's a stream, we convert it to a string first. + if (is_resource($data)) { + $data = stream_get_contents($data); + } + + $before = md5($data); + + // Converting the data to unicode, if needed. + $data = DAV\StringUtil::ensureUTF8($data); + + if (md5($data) !== $before) $modified = true; + + try { + + // If the data starts with a [, we can reasonably assume we're dealing + // with a jCal object. + if (substr($data, 0, 1) === '[') { + $vobj = VObject\Reader::readJson($data); + + // Converting $data back to iCalendar, as that's what we + // technically support everywhere. + $data = $vobj->serialize(); + $modified = true; + } else { + $vobj = VObject\Reader::read($data); + } + + } catch (VObject\ParseException $e) { + + throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid vCard or jCard data. Parse error: ' . $e->getMessage()); + + } + + if ($vobj->name !== 'VCARD') { + throw new DAV\Exception\UnsupportedMediaType('This collection can only support vcard objects.'); + } + + if (!isset($vobj->UID)) { + // No UID in vcards is invalid, but we'll just add it in anyway. + $vobj->add('UID', DAV\UUIDUtil::getUUID()); + $data = $vobj->serialize(); + $modified = true; + } + + // Destroy circular references to PHP will GC the object. + $vobj->destroy(); + } + + + /** + * This function handles the addressbook-query REPORT + * + * This report is used by the client to filter an addressbook based on a + * complex query. + * + * @param Xml\Request\AddressBookQueryReport $report + * @return void + */ + protected function addressbookQueryReport($report) { + + $depth = $this->server->getHTTPDepth(0); + + if ($depth == 0) { + $candidateNodes = [ + $this->server->tree->getNodeForPath($this->server->getRequestUri()) + ]; + if (!$candidateNodes[0] instanceof ICard) { + throw new ReportNotSupported('The addressbook-query report is not supported on this url with Depth: 0'); + } + } else { + $candidateNodes = $this->server->tree->getChildren($this->server->getRequestUri()); + } + + $contentType = $report->contentType; + if ($report->version) { + $contentType .= '; version=' . $report->version; + } + + $vcardType = $this->negotiateVCard( + $contentType + ); + + $validNodes = []; + foreach ($candidateNodes as $node) { + + if (!$node instanceof ICard) + continue; + + $blob = $node->get(); + if (is_resource($blob)) { + $blob = stream_get_contents($blob); + } + + if (!$this->validateFilters($blob, $report->filters, $report->test)) { + continue; + } + + $validNodes[] = $node; + + if ($report->limit && $report->limit <= count($validNodes)) { + // We hit the maximum number of items, we can stop now. + break; + } + + } + + $result = []; + foreach ($validNodes as $validNode) { + + if ($depth == 0) { + $href = $this->server->getRequestUri(); + } else { + $href = $this->server->getRequestUri() . '/' . $validNode->getName(); + } + + list($props) = $this->server->getPropertiesForPath($href, $report->properties, 0); + + if (isset($props[200]['{' . self::NS_CARDDAV . '}address-data'])) { + + $props[200]['{' . self::NS_CARDDAV . '}address-data'] = $this->convertVCard( + $props[200]['{' . self::NS_CARDDAV . '}address-data'], + $vcardType + ); + + } + $result[] = $props; + + } + + $prefer = $this->server->getHTTPPrefer(); + + $this->server->httpResponse->setStatus(207); + $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); + $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer'); + $this->server->httpResponse->setBody($this->server->generateMultiStatus($result, $prefer['return'] === 'minimal')); + + } + + /** + * Validates if a vcard makes it throught a list of filters. + * + * @param string $vcardData + * @param array $filters + * @param string $test anyof or allof (which means OR or AND) + * @return bool + */ + function validateFilters($vcardData, array $filters, $test) { + + + if (!$filters) return true; + $vcard = VObject\Reader::read($vcardData); + + foreach ($filters as $filter) { + + $isDefined = isset($vcard->{$filter['name']}); + if ($filter['is-not-defined']) { + if ($isDefined) { + $success = false; + } else { + $success = true; + } + } elseif ((!$filter['param-filters'] && !$filter['text-matches']) || !$isDefined) { + + // We only need to check for existence + $success = $isDefined; + + } else { + + $vProperties = $vcard->select($filter['name']); + + $results = []; + if ($filter['param-filters']) { + $results[] = $this->validateParamFilters($vProperties, $filter['param-filters'], $filter['test']); + } + if ($filter['text-matches']) { + $texts = []; + foreach ($vProperties as $vProperty) + $texts[] = $vProperty->getValue(); + + $results[] = $this->validateTextMatches($texts, $filter['text-matches'], $filter['test']); + } + + if (count($results) === 1) { + $success = $results[0]; + } else { + if ($filter['test'] === 'anyof') { + $success = $results[0] || $results[1]; + } else { + $success = $results[0] && $results[1]; + } + } + + } // else + + // There are two conditions where we can already determine whether + // or not this filter succeeds. + if ($test === 'anyof' && $success) { + + // Destroy circular references to PHP will GC the object. + $vcard->destroy(); + + return true; + } + if ($test === 'allof' && !$success) { + + // Destroy circular references to PHP will GC the object. + $vcard->destroy(); + + return false; + } + + } // foreach + + + // Destroy circular references to PHP will GC the object. + $vcard->destroy(); + + // If we got all the way here, it means we haven't been able to + // determine early if the test failed or not. + // + // This implies for 'anyof' that the test failed, and for 'allof' that + // we succeeded. Sounds weird, but makes sense. + return $test === 'allof'; + + } + + /** + * Validates if a param-filter can be applied to a specific property. + * + * @todo currently we're only validating the first parameter of the passed + * property. Any subsequence parameters with the same name are + * ignored. + * @param array $vProperties + * @param array $filters + * @param string $test + * @return bool + */ + protected function validateParamFilters(array $vProperties, array $filters, $test) { + + foreach ($filters as $filter) { + + $isDefined = false; + foreach ($vProperties as $vProperty) { + $isDefined = isset($vProperty[$filter['name']]); + if ($isDefined) break; + } + + if ($filter['is-not-defined']) { + if ($isDefined) { + $success = false; + } else { + $success = true; + } + + // If there's no text-match, we can just check for existence + } elseif (!$filter['text-match'] || !$isDefined) { + + $success = $isDefined; + + } else { + + $success = false; + foreach ($vProperties as $vProperty) { + // If we got all the way here, we'll need to validate the + // text-match filter. + $success = DAV\StringUtil::textMatch($vProperty[$filter['name']]->getValue(), $filter['text-match']['value'], $filter['text-match']['collation'], $filter['text-match']['match-type']); + if ($success) break; + } + if ($filter['text-match']['negate-condition']) { + $success = !$success; + } + + } // else + + // There are two conditions where we can already determine whether + // or not this filter succeeds. + if ($test === 'anyof' && $success) { + return true; + } + if ($test === 'allof' && !$success) { + return false; + } + + } + + // If we got all the way here, it means we haven't been able to + // determine early if the test failed or not. + // + // This implies for 'anyof' that the test failed, and for 'allof' that + // we succeeded. Sounds weird, but makes sense. + return $test === 'allof'; + + } + + /** + * Validates if a text-filter can be applied to a specific property. + * + * @param array $texts + * @param array $filters + * @param string $test + * @return bool + */ + protected function validateTextMatches(array $texts, array $filters, $test) { + + foreach ($filters as $filter) { + + $success = false; + foreach ($texts as $haystack) { + $success = DAV\StringUtil::textMatch($haystack, $filter['value'], $filter['collation'], $filter['match-type']); + + // Breaking on the first match + if ($success) break; + } + if ($filter['negate-condition']) { + $success = !$success; + } + + if ($success && $test === 'anyof') + return true; + + if (!$success && $test == 'allof') + return false; + + + } + + // If we got all the way here, it means we haven't been able to + // determine early if the test failed or not. + // + // This implies for 'anyof' that the test failed, and for 'allof' that + // we succeeded. Sounds weird, but makes sense. + return $test === 'allof'; + + } + + /** + * This event is triggered when fetching properties. + * + * This event is scheduled late in the process, after most work for + * propfind has been done. + * + * @param DAV\PropFind $propFind + * @param DAV\INode $node + * @return void + */ + function propFindLate(DAV\PropFind $propFind, DAV\INode $node) { + + // If the request was made using the SOGO connector, we must rewrite + // the content-type property. By default SabreDAV will send back + // text/x-vcard; charset=utf-8, but for SOGO we must strip that last + // part. + if (strpos($this->server->httpRequest->getHeader('User-Agent'), 'Thunderbird') === false) { + return; + } + $contentType = $propFind->get('{DAV:}getcontenttype'); + list($part) = explode(';', $contentType); + if ($part === 'text/x-vcard' || $part === 'text/vcard') { + $propFind->set('{DAV:}getcontenttype', 'text/x-vcard'); + } + + } + + /** + * This method is used to generate HTML output for the + * Sabre\DAV\Browser\Plugin. This allows us to generate an interface users + * can use to create new addressbooks. + * + * @param DAV\INode $node + * @param string $output + * @return bool + */ + function htmlActionsPanel(DAV\INode $node, &$output) { + + if (!$node instanceof AddressBookHome) + return; + + $output .= '<tr><td colspan="2"><form method="post" action=""> + <h3>Create new address book</h3> + <input type="hidden" name="sabreAction" value="mkcol" /> + <input type="hidden" name="resourceType" value="{DAV:}collection,{' . self::NS_CARDDAV . '}addressbook" /> + <label>Name (uri):</label> <input type="text" name="name" /><br /> + <label>Display name:</label> <input type="text" name="{DAV:}displayname" /><br /> + <input type="submit" value="create" /> + </form> + </td></tr>'; + + return false; + + } + + /** + * This event is triggered after GET requests. + * + * This is used to transform data into jCal, if this was requested. + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return void + */ + function httpAfterGet(RequestInterface $request, ResponseInterface $response) { + + if (strpos($response->getHeader('Content-Type'), 'text/vcard') === false) { + return; + } + + $target = $this->negotiateVCard($request->getHeader('Accept'), $mimeType); + + $newBody = $this->convertVCard( + $response->getBody(), + $target + ); + + $response->setBody($newBody); + $response->setHeader('Content-Type', $mimeType . '; charset=utf-8'); + $response->setHeader('Content-Length', strlen($newBody)); + + } + + /** + * This helper function performs the content-type negotiation for vcards. + * + * It will return one of the following strings: + * 1. vcard3 + * 2. vcard4 + * 3. jcard + * + * It defaults to vcard3. + * + * @param string $input + * @param string $mimeType + * @return string + */ + protected function negotiateVCard($input, &$mimeType = null) { + + $result = HTTP\Util::negotiate( + $input, + [ + // Most often used mime-type. Version 3 + 'text/x-vcard', + // The correct standard mime-type. Defaults to version 3 as + // well. + 'text/vcard', + // vCard 4 + 'text/vcard; version=4.0', + // vCard 3 + 'text/vcard; version=3.0', + // jCard + 'application/vcard+json', + ] + ); + + $mimeType = $result; + switch ($result) { + + default : + case 'text/x-vcard' : + case 'text/vcard' : + case 'text/vcard; version=3.0' : + $mimeType = 'text/vcard'; + return 'vcard3'; + case 'text/vcard; version=4.0' : + return 'vcard4'; + case 'application/vcard+json' : + return 'jcard'; + + // @codeCoverageIgnoreStart + } + // @codeCoverageIgnoreEnd + + } + + /** + * Converts a vcard blob to a different version, or jcard. + * + * @param string $data + * @param string $target + * @return string + */ + protected function convertVCard($data, $target) { + + $data = VObject\Reader::read($data); + switch ($target) { + default : + case 'vcard3' : + $data = $data->convert(VObject\Document::VCARD30); + $newResult = $data->serialize(); + break; + case 'vcard4' : + $data = $data->convert(VObject\Document::VCARD40); + $newResult = $data->serialize(); + break; + case 'jcard' : + $data = $data->convert(VObject\Document::VCARD40); + $newResult = json_encode($data->jsonSerialize()); + break; + + } + // Destroy circular references to PHP will GC the object. + $data->destroy(); + + return $newResult; + + } + + /** + * Returns a plugin name. + * + * Using this name other plugins will be able to access other plugins + * using DAV\Server::getPlugin + * + * @return string + */ + function getPluginName() { + + return 'carddav'; + + } + + /** + * Returns a bunch of meta-data about the plugin. + * + * Providing this information is optional, and is mainly displayed by the + * Browser plugin. + * + * The description key in the returned array may contain html and will not + * be sanitized. + * + * @return array + */ + function getPluginInfo() { + + return [ + 'name' => $this->getPluginName(), + 'description' => 'Adds support for CardDAV (rfc6352)', + 'link' => 'http://sabre.io/dav/carddav/', + ]; + + } + +} |