summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Bruederli <bruederli@kolabsys.com>2014-03-12 11:13:27 (GMT)
committerThomas Bruederli <bruederli@kolabsys.com>2014-03-13 09:18:42 (GMT)
commit3fe616421a8d7340b4dd29ce56dafec4733d4d3c (patch)
treece37ebf1c32e4a716b205d52b4867f590fdb5638
parent12e71c361fce1e132d35d29d2001f69c34be5e3e (diff)
downloadiRony-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.php4
-rw-r--r--lib/Kolab/CardDAV/LDAPDirectory.php484
-rw-r--r--lib/Kolab/CardDAV/Plugin.php76
-rw-r--r--lib/Kolab/CardDAV/UserAddressBooks.php27
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;
+ }
}