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 .= '