diff options
author | Thomas Bruederli <bruederli@kolabsys.com> | 2013-03-07 16:07:08 (GMT) |
---|---|---|
committer | Thomas Bruederli <bruederli@kolabsys.com> | 2013-03-07 16:07:08 (GMT) |
commit | 6b8cdd9ab0d884fa27a0ad4da39e4981bdecf513 (patch) | |
tree | f551dc55fdd84ff90b158efad96cadfafcdef24b /lib | |
parent | 598cc5aa7625257f2bd06bdc96836d633b49f452 (diff) | |
download | iRony-6b8cdd9ab0d884fa27a0ad4da39e4981bdecf513.tar.gz |
Add support for CardDAV
Diffstat (limited to 'lib')
-rw-r--r-- | lib/Kolab/CardDAV/ContactsBackend.php | 687 | ||||
-rw-r--r-- | lib/Kolab/Utils/VObjectUtils.php | 69 |
2 files changed, 756 insertions, 0 deletions
diff --git a/lib/Kolab/CardDAV/ContactsBackend.php b/lib/Kolab/CardDAV/ContactsBackend.php new file mode 100644 index 0000000..5340be8 --- /dev/null +++ b/lib/Kolab/CardDAV/ContactsBackend.php @@ -0,0 +1,687 @@ +<?php + +/** + * SabreDAV Contacts backend for Kolab. + * + * @author Thomas Bruederli <bruederli@kolabsys.com> + * + * Copyright (C) 2013, 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_charset; +use \kolab_storage; +use Sabre\DAV; +use Sabre\CardDAV; +use Sabre\VObject; +use Kolab\Utils\VObjectUtils; + +/** + * Kolab Contacts backend. + * + * Checkout the Sabre\CardDAV\Backend\BackendInterface for all the methods that must be implemented. + */ +class ContactsBackend extends CardDAV\Backend\AbstractBackend +{ + private $sources; + private $folders; + private $useragent; + + + /** + * Read available contact folders from server + */ + private function _read_sources() + { + // already read sources + if (isset($this->sources)) + return $this->sources; + + // get all folders that have "contact" type + $folders = kolab_storage::get_folders('contact'); + $this->sources = $this->folders = array(); + + // convert to UTF8 and sort + $names = array(); + foreach ($folders as $folder) { + $folders[$folder->name] = $folder; + $names[$folder->name] = rcube_charset::convert($folder->name, 'UTF7-IMAP'); + } + + asort($names, SORT_LOCALE_STRING); + + foreach ($names as $utf7name => $name) { + $id = urlencode($utf7name); + $folder = $this->folders[$id] = $folders[$utf7name]; + $fdata = $folder->get_imap_data(); // fetch IMAP folder data for CTag generation + $this->sources[$id] = array( + 'id' => $id, + 'uri' => $id, + '{DAV:}displayname' => $name, + '{http://calendarserver.org/ns/}getctag' => sprintf('%d-%d-%d', $fdata['UIDVALIDITY'], $fdata['HIGHESTMODSEQ'], time(), $fdata['UIDNEXT']), + '{urn:ietf:params:xml:ns:caldav}supported-address-data' => new CardDAV\Property\SupportedAddressData(), + ); + + } + + return $this->sources; + } + + + /** + * Getter for a kolab_storage_folder representing the address book for the given ID + * + * @param string Folder ID + * @return object kolab_storage_folder instance + */ + public function get_storage_folder($id) + { + if ($this->folders[$id]) { + return $this->folders[$id]; + } + else { + $storage = kolab_storage::get_folder(urldecode($id)); + return !PEAR::isError($this->storage) ? $storage : null; + } + } + + + /** + * Returns the list of addressbooks for a specific user. + * + * @param string $principalUri + * @return array + */ + public function getAddressBooksForUser($principalUri) + { + $this->_read_sources(); + + $addressBooks = array(); + foreach ($this->sources as $id => $source) { + $source['principaluri'] = $principalUri; + $addressBooks[] = $source; + } + + return $addressBooks; + } + + + /** + * Updates an addressbook's properties + * + * See Sabre\DAV\IProperties for a description of the mutations array, as + * well as the return value. + * + * @param mixed $addressBookId + * @param array $mutations + * @see Sabre\DAV\IProperties::updateProperties + * @return bool|array + */ + public function updateAddressBook($addressBookId, array $mutations) + { + // TODO: implement this + return false; + } + + /** + * Creates a new address book + * + * @param string $principalUri + * @param string $url Just the 'basename' of the url. + * @param array $properties + * @return void + */ + public function createAddressBook($principalUri, $url, array $properties) + { + // TODO: implement this + } + + /** + * Deletes an entire addressbook and all its contents + * + * @param int $addressBookId + * @return void + */ + public function deleteAddressBook($addressBookId) + { + // TODO: implement this + } + + /** + * Returns all cards for a specific addressbook id. + * + * This method should return the following properties for each card: + * * carddata - raw vcard data + * * uri - Some unique url + * * lastmodified - A unix timestamp + * * etag - A unique etag. This must change every time the card changes. + * * size - The size of the card in bytes. + * + * If these last two properties are provided, less time will be spent + * calculating them. If they are specified, you can also ommit carddata. + * This may speed up certain requests, especially with large cards. + * + * @param mixed $addressbookId + * @return array + */ + public function getCards($addressbookId) + { + console(__METHOD__, $addressbookId); + + $query = array(); + $cards = array(); + if ($storage = $this->get_storage_folder($addressbookId)) { + foreach ((array)$storage->select($query) as $contact) { + $cards[] = array( + 'id' => $contact['uid'], + 'uri' => $contact['uid'] . '.vcf', + 'lastmodified' => $contact['changed']->format('U'), +// 'calendarid' => $addressbookId, + 'etag' => self::_get_etag($contact), + 'size' => $contact['_size'], + ); + } + } + + return $cards; + } + + /** + * Returns a specfic card. + * + * The same set of properties must be returned as with getCards. The only + * exception is that 'carddata' is absolutely required. + * + * @param mixed $addressBookId + * @param string $cardUri + * @return array + */ + public function getCard($addressBookId, $cardUri) + { + console(__METHOD__, $addressBookId, $cardUri); + + $uid = basename($cardUri, '.vcf'); + $storage = $this->get_storage_folder($addressBookId); + + if ($storage && ($contact = $storage->get_object($uid))) { + return array( + 'id' => $contact['uid'], + 'uri' => $contact['uid'] . '.vcf', + 'lastmodified' => $contact['changed']->format('U'), +// 'calendarid' => $calendarId, + 'carddata' => $this->_to_vcard($contact), + 'etag' => self::_get_etag($contact), + ); + } + + return array(); + } + + /** + * Creates a new card. + * + * The addressbook id will be passed as the first argument. This is the + * same id as it is returned from the getAddressbooksForUser method. + * + * The cardUri is a base uri, and doesn't include the full path. The + * cardData argument is the vcard body, and is passed as a string. + * + * It is possible to return an ETag from this method. This ETag is for the + * newly created resource, and must be enclosed with double quotes (that + * is, the string itself must contain the double quotes). + * + * You should only return the ETag if you store the carddata as-is. If a + * subsequent GET request on the same card does not have the same body, + * byte-by-byte and you did return an ETag here, clients tend to get + * confused. + * + * @param mixed $addressBookId + * @param string $cardUri + * @param string $cardData + * @return string|null + */ + public function createCard($addressBookId, $cardUri, $cardData) + { + console(__METHOD__, $addressbookId, $cardUri, $cardData); + + $uid = basename($cardUri, '.vcf'); + $storage = $this->get_storage_folder($addressBookId); + $object = $this->parse_vcard($cardData, $uid); + + if ($object['uid'] == $uid) { + $success = $storage->save($object, 'contact'); + if (!$success) { + rcube::raise_error(array( + 'code' => 600, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving contact object to Kolab server"), + true, false); + } + } + else { + rcube::raise_error(array( + 'code' => 600, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error creating contact object: UID doesn't match object URI"), + true, false); + } + + // return new Etag + return $success ? self::_get_etag($object) : null; + } + + /** + * Updates a card. + * + * The addressbook id will be passed as the first argument. This is the + * same id as it is returned from the getAddressbooksForUser method. + * + * The cardUri is a base uri, and doesn't include the full path. The + * cardData argument is the vcard body, and is passed as a string. + * + * It is possible to return an ETag from this method. This ETag should + * match that of the updated resource, and must be enclosed with double + * quotes (that is: the string itself must contain the actual quotes). + * + * If you don't return an ETag, you can just return null. + * + * @param mixed $addressBookId + * @param string $cardUri + * @param string $cardData + * @return string|null + */ + public function updateCard($addressBookId, $cardUri, $cardData) + { + console(__METHOD__, $addressbookId, $cardUri, $cardData); + + $uid = basename($cardUri, '.vcf'); + $storage = $this->get_storage_folder($addressBookId); + $object = $this->parse_vcard($cardData, $uid); + + // sanity check + if ($object['uid'] != $uid) { + rcube::raise_error(array( + 'code' => 600, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error creating contact object: UID doesn't match object URI"), + true, false); + + return null; + } + + // copy meta data (starting with _) from old object + $old = $storage->get_object($uid); + foreach ((array)$old as $key => $val) { + if (!isset($object[$key]) && $key[0] == '_') + $object[$key] = $val; + } + + // save object + $saved = $storage->save($object, 'contact', $uid); + if (!$saved) { + rcube::raise_error(array( + 'code' => 600, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving contact object to Kolab server"), + true, false); + + return null; + } + + // return new Etag + return self::_get_etag($object); + } + + /** + * Deletes a card + * + * @param mixed $addressBookId + * @param string $cardUri + * @return bool + */ + public function deleteCard($addressBookId, $cardUri) + { + console(__METHOD__, $addressbookId, $cardUri); + // TODO: implement this + } + + + /** + * Set User-Agent string of the connected client + */ + public function setUserAgent($uastring) + { + $ua_classes = array( + 'thunderbird' => 'Thunderbird/\d', + ); + + foreach ($ua_classes as $class => $regex) { + if (preg_match("!$regex!", $uastring)) { + $this->useragent = $class; + break; + } + } + } + + + /********** Data conversion utilities ***********/ + + private $phonetypes = array( + 'main' => 'voice', + 'homefax' => 'fax', + 'workfax' => 'fax', + 'mobile' => 'cell', + 'other' => 'textphone', + ); + + + /** + * Parse the given VCard string into a hash array kolab_format_contact can handle + * + * @param string VCard data block + * @return array Hash array with contact properties or null on failure + */ + private function parse_vcard($cardData, $uid) + { + try { + VObject\Property::$classMap['REV'] = 'Sabre\\VObject\\Property\\DateTime'; + + $vobject = VObject\Reader::read($cardData, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); + + if ($vobject && $vobject->name == 'VCARD') { + $contact = $this->_to_array($vobject); + if (!empty($contact['uid'])) { + return $contact; + } + } + } + catch (VObject\ParseException $e) { + rcube::raise_error(array( + 'code' => 600, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "VCard data parse error: " . $e->getMessage()), + true, false); + } + + return null; + } + + + /** + * Build a valid VCard format block from the given contact record + * + * @param array Hash array with contact properties from libkolab + * @return string VCARD string containing the contact data + */ + private function _to_vcard($contact) + { + $vc = VObject\Component::create('VCARD'); + $vc->version = '3.0'; + $vc->prodid = '-//Kolab DAV Server ' .KOLAB_DAV_VERSION . '//Sabre//Sabre VObject ' . CardDAV\Version::VERSION . '//EN'; + + $vc->add('UID', $contact['uid']); + $vc->add('FN', $contact['name']); + + $n = VObject\Property::create('N'); + $n->setParts(array($contact['surname'], $contact['firstname'], $contact['middlename'], $contact['prefix'], $contact['suffix'])); + $vc->add($n); + + if (!empty($contact['nickname'])) + $vc->add('NICKNAME', $contact['nickname']); + if (!empty($contact['jobtitle'])) + $vc->add('TITLE', $contact['jobtitle']); + if (!empty($contact['profession'])) + $vc->add('X-PROFESSION', $contact['profession']); + + if (!empty($contact['organization']) || !empty($contact['department'])) { + $org = VObject\Property::create('ORG'); + $org->setParts(array($contact['organization'], $contact['department'])); + $vc->add($org); + } + + // TODO: save as RELATED + if (!empty($contact['assistant'])) + $vc->add('X-ASSISTANT', join(',', (array)$contact['assistant'])); + if (!empty($contact['manager'])) + $vc->add('X-MANAGER', join(',', (array)$contact['manager'])); + if (!empty($contact['spouse'])) + $vc->add('X-SPOUSE', $contact['spouse']); + if (!empty($contact['children'])) + $vc->add('X-CHILDREN', join(',', (array)$contact['children'])); + + foreach ((array)$contact['email'] as $email) { + // TODO: add types + $vc->add('EMAIL', $email, array('type' => 'INTERNET')); + } + + foreach ((array)$contact['phone'] as $phone) { + $type = $this->phonetypes[$phone['type']] ?: $phone['type']; + $vc->add('TEL', $phone['number'], array('type' => strtoupper($type))); + } + + foreach ((array)$contact['website'] as $website) { + $vc->add('URL', $website['url'], array('type' => strtoupper($website['type']))); + } + + foreach ((array)$contact['im'] as $im) { + list($prot, $val) = explode(':', $im); + if ($val) $vc->add('x-' . $prot, $val); + else $vc->add('IMPP', $im); + } + + foreach ((array)$contact['address'] as $adr) { + $vadr = VObject\Property::create('ADR', null, array('type' => strtoupper($adr['type']))); + $vadr->setParts(array('','', $adr['street'], $adr['locality'], $adr['region'], $adr['zipcode'], $adr['country'])); + $vc->add($vadr); + } + + if (!empty($contact['notes'])) + $vc->add('NOTE', $contact['notes']); + + if (!empty($contact['gender'])) + $vc->add('X-GENDER', $contact['gender']); + + if (!empty($contact['birthday']) && $contact['birthday'] instanceof \DateTime) { + $contact['birthday']->_dateonly = true; + $vc->add(VObjectUtils::datetime_prop('BDAY', $contact['birthday'], false)); + } + if (!empty($contact['anniversary']) && $contact['birthday'] instanceof \DateTime) { + $contact['anniversary']->_dateonly = true; + $vc->add(VObjectUtils::datetime_prop('X-ANNIVERSARY', $contact['anniversary'], false)); + } + + if ($contact['categories']) { + $cat = VObject\Property::create('CATEGORIES'); + $cat->setParts((array)$contact['categories']); + $vc->add($cat); + } + + if (!empty($contact['freebusyurl'])) + $vc->add('FBURL', $contact['freebusyurl']); + + if (!empty($contact['photo'])) { + $vc->PHOTO = base64_encode($contact['photo']); + $vc->PHOTO->add('BASE64', null); + } + + if (!empty($contact['changed'])) + $vc->add(VObjectUtils::datetime_prop('REV', $contact['changed'], true)); + + return $vc->serialize(); + } + + /** + * Convert the given Sabre\VObject\Component\Vcard object to a libkolab compatible contact format + * + * @param object Vcard object to convert + * @return array Hash array with contact properties + */ + private function _to_array($vc) + { + $contact = array( + 'uid' => strval($vc->UID), + 'name' => strval($vc->FN), + 'changed' => $vc->REV ? $vc->REV->getDateTime() : null, + ); + + $phonetypemap = array_flip($this->phonetypes); + + // map attributes to internal fields + foreach ($vc->children as $prop) { + if (!($prop instanceof VObject\Property)) + continue; + + switch ($prop->name) { + case 'N': + list($contact['surname'], $contact['firstname'], $contact['middlename'], $contact['prefix'], $contact['suffix']) = $prop->getParts(); + break; + + case 'NOTE': + $contact['notes'] = $prop->value; + break; + + case 'TITLE': + case 'NICKNAME': + $contact[strtolower($prop->name)] = $prop->value; + break; + + case 'ORG': + list($contact['categories'], $contact['department']) = $prop->getParts(); + break; + + case 'CATEGORY': + case 'CATEGORIES': + $contact['categories'] = $prop->getParts(); + break; + + case 'EMAIL': + $types = self::array_filter($prop->offsetGet('type'), 'internet,pref', true); + // console(array_map('strtolower', $types)); + $contact['email'][] = $prop->value; + break; + + case 'URL': + $types = self::array_filter($prop->offsetGet('type'), 'internet,pref', true); + $contact['website'][] = array('url' => $prop->value, 'type' => strtolower($types[0])); + break; + + case 'TEL': + $types = self::array_filter($prop->offsetGet('type'), 'internet,pref', true); + $type = strtolower($types[0]); + $contact['phone'][] = array('number' => $prop->value, 'type' => $phonetypemap[$type] ?: $type); + break; + + case 'ADR': + $type = $prop->offsetGet('type'); + $adr = array('type' => strval($type)); + list(,, $adr['street'], $adr['locality'], $adr['region'], $adr['zipcode'], $adr['country']) = $prop->getParts(); + $contact['address'][] = $adr; + break; + + case 'BDAY': + $contact['birthday'] = new \DateTime($prop->value); + $contact['birthday']->_dateonly = true; + break; + + case 'X-ANNIVERSARY': + $contact['anniversary'] = new \DateTime($prop->value); + $contact['anniversary']->_dateonly = true; + break; + + case 'X-GENDER': + case 'X-PROFESSION': + case 'X-SPOUSE': + $contact[strtolower(substr($prop->name, 2))] = $prop->value; + break; + + case 'X-MANAGER': + case 'X-ASSISTANT': + case 'X-CHILDREN': + $contact[strtolower(substr($prop->name, 2))] = explode(',', $prop->value); + break; + + case 'X-JABBER': + case 'X-ICQ': + case 'X-MSN': + case 'X-AIM': + case 'X-YAHOO': + case 'X-SKYPE': + $protocol = strtolower(substr($prop->name, 2)); + $contact['im'][] = $protocol. ':' . $prop->value; + break; + + case 'PHOTO': + $param = $prop->parameters[0]; + if ($param->value && strtolower($param->value) == 'b' || strtolower($param->name) == 'base64') { + $contact['photo'] = base64_decode($prop->value); + } + break; + + case 'CUSTOM1': + case 'CUSTOM2': + case 'CUSTOM3': + case 'CUSTOM4': + default: + if (substr($prop->name, 0, 2) == 'X-' || substr($prop->name, 0, 6) == 'CUSTOM') + $contact['x-custom'][] = array($prop->name, strval($prop->value)); + break; + } + } + + return $contact; + } + + /** + * Extract array values by a filter + * + * @param array Array to filter + * @param keys Array or comma separated list of values to keep + * @param boolean Invert key selection: remove the listed values + * + * @return array The filtered array + */ + private static function array_filter($arr, $values, $inverse = false) + { + if (!is_array($values)) { + $values = explode(',', $values); + } + + $result = array(); + $keep = array_flip((array)$values); + + foreach ($arr as $key => $val) { + if ($inverse != isset($keep[strtolower($val)])) { + $result[$key] = $val; + } + } + + return $result; + } + + /** + * 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), time(), $contact['_msguid']); + } + +} diff --git a/lib/Kolab/Utils/VObjectUtils.php b/lib/Kolab/Utils/VObjectUtils.php new file mode 100644 index 0000000..dadd7be --- /dev/null +++ b/lib/Kolab/Utils/VObjectUtils.php @@ -0,0 +1,69 @@ +<?php + +namespace Kolab\Utils; + +use Sabre\VObject\Property; + +/** + * Helper class proviting utility functions for VObject data encoding + */ +class VObjectUtils +{ + + /** + * Helper method to correctly interpret an all-day date value + */ + public static function convert_datetime($prop) + { + if (empty($prop)) { + return null; + } + else if ($prop instanceof Property\MultiDateTime) { + $dt = array(); + $dateonly = ($prop->getDateType() & Property\DateTime::DATE); + foreach ($prop->getDateTimes() as $item) { + $item->_dateonly = $dateonly; + $dt[] = $item; + } + } + else if ($prop instanceof Property\DateTime) { + $dt = $prop->getDateTime(); + if ($prop->getDateType() & Property\DateTime::DATE) { + $dt->_dateonly = true; + } + } + else if ($prop instanceof \DateTime) { + $dt = $prop; + } + + return $dt; + } + + + /** + * Create a Sabre\VObject\Property instance from a PHP DateTime object + * + * @param string Property name + * @param object DateTime + */ + public static function datetime_prop($name, $dt, $utc = false) + { + $vdt = new Property\DateTime($name); + $vdt->setDateTime($dt, $dt->_dateonly ? Property\DateTime::DATE : ($utc ? Property\DateTime::UTC : Property\DateTime::LOCALTZ)); + return $vdt; + } + + /** + * Copy values from one hash array to another using a key-map + */ + public static function map_keys($values, $map) + { + $out = array(); + foreach ($map as $from => $to) { + if (isset($values[$from])) + $out[$to] = $values[$from]; + } + return $out; + } + +}
\ No newline at end of file |