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 */ public 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 */ public 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. */ public 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 LocalHref($this->getAddressBookHomeForPrincipal($path).'/'); }); if ($this->directories) { $propFind->handle('{'.self::NS_CARDDAV.'}directory-gateway', function () { return new LocalHref($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 */ public 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) = Uri\split($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 */ public 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, 'minimal' === $prefer['return'])); } /** * 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 resource $data * @param bool $modified should be set to true, if this event handler * changed &$data */ public 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 bool $modified should be set to true, if this event handler * changed &$data */ public 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 */ 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 = $data; 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 ('VCARD' !== $vobj->name) { throw new DAV\Exception\UnsupportedMediaType('This collection can only support vcard objects.'); } $options = VObject\Node::PROFILE_CARDDAV; $prefer = $this->server->getHTTPPrefer(); if ('strict' !== $prefer['handling']) { $options |= VObject\Node::REPAIR; } $messages = $vobj->validate($options); $highestLevel = 0; $warningMessage = null; // $messages contains a list of problems with the vcard, along with // their severity. foreach ($messages as $message) { if ($message['level'] > $highestLevel) { // Recording the highest reported error level. $highestLevel = $message['level']; $warningMessage = $message['message']; } switch ($message['level']) { case 1: // Level 1 means that there was a problem, but it was repaired. $modified = true; break; case 2: // Level 2 means a warning, but not critical break; case 3: // Level 3 means a critical error throw new DAV\Exception\UnsupportedMediaType('Validation error in vCard: '.$message['message']); } } if ($warningMessage) { $this->server->httpResponse->setHeader( 'X-Sabre-Ew-Gross', 'vCard validation warning: '.$warningMessage ); // Re-serializing object. $data = $vobj->serialize(); if (!$modified && 0 !== strcmp($data, $before)) { // This ensures that the system does not send an ETag back. $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 */ protected function addressbookQueryReport($report) { $depth = $this->server->getHTTPDepth(0); if (0 == $depth) { $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 (0 == $depth) { $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, $report->addressDataProperties ); } $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, 'minimal' === $prefer['return'])); } /** * Validates if a vcard makes it throught a list of filters. * * @param string $vcardData * @param string $test anyof or allof (which means OR or AND) * * @return bool */ public 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 (1 === count($results)) { $success = $results[0]; } else { if ('anyof' === $filter['test']) { $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 ('anyof' === $test && $success) { // Destroy circular references to PHP will GC the object. $vcard->destroy(); return true; } if ('allof' === $test && !$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 'allof' === $test; } /** * 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 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. if (isset($vProperty[$filter['name']])) { $success = DAV\StringUtil::textMatch( $vProperty[$filter['name']]->getValue(), $filter['text-match']['value'], $filter['text-match']['collation'], $filter['text-match']['match-type'] ); if ($filter['text-match']['negate-condition']) { $success = !$success; } } if ($success) { break; } } } // else // There are two conditions where we can already determine whether // or not this filter succeeds. if ('anyof' === $test && $success) { return true; } if ('allof' === $test && !$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 'allof' === $test; } /** * Validates if a text-filter can be applied to a specific property. * * @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']); if ($filter['negate-condition']) { $success = !$success; } // Breaking on the first match if ($success) { break; } } if ($success && 'anyof' === $test) { return true; } if (!$success && 'allof' == $test) { 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 'allof' === $test; } /** * This event is triggered when fetching properties. * * This event is scheduled late in the process, after most work for * propfind has been done. */ public 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 (false === strpos((string) $this->server->httpRequest->getHeader('User-Agent'), 'Thunderbird')) { return; } $contentType = $propFind->get('{DAV:}getcontenttype'); if (null !== $contentType) { list($part) = explode(';', $contentType); if ('text/x-vcard' === $part || 'text/vcard' === $part) { $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 string $output * * @return bool */ public function htmlActionsPanel(DAV\INode $node, &$output) { if (!$node instanceof AddressBookHome) { return; } $output .= '

Create new address book



'; return false; } /** * This event is triggered after GET requests. * * This is used to transform data into jCal, if this was requested. */ public function httpAfterGet(RequestInterface $request, ResponseInterface $response) { $contentType = $response->getHeader('Content-Type'); if (null === $contentType || false === strpos($contentType, 'text/vcard')) { 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\negotiateContentType( $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|resource $data * @param string $target * @param array $propertiesFilter * * @return string */ protected function convertVCard($data, $target, array $propertiesFilter = null) { if (is_resource($data)) { $data = stream_get_contents($data); } $input = VObject\Reader::read($data); if (!empty($propertiesFilter)) { $propertiesFilter = array_merge(['UID', 'VERSION', 'FN'], $propertiesFilter); $keys = array_unique(array_map(function ($child) { return $child->name; }, $input->children())); $keys = array_diff($keys, $propertiesFilter); foreach ($keys as $key) { unset($input->$key); } $data = $input->serialize(); } $output = null; try { switch ($target) { default: case 'vcard3': if (VObject\Document::VCARD30 === $input->getDocumentType()) { // Do nothing return $data; } $output = $input->convert(VObject\Document::VCARD30); return $output->serialize(); case 'vcard4': if (VObject\Document::VCARD40 === $input->getDocumentType()) { // Do nothing return $data; } $output = $input->convert(VObject\Document::VCARD40); return $output->serialize(); case 'jcard': $output = $input->convert(VObject\Document::VCARD40); return json_encode($output); } } finally { // Destroy circular references to PHP will GC the object. $input->destroy(); if (!is_null($output)) { $output->destroy(); } } } /** * Returns a plugin name. * * Using this name other plugins will be able to access other plugins * using DAV\Server::getPlugin * * @return string */ public 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 */ public function getPluginInfo() { return [ 'name' => $this->getPluginName(), 'description' => 'Adds support for CardDAV (rfc6352)', 'link' => 'http://sabre.io/dav/carddav/', ]; } }