diff --git a/lib/Kolab/CardDAV/LDAPDirectory.php b/lib/Kolab/CardDAV/LDAPDirectory.php index ac7137b..022eb6a 100644 --- a/lib/Kolab/CardDAV/LDAPDirectory.php +++ b/lib/Kolab/CardDAV/LDAPDirectory.php @@ -1,579 +1,581 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ namespace Kolab\CardDAV; use \rcube; use \rcube_ldap; use \rcube_ldap_generic; use Sabre\DAV; use Sabre\DAVACL; use Sabre\CardDAV; use Kolab\Utils\VObjectUtils; /** * CardDAV Directory Gateway implementation */ class LDAPDirectory extends DAV\Collection implements \Sabre\CardDAV\IDirectory, DAV\IProperties, DAVACL\IACL { const DIRECTORY_NAME = 'ldap-directory'; protected $config; protected $ldap; protected $carddavBackend; protected $principalUri; protected $addressBookInfo = array(); protected $cache; - protected $query; + protected CardDAV\Xml\Request\AddressBookQueryReport $query; protected $filter; /** * Default constructor */ function __construct($config, $principalUri, $carddavBackend = null) { $this->config = $config; $this->principalUri = $principalUri; $this->addressBookInfo = array( 'id' => self::DIRECTORY_NAME, 'uri' => self::DIRECTORY_NAME, '{DAV:}displayname' => $config['name'] ?: "LDAP Directory", '{urn:ietf:params:xml:ns:carddav}supported-address-data' => new CardDAV\Xml\Property\SupportedAddressData(), 'principaluri' => $principalUri, ); // used for vcard serialization $this->carddavBackend = $carddavBackend ?: new ContactsBackend(); $this->carddavBackend->ldap_directory = $this; // initialize cache $rcube = rcube::get_instance(); if ($rcube->config->get('kolabdav_ldap_cache')) { $this->cache = $rcube->get_cache_shared('kolabdav_ldap'); // expunge cache every now and then if (rand(0,10) === 0) { $this->cache->expunge(); } } } private function connect() { if (!isset($this->ldap)) { $this->ldap = new rcube_ldap($this->config, $this->config['debug']); $this->ldap->set_pagesize($this->config['sizelimit'] ?: 10000); } return $this->ldap->ready ? $this->ldap : null; } /** * Set parsed addressbook-query object for filtering + * @param Sabre\CardDAV\Xml\Request\AddressBookQueryReport $report */ - function setAddressbookQuery($query) + function setAddressbookQuery($report) { - $this->query = $query; - $this->filter = $this->addressbook_query2ldap_filter($query); + $this->query = $report; + $this->filter = $this->addressbook_query2ldap_filter($report); } /** * Returns the name of the node. * * This is used to generate the url. * * @return string */ function getName() { return self::DIRECTORY_NAME; } /** * Returns a specific child node, referenced by its name * * This method must throw Sabre\DAV\Exception\NotFound if the node does not * exist. * * @param string $name * @return DAV\INode */ function getChild($cardUri) { console(__METHOD__, $cardUri); $uid = VObjectUtils::uri2uid($cardUri, '.vcf'); $record = null; // get from cache $cache_key = $uid; if ($this->cache && ($cached = $this->cache->get($cache_key))) { return new LDAPCard($this->carddavBackend, $this->addressBookInfo, $cached); } if ($contact = $this->getContactObject($uid)) { $obj = array( 'id' => $contact['uid'], 'uri' => VObjectUtils::uid2uri($contact['uid'], '.vcf'), 'lastmodified' => $contact['_timestamp'], 'carddata' => $this->carddavBackend->to_vcard($contact), 'etag' => self::_get_etag($contact), ); // cache this object if ($this->cache) { $this->cache->set($cache_key, $obj); } return new LDAPCard($this->carddavBackend, $this->addressBookInfo, $obj); } throw new DAV\Exception\NotFound('Card not found'); } /** * Read contact object from LDAP */ function getContactObject($uid) { $contact = null; if ($ldap = $this->connect()) { $ldap->reset(); // used cached uid mapping $cached_index = $this->cache ? $this->cache->get('index') : array(); if ($cached_index[$uid]) { $contact = $ldap->get_record($cached_index[$uid][0], true); } else { // query for uid $result = $ldap->search('uid', $uid, 1, true, true); if ($result->count) { $contact = $result[0]; } } if ($contact) { $this->_normalize_contact($contact); } } return $contact; } /** * Returns an array with all the child nodes * * @return DAV\INode[] */ function getChildren() { console(__METHOD__, $this->query, $this->filter); $children = array(); // return cached index if (!$this->query && !$this->config['searchonly'] && $this->cache && ($cached_index = $this->cache->get('index'))) { foreach ($cached_index as $uid => $c) { $obj = array( 'id' => $uid, 'uri' => VObjectUtils::uid2uri($uid, '.vcf'), 'etag' => $c[1], 'lastmodified' => $c[2], ); $children[] = new LDAPCard($this->carddavBackend, $this->addressBookInfo, $obj); } return $children; } // query LDAP if we have a search query or listing is allowed if (($this->query || !$this->config['searchonly']) && ($ldap = $this->connect())) { // set pagesize from query limit attribute if ($this->query && $this->query->limit) { $this->ldap->set_pagesize(intval($this->query->limit)); } // set the prepared LDAP filter derived from the addressbook-query if ($this->query && !empty($this->filter)) { $ldap->set_search_set($this->filter); } else { $ldap->set_search_set(null); } $results = $ldap->list_records(null); $directory_index = array(); // convert results into vcard blocks foreach ($results as $contact) { $this->_normalize_contact($contact); $obj = array( 'id' => $contact['uid'], 'uri' => VObjectUtils::uid2uri($contact['uid'], '.vcf'), 'lastmodified' => $contact['_timestamp'], 'carddata' => $this->carddavBackend->to_vcard($contact), 'etag' => self::_get_etag($contact), ); // cache record $cache_key = $contact['uid']; if ($this->cache) { $this->cache->set($cache_key, $obj); } $directory_index[$contact['uid']] = array($contact['ID'], $obj['etag'], $contact['_timestamp']); // add CardDAV node $children[] = new LDAPCard($this->carddavBackend, $this->addressBookInfo, $obj); } // cache the full listing if (empty($this->filter) && $this->cache) { $this->cache->set('index', $directory_index); } } return $children; } /** * Returns a list of properties for this node. * * The properties list is a list of propertynames the client requested, * encoded in clark-notation {xmlnamespace}tagname * * If the array is empty, it means 'all properties' were requested. * * @param array $properties * @return array */ public function getProperties($properties) { console(__METHOD__, $properties); $response = array(); foreach ($properties as $propertyName) { if (isset($this->addressBookInfo[$propertyName])) { $response[$propertyName] = $this->addressBookInfo[$propertyName]; } else if ($propertyName == '{DAV:}getlastmodified') { $response[$propertyName] = new DAV\Xml\Property\GetLastModified($this->getLastModified()); } } return $response; } /** * Returns the last modification time, as a unix timestamp * * @return int */ function getLastModified() { console(__METHOD__); return time(); } /** * Deletes the entire addressbook. * * @return void */ public function delete() { throw new DAV\Exception\MethodNotAllowed('Deleting directories is not allowed'); } /** * Renames the addressbook * * @param string $newName * @return void */ public function setName($newName) { throw new DAV\Exception\MethodNotAllowed('Renaming directories not allowed'); } /** * Returns the owner principal * * This must be a url to a principal, or null if there's no owner * * @return string|null */ public function getOwner() { return $this->principalUri; } /** * Returns a group principal * * This must be a url to a principal, or null if there's no owner * * @return string|null */ function getGroup() { return null; } /** * Returns a list of ACE's for this node. * * Each ACE has the following properties: * * 'privilege', a string such as {DAV:}read or {DAV:}write * * 'principal', a url to the principal who owns the node * * 'protected' (optional), indicating that this ACE is not allowed to be updated. * * @return array */ public function getACL() { $acl = array( array( 'privilege' => '{DAV:}read', 'principal' => $this->principalUri, 'protected' => true, ), ); return $acl; } /** * Updates the ACL * * @param array $acl * @return void */ function setACL(array $acl) { throw new DAV\Exception\MethodNotAllowed('Changing ACL for directories is not allowed'); } /** * Returns the list of supported privileges for this node. * * If null is returned from this method, the default privilege set is used, * which is fine for most common usecases. * * @return array|null */ function getSupportedPrivilegeSet() { return null; } /** * Updates properties on this node. * * @param PropPatch $propPatch * @return void */ public function propPatch(DAV\PropPatch $propPatch) { console(__METHOD__, $propPatch); // NOP } /** * Post-process the given contact record from rcube_ldap */ private function _normalize_contact(&$contact) { if (is_numeric($contact['changed'])) { $contact['_timestamp'] = intval($contact['changed']); $contact['changed'] = new \DateTime('@' . $contact['changed']); } else if (!empty($contact['changed'])) { try { // 2018 05 14 06 22 31 .0Z 2018-05-14T06:22:31 $contact['changed'] = preg_replace('/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.0Z$/', '$1-$2-$3T$4:$5:$6', $contact['changed']); $contact['changed'] = new \DateTime($contact['changed']); $contact['_timestamp'] = intval($contact['changed']->format('U')); } catch (\Exception $e) { $contact['changed'] = null; } } // map col:subtype fields to a list that the vcard serialization function understands foreach (array('email' => 'address', 'phone' => 'number', 'website' => 'url') as $col => $prop) { foreach (rcube_ldap::get_col_values($col, $contact) as $type => $values) { foreach ((array)$values as $value) { $contact[$col][] = array($prop => $value, 'type' => $type); } } unset($contact[$col.':'.$type]); } $addresses = array(); foreach (rcube_ldap::get_col_values('address', $contact) as $type => $values) { foreach ((array)$values as $adr) { // skip empty address $adr = array_filter($adr); if (empty($adr)) continue; $addresses[] = array( 'type' => $type, 'street' => $adr['street'], 'locality' => $adr['locality'], 'code' => $adr['zipcode'], 'region' => $adr['region'], 'country' => $adr['country'], ); } unset($contact['address:'.$type]); } $contact['address'] = $addresses; } /** * Translate the given AddressBookQueryParser object into an LDAP filter + * @param Sabre\CardDAV\Xml\Request\AddressBookQueryReport $report */ private function addressbook_query2ldap_filter($query) { $criterias = array(); foreach ($query->filters as $filter) { $ldap_attrs = $this->map_property2ldap($filter['name']); $ldap_filter = ''; $count = 0; // unknown attribute, skip if (empty($ldap_attrs)) { continue; } foreach ((array)$filter['text-matches'] as $matcher) { // case-insensitive matching if (in_array($matcher['collation'], array('i;unicode-casemap', 'i;ascii-casemap'))) { $matcher['value'] = mb_strtolower($matcher['value']); } $value = rcube_ldap_generic::quote_string($matcher['value']); $ldap_match = ''; // this assumes fuzzy search capabilities of the LDAP backend switch ($matcher['match-type']) { case 'contains': $wp = $ws = '*'; break; case 'starts-with': $ws = '*'; break; case 'ends-with': $wp = '*'; break; default: $wp = $ws = ''; } // OR query for all attributes involved if (count($ldap_attrs) > 1) { $ldap_match .= '(|'; } foreach ($ldap_attrs as $attr) { $ldap_match .= "($attr=$wp$value$ws)"; } if (count($ldap_attrs) > 1) { $ldap_match .= ')'; } // negate the filter if ($matcher['negate-condition']) { $ldap_match = '(!' . $ldap_match . ')'; } $ldap_filter .= $ldap_match; $count++; } if ($count > 1) { $criterias[] = '(' . ($filter['test'] == 'allof' ? '&' : '|') . $ldap_filter . ')'; } else if (!empty($ldap_filter)) { $criterias[] = $ldap_filter; } } return empty($criterias) ? '' : sprintf('(%s%s)', $query->test == 'allof' ? '&' : '|', join('', $criterias)); } /** * Map a vcard property to an LDAP attribute */ private function map_property2ldap($propname) { $attribs = array(); // LDAP backend not available, abort if (!($ldap = $this->connect())) { return $attribs; } $vcard_fieldmap = array( 'FN' => array('name'), 'N' => array('surname','firstname','middlename'), 'ADR' => array('street','locality','region','code','country'), 'TITLE' => array('jobtitle'), 'ORG' => array('organization','department'), 'TEL' => array('phone'), 'URL' => array('website'), 'ROLE' => array('profession'), 'BDAY' => array('birthday'), 'IMPP' => array('im'), ); $fields = $vcard_fieldmap[$propname] ?: array(strtolower($propname)); foreach ($fields as $field) { if ($ldap->coltypes[$field]) { $attribs = array_merge($attribs, (array)$ldap->coltypes[$field]['attributes']); } } return $attribs; } /** * Generate an Etag string from the given contact data * * @param array Hash array with contact properties from libkolab * @return string Etag string */ private static function _get_etag($contact) { return sprintf('"%s-%d"', substr(md5($contact['uid']), 0, 16), $contact['_timestamp']); } } diff --git a/lib/Kolab/CardDAV/Plugin.php b/lib/Kolab/CardDAV/Plugin.php index 349be2a..c34c96f 100644 --- a/lib/Kolab/CardDAV/Plugin.php +++ b/lib/Kolab/CardDAV/Plugin.php @@ -1,264 +1,264 @@ * * Copyright (C) 2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ namespace Kolab\CardDAV; use Sabre\DAV; use Sabre\DAVACL; use Sabre\CardDAV; use Sabre\VObject; use Kolab\DAV\Auth\HTTPBasic; /** * Extended CardDAV plugin to tweak data validation */ class Plugin extends CardDAV\Plugin { // make already parsed vcard blocks available for later use public static $parsed_vcard; // allow the backend to force a redirect Location public static $redirect_basename; // vcard version requested by the connecting client public static $vcard_version = 'vcard3'; /** * Initializes the plugin * * @param DAV\Server $server * @return void */ public function initialize(DAV\Server $server) { parent::initialize($server); $server->on('beforeMethod', array($this, 'beforeMethod'), 0); $server->on('afterCreateFile', array($this, 'afterWriteContent')); $server->on('afterWriteContent', array($this, 'afterWriteContent')); } /** * Adds all CardDAV-specific properties * * @param DAV\PropFind $propFind * @param DAV\INode $node * @return void */ public function propFindEarly(DAV\PropFind $propFind, DAV\INode $node) { // publish global ldap address book and resources list for this principal if ($node instanceof DAVACL\IPrincipal && empty($this->directories)) { if (\rcube::get_instance()->config->get('kolabdav_ldap_directory')) { $this->directories[] = self::ADDRESSBOOK_ROOT . '/' . $node->getName() . '/' . LDAPDirectory::DIRECTORY_NAME; } if (\rcube::get_instance()->config->get('kolabdav_ldap_resources')) { $this->directories[] = self::ADDRESSBOOK_ROOT . '/' . $node->getName() . '/' . LDAPResources::DIRECTORY_NAME; } } $propFind->handle('{' . self::NS_CARDDAV . '}addressbook-home-set', function() { return new DAV\Xml\Property\Href($this->getAddressBookHomeForPrincipal(HTTPBasic::$current_user) . '/'); }); parent::propFindEarly($propFind, $node); } /** * Handler for beforeMethod events */ public function beforeMethod($request, $response) { $method = $request->getMethod(); if ($method == 'PUT' && $request->getHeader('If-None-Match') == '*') { // In-None-Match: * is only valid with PUT requests creating a new resource. // SOGo Conenctor for Thunderbird also sends it with update requests which then fail // in the Server::checkPreconditions(). // See https://issues.kolab.org/show_bug.cgi?id=2589 and http://www.sogo.nu/bugs/view.php?id=1624 // This is a work-around for the buggy SOGo connector and should be removed once fixed. if (strpos($request->getHeader('User-Agent'), 'Thunderbird/') > 0) { unset($_SERVER['HTTP_IF_NONE_MATCH']); } } else if ($method == 'GET' && ($accept = $request->getHeader('Accept'))) { // determine requested vcard version from Accept: header self::$vcard_version = parent::negotiateVCard($accept); } } /** * Inject some additional HTTP response headers */ public function afterWriteContent($uri, $node) { // send Location: header to corrected URI if (self::$redirect_basename) { $path = explode('/', $uri); array_pop($path); array_push($path, self::$redirect_basename); $this->server->httpResponse->setHeader('Location', $this->server->getBaseUri() . join('/', array_map('urlencode', $path))); self::$redirect_basename = null; } } /** * 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 { $vobj = VObject\Reader::read($data, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); if ($vobj->name == 'VCARD') { $this->parsed_vcard = $vobj; } } catch (VObject\ParseException $e) { throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid vcard 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)) { throw new DAV\Exception\BadRequest('Every vcard must have a UID.'); } } /** * Wrapper for Plugin::negotiateVCard() to store the requested vcard version for the backend */ protected function negotiateVCard($input, &$mimeType = null) { self::$vcard_version = parent::negotiateVCard($input, $mimeType); return self::$vcard_version; } /** * Converts a vcard blob to a different version, or jcard. * * (optimized version that skips parsing and re-serialization if possible) * * @param string|resource $data * @param string $target * @param array $propertiesFilter * @return string */ protected function convertVCard($data, $target, $propertiesFilter = array()) { $version = 'vcard3'; if (is_string($data) && preg_match('/VERSION:(\d)/', $data, $m)) { $version = 'vcard' . $m[1]; } // no conversion needed if ($target == $version && empty($propertiesFilter)) { if (is_resource($data)) { $data = stream_get_contents($data); } return $data; } return parent::convertVCard($data, $target, $propertiesFilter); } /** * This function handles the addressbook-query REPORT * * This report is used by the client to filter an addressbook based on a * complex query. * - * @param \DOMNode $dom + * @param \Sabre\CardDAV\Xml\Request\AddressBookQueryReport $report * @return void */ - protected function addressbookQueryReport($dom) + protected function addressbookQueryReport($report) { - $node = $this->server->tree->getNodeForPath(($uri = $this->server->getRequestUri())); + $uri = $this->server->getRequestUri(); + $node = $this->server->tree->getNodeForPath($uri); console(__METHOD__, $uri); + // TODO: port to new API if still required. // fix some bogus parameters in queries sent by the SOGo connector. // issue submitted in http://www.sogo.nu/bugs/view.php?id=2655 - $xpath = new \DOMXPath($dom); - $xpath->registerNameSpace('card', Plugin::NS_CARDDAV); - - $filters = $xpath->query('/card:addressbook-query/card:filter'); - if ($filters->length === 1) { - $filter = $filters->item(0); - $propFilters = $xpath->query('card:prop-filter', $filter); - for ($ii=0; $ii < $propFilters->length; $ii++) { - $propFilter = $propFilters->item($ii); - $name = $propFilter->getAttribute('name'); - - // attribute 'mail' => EMAIL - if ($name == 'mail') { - $propFilter->setAttribute('name', 'EMAIL'); - } - - $textMatches = $xpath->query('card:text-match', $propFilter); - for ($jj=0; $jj < $textMatches->length; $jj++) { - $textMatch = $textMatches->item($jj); - $collation = $textMatch->getAttribute('collation'); - - // 'i;unicasemap' is a non-standard collation - if ($collation == 'i;unicasemap') { - $textMatch->setAttribute('collation', 'i;unicode-casemap'); - } - } - } - } + // $xpath = new \DOMXPath($dom); + // $xpath->registerNameSpace('card', Plugin::NS_CARDDAV); + + // $filters = $xpath->query('/card:addressbook-query/card:filter'); + // if ($filters->length === 1) { + // $filter = $filters->item(0); + // $propFilters = $xpath->query('card:prop-filter', $filter); + // for ($ii=0; $ii < $propFilters->length; $ii++) { + // $propFilter = $propFilters->item($ii); + // $name = $propFilter->getAttribute('name'); + + // // attribute 'mail' => EMAIL + // if ($name == 'mail') { + // $propFilter->setAttribute('name', 'EMAIL'); + // } + + // $textMatches = $xpath->query('card:text-match', $propFilter); + // for ($jj=0; $jj < $textMatches->length; $jj++) { + // $textMatch = $textMatches->item($jj); + // $collation = $textMatch->getAttribute('collation'); + + // // 'i;unicasemap' is a non-standard collation + // if ($collation == 'i;unicasemap') { + // $textMatch->setAttribute('collation', 'i;unicode-casemap'); + // } + // } + // } + // } // query on LDAP node: pass along filter query if ($node instanceof LDAPDirectory || $node instanceof LDAPResources) { - $query = new CardDAV\AddressBookQueryParser($dom); - $query->parse(); // set query and ... - $node->setAddressbookQuery($query); + $node->setAddressbookQuery($report); } // ... proceed with default action - parent::addressbookQueryReport($dom); + parent::addressbookQueryReport($report); } }