diff options
author | Thomas Bruederli <bruederli@kolabsys.com> | 2014-03-12 11:13:27 (GMT) |
---|---|---|
committer | Thomas Bruederli <bruederli@kolabsys.com> | 2014-03-13 09:18:42 (GMT) |
commit | 3fe616421a8d7340b4dd29ce56dafec4733d4d3c (patch) | |
tree | ce37ebf1c32e4a716b205d52b4867f590fdb5638 | |
parent | 12e71c361fce1e132d35d29d2001f69c34be5e3e (diff) | |
download | iRony-3fe616421a8d7340b4dd29ce56dafec4733d4d3c.tar.gz |
Implement the CardDAV Directory Gateway Extension for Apple clients (and SOGo connector).
This exposes an LDAP-based address book inside the principals address book collection.
According to the spec [1], this should be used for querying only. Listing, however, can
be enabled by setting 'searchonly' => false in config.
CARDDAV:addressbook-query requests will be translated into LDAP filters
and post-processed by SabreDAV internals.
[1] http://tools.ietf.org/html/draft-daboo-carddav-directory-gateway-02
-rw-r--r-- | lib/Kolab/CardDAV/ContactsBackend.php | 4 | ||||
-rw-r--r-- | lib/Kolab/CardDAV/LDAPDirectory.php | 484 | ||||
-rw-r--r-- | lib/Kolab/CardDAV/Plugin.php | 76 | ||||
-rw-r--r-- | lib/Kolab/CardDAV/UserAddressBooks.php | 27 |
4 files changed, 589 insertions, 2 deletions
diff --git a/lib/Kolab/CardDAV/ContactsBackend.php b/lib/Kolab/CardDAV/ContactsBackend.php index f43edb5..5ce2be7 100644 --- a/lib/Kolab/CardDAV/ContactsBackend.php +++ b/lib/Kolab/CardDAV/ContactsBackend.php @@ -313,7 +313,7 @@ class ContactsBackend extends CardDAV\Backend\AbstractBackend 'id' => $contact['uid'], 'uri' => $contact['uid'] . '.vcf', 'lastmodified' => is_a($contact['changed'], 'DateTime') ? $contact['changed']->format('U') : null, - 'carddata' => $this->_to_vcard($contact), + 'carddata' => $this->to_vcard($contact), 'etag' => self::_get_etag($contact), ); } @@ -613,7 +613,7 @@ class ContactsBackend extends CardDAV\Backend\AbstractBackend * @param array Hash array with contact properties from libkolab * @return string VCARD string containing the contact data */ - private function _to_vcard($contact) + public function to_vcard($contact) { $vc = VObject\Component::create('VCARD'); $vc->version = '3.0'; diff --git a/lib/Kolab/CardDAV/LDAPDirectory.php b/lib/Kolab/CardDAV/LDAPDirectory.php new file mode 100644 index 0000000..622ce29 --- /dev/null +++ b/lib/Kolab/CardDAV/LDAPDirectory.php @@ -0,0 +1,484 @@ +<?php + +/** + * CardDAV Directory class providing read-only access + * to an LDAP-based global address book. + * + * This implements the CardDAV Directory Gateway Extension suggested by Apple Inc. + * http://tools.ietf.org/html/draft-daboo-carddav-directory-gateway-02 + * + * @author Thomas Bruederli <bruederli@kolabsys.com> + * + * Copyright (C) 2014, Kolab Systems AG <contact@kolabsys.com> + * + * 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 <http://www.gnu.org/licenses/>. + */ + +namespace Kolab\CardDAV; + +use \rcube; +use \rcube_ldap; +use \rcube_ldap_generic; +use Sabre\DAV; +use Sabre\DAVACL; +use Sabre\CardDAV\Card; +use Sabre\CardDAV\Property; + +/** + * CardDAV Directory Gateway implementation + */ +class LDAPDirectory extends DAV\Collection implements \Sabre\CardDAV\IDirectory, DAV\IProperties, DAVACL\IACL +{ + const DIRECTORY_NAME = 'ldap-directory'; + + private $config; + private $ldap; + private $carddavBackend; + private $principalUri; + private $addressBookInfo = array(); + private $uid2id = array(); + private $query; + private $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:caldav}supported-address-data' => new Property\SupportedAddressData(), + 'principaluri' => $principalUri, + ); + + // used for vcard serialization + $this->carddavBackend = $carddavBackend ?: new ContactsBackend(); + } + + 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 + */ + function setAddressbookQuery($query) + { + $this->query = $query; + $this->filter = $this->addressbook_query2ldap_filter($query); + } + + /** + * 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 = basename($cardUri, '.vcf'); + $record = null; + + // TODO: get from cache + + if ($ldap = $this->connect()) { + // used cached uid mapping + if ($ID = $this->uid2id[$uid]) { + $record = $ldap->get_record($ID, true); + } + else { // query for uid + $result = $ldap->search('uid', $uid, 1, true, true); + if ($result->count) { + $record = $result[0]; + } + } + + if ($record) { + $this->_normalize_contact($record); + $obj = array( + 'id' => $contact['uid'], + 'uri' => $contact['uid'] . '.vcf', + 'lastmodified' => $contact['_timestamp'], + 'carddata' => $this->carddavBackend->to_vcard($contact), + 'etag' => self::_get_etag($contact), + ); + + return new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + } + + throw new DAV\Exception\NotFound('Card not found'); + } + + /** + * Returns an array with all the child nodes + * + * @return DAV\INode[] + */ + function getChildren() + { + console(__METHOD__, $this->query, $this->filter); + + $children = array(); + + // 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); + + // convert restuls into vcard blocks + foreach ($results as $contact) { + $this->_normalize_contact($contact); + + $obj = array( + 'id' => $contact['uid'], + 'uri' => $contact['uid'] . '.vcf', + 'lastmodified' => $contact['_timestamp'], + 'carddata' => $this->carddavBackend->to_vcard($contact), + 'etag' => self::_get_etag($contact), + ); + + // TODO: cache result + $this->uid2id[$contact['uid']] = $contact['ID']; + + $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + } + + 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\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, + ), + ); + } + + /** + * 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 array $mutations + * @return bool|array + */ + function updateProperties($mutations) + { + console(__METHOD__, $mutations); + return false; + } + + /** + * Post-process the given contact record from rcube_ldap + */ + private function _normalize_contact(&$contact) + { + if (is_numeric($contact['changed'])) { + $contact['_timestamp'] = $contact['changed']; + $contact['changed'] = new \DateTime('@' . $contact['changed']); + } + else if (!empty($contact['changed'])) { + try { + $contact['changed'] = new \DateTime($contact['changed']); + $contact['_timestamp'] = $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 ($values as $value) { + $contact[$col][] = array($prop => $value, 'type' => $type); + } + } + } + } + + /** + * Translate the given AddressBookQueryParser object into an LDAP filter + */ + 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 = $this->connect(); + + $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 2abf0a8..1456e78 100644 --- a/lib/Kolab/CardDAV/Plugin.php +++ b/lib/Kolab/CardDAV/Plugin.php @@ -24,6 +24,7 @@ namespace Kolab\CardDAV; use Sabre\DAV; +use Sabre\DAVACL; use Sabre\CardDAV; use Sabre\VObject; @@ -55,6 +56,25 @@ class Plugin extends CardDAV\Plugin } /** + * Adds all CardDAV-specific properties + * + * @param string $path + * @param DAV\INode $node + * @param array $requestedProperties + * @param array $returnedProperties + * @return void + */ + public function beforeGetProperties($path, DAV\INode $node, array &$requestedProperties, array &$returnedProperties) + { + // publish global ldap address book for this principal + if ($node instanceof DAVACL\IPrincipal && empty($this->directories) && \rcube::get_instance()->config->get('global_ldap_directory')) { + $this->directories[] = self::ADDRESSBOOK_ROOT . '/' . $node->getName() . '/' . LDAPDirectory::DIRECTORY_NAME; + } + + parent::beforeGetProperties($path, $node, $requestedProperties, $returnedProperties); + } + + /** * Handler for beforeMethod events */ public function beforeMethod($method, $uri) @@ -124,4 +144,60 @@ class Plugin extends CardDAV\Plugin } } + /** + * 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 + * @return void + */ + protected function addressbookQueryReport($dom) + { + $node = $this->server->tree->getNodeForPath(($uri = $this->server->getRequestUri())); + console(__METHOD__, $uri); + + // fix some stupid mistakes in queries sent by the SOGo connector + $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) { + $query = new CardDAV\AddressBookQueryParser($dom); + $query->parse(); + + // set query and ... + $node->setAddressbookQuery($query); + } + + // ... proceed with default action + parent::addressbookQueryReport($dom); + } }
\ No newline at end of file diff --git a/lib/Kolab/CardDAV/UserAddressBooks.php b/lib/Kolab/CardDAV/UserAddressBooks.php index 02707f0..db71bbe 100644 --- a/lib/Kolab/CardDAV/UserAddressBooks.php +++ b/lib/Kolab/CardDAV/UserAddressBooks.php @@ -23,6 +23,7 @@ namespace Kolab\CardDAV; +use \rcube; use Sabre\DAV; use Sabre\DAVACL; @@ -33,6 +34,9 @@ use Sabre\DAVACL; */ class UserAddressBooks extends \Sabre\CardDAV\UserAddressBooks implements DAV\IExtendedCollection, DAVACL\IACL { + // pseudo-singleton instance + private $ldap_directory; + /** * Returns a list of addressbooks * @@ -45,6 +49,11 @@ class UserAddressBooks extends \Sabre\CardDAV\UserAddressBooks implements DAV\IE foreach($addressbooks as $addressbook) { $objs[] = new AddressBook($this->carddavBackend, $addressbook); } + + if (rcube::get_instance()->config->get('global_ldap_directory')) { + $objs[] = $this->getLDAPDirectory(); + } + return $objs; } @@ -56,6 +65,10 @@ class UserAddressBooks extends \Sabre\CardDAV\UserAddressBooks implements DAV\IE */ public function getChild($name) { + if ($name == LDAPDirectory::DIRECTORY_NAME) { + return $this->getLDAPDirectory(); + } + if ($addressbook = $this->carddavBackend->getAddressBookByName($name)) { $addressbook['principaluri'] = $this->principalUri; return new AddressBook($this->carddavBackend, $addressbook); @@ -64,4 +77,18 @@ class UserAddressBooks extends \Sabre\CardDAV\UserAddressBooks implements DAV\IE throw new DAV\Exception\NotFound('Addressbook with name \'' . $name . '\' could not be found'); } + /** + * Getter for the singleton instance of the LDAP directory + */ + private function getLDAPDirectory() + { + if (!$this->ldap_directory) { + $rcube = rcube::get_instance(); + $config = $rcube->config->get('global_ldap_directory'); + $config['debug'] = $rcube->config->get('ldap_debug'); + $this->ldap_directory = new LDAPDirectory($config, $this->principalUri, $this->carddavBackend); + } + + return $this->ldap_directory; + } } |