diff --git a/composer.json b/composer.json index c95e29d..053ce31 100644 --- a/composer.json +++ b/composer.json @@ -1,33 +1,29 @@ { "name": "kolab/irony", "description": "iRony - The Kolab WebDAV/CalDAV/CardDAV Server", "license": "AGPL-3.0", - "version": "0.3-dev", + "version": "0.4-dev", "repositories": [ { "type": "pear", "url": "http://pear.php.net/" }, - { - "type": "vcs", - "url": "https://github.com/thomascube/sabre-vobject.git" - }, { "type": "vcs", "url": "git://git.kolab.org/git/pear/Net_LDAP3" } ], "require": { - "php": ">=5.3.3", - "sabre/dav" : "1.8.9" + "php": ">=5.4.1", + "sabre/dav" : "~2.1.0" }, "require-dev": { "pear/mail_mime": ">=1.8.9", "pear/mail_mime-decode": ">=1.5.5", "pear/http_request2": ">=2.1.1", "pear-pear.php.net/net_idna2": ">=0.1.1", "pear-pear.php.net/net_ldap2": ">=2.0.12", "kolab/Net_LDAP3": "dev-master" }, "minimum-stability": "dev" } diff --git a/lib/Kolab/CalDAV/CalendarBackend.php b/lib/Kolab/CalDAV/CalendarBackend.php index df4e7d4..ba31729 100644 --- a/lib/Kolab/CalDAV/CalendarBackend.php +++ b/lib/Kolab/CalDAV/CalendarBackend.php @@ -1,824 +1,805 @@ * * 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\CalDAV; use \PEAR; use \rcube; use \rcube_charset; use \kolab_storage; use \kolab_storage_config; use \libcalendaring; use Kolab\Utils\DAVBackend; use Kolab\Utils\VObjectUtils; use Kolab\DAV\Auth\HTTPBasic; use Sabre\DAV; use Sabre\CalDAV; use Sabre\VObject; /** * Kolab Calendaring backend. * * Checkout the Sabre\CalDAV\Backend\BackendInterface for all the methods that must be implemented. * */ class CalendarBackend extends CalDAV\Backend\AbstractBackend { private $calendars; private $folders; private $aliases; private $useragent; private $subscribed = null; /** * Read available calendar folders from server */ private function _read_calendars() { // already read sources if (isset($this->calendars)) return $this->calendars; // get all folders that have "event" type $folders = array_merge(kolab_storage::get_folders('event', $this->subscribed), kolab_storage::get_folders('task', $this->subscribed)); $this->calendars = $this->folders = $this->aliases = array(); $order = 1; foreach (kolab_storage::sort_folders($folders) as $folder) { $id = $folder->get_uid(); $this->folders[$id] = $folder; $fdata = $folder->get_imap_data(); // fetch IMAP folder data for CTag generation $this->calendars[$id] = array( 'id' => $id, 'uri' => $id, '{DAV:}displayname' => html_entity_decode($folder->get_name(), ENT_COMPAT, RCUBE_CHARSET), '{http://apple.com/ns/ical/}calendar-color' => '#' . $folder->get_color('FF0000') . 'FF', '{http://calendarserver.org/ns/}getctag' => sprintf('%d-%d-%d', $fdata['UIDVALIDITY'], $fdata['HIGHESTMODSEQ'], $fdata['UIDNEXT']), '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' => new CalDAV\Property\SupportedCalendarComponentSet(array(DAVBackend::$caldav_type_component_map[$folder->type])), '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp' => new CalDAV\Property\ScheduleCalendarTransp('opaque'), '{http://apple.com/ns/ical/}calendar-order' => $order++, ); $this->aliases[$folder->name] = $id; // these properties are used for sharing supprt (not yet active) if (false && $folder->get_namespace() != 'personal') { $rights = $folder->get_myrights(); $this->calendars[$id]['{http://calendarserver.org/ns/}shared-url'] = '/calendars/' . $folder->get_owner() . '/' . $id; $this->calendars[$id]['{http://calendarserver.org/ns/}owner-principal'] = $folder->get_owner(); $this->calendars[$id]['{http://sabredav.org/ns}read-only'] = strpos($rights, 'i') === false; } } return $this->calendars; } /** * Getter for a kolab_storage_folder representing the calendar for the given ID * * @param string Calendar ID * @return object kolab_storage_folder instance */ public function get_storage_folder($id) { // resolve alias name if ($this->aliases[$id]) { $id = $this->aliases[$id]; } if ($this->folders[$id]) { return $this->folders[$id]; } else { return DAVBackend::get_storage_folder($id, ''); } } /** * Returns a list of calendars for a principal. * * Every calendars is an array with the following keys: * * id, a unique id that will be used by other functions to modify the * calendar. This can be the same as the uri or a database key. * * uri, which the basename of the uri with which the calendar is * accessed. * * principaluri. The owner of the calendar. Almost always the same as * principalUri passed to this method. * * Furthermore it can contain webdav properties in clark notation. A very * common one is '{DAV:}displayname'. * * @param string $principalUri * @return array */ public function getCalendarsForUser($principalUri) { console(__METHOD__, $principalUri); $this->_read_calendars(); $calendars = array(); foreach ($this->calendars as $id => $cal) { $this->calendars[$id]['principaluri'] = $principalUri; $calendars[] = $this->calendars[$id]; } return $calendars; } /** * Returns calendar properties for a specific node identified by name/uri * * @param string Node name/uri * @return array Hash array with calendar properties or null if not found */ public function getCalendarByName($calendarUri) { console(__METHOD__, $calendarUri); $this->_read_calendars(); $id = $calendarUri; // resolve aliases (calendar by folder name) if ($this->aliases[$calendarUri]) { $id = $this->aliases[$calendarUri]; } if ($this->calendars[$id] && empty($this->calendars[$id]['principaluri'])) { $this->calendars[$id]['principaluri'] = 'principals/' . HTTPBasic::$current_user; } // retry with subscribed = false (#2701) if (empty($this->calendars[$id]) && $id != 'outbox' && $this->subscribed === null && rcube::get_instance()->config->get('kolab_use_subscriptions')) { $this->subscribed = false; unset($this->calendars); return $this->getCalendarByName($calendarUri); } return $this->calendars[$id]; } /** * Creates a new calendar for a principal. * * If the creation was a success, an id must be returned that can be used to reference * this calendar in other methods, such as updateCalendar. * * @param string $principalUri * @param string $calendarUri * @param array $properties * @return void */ public function createCalendar($principalUri, $calendarUri, array $properties) { console(__METHOD__, $calendarUri, $properties); return DAVBackend::folder_create('event', $properties, $calendarUri); } /** * Updates properties for a calendar. * - * The mutations array uses the propertyName in clark-notation as key, - * and the array value for the property value. In the case a property - * should be deleted, the property value will be null. + * The list of mutations is stored in a Sabre\DAV\PropPatch object. + * To do the actual updates, you must tell this object which properties + * you're going to process with the handle() method. * - * This method must be atomic. If one property cannot be changed, the - * entire operation must fail. + * Calling the handle method is like telling the PropPatch object "I + * promise I can handle updating this property". * - * If the operation was successful, true can be returned. - * If the operation failed, false can be returned. + * Read the PropPatch documenation for more info and examples. * - * Deletion of a non-existent property is always successful. - * - * Lastly, it is optional to return detailed information about any - * failures. In this case an array should be returned with the following - * structure: - * - * array( - * 403 => array( - * '{DAV:}displayname' => null, - * ), - * 424 => array( - * '{DAV:}owner' => null, - * ) - * ) - * - * In this example it was forbidden to update {DAV:}displayname. - * (403 Forbidden), which in turn also caused {DAV:}owner to fail - * (424 Failed Dependency) because the request needs to be atomic. - * - * @param mixed $calendarId - * @param array $mutations - * @return bool|array + * @param string $path + * @param \Sabre\DAV\PropPatch $propPatch + * @return void */ - public function updateCalendar($calendarId, array $mutations) + public function updateCalendar($calendarId, \Sabre\DAV\PropPatch $propPatch) { - console(__METHOD__, $calendarId, $mutations); + console(__METHOD__, $calendarId, $propPatch); - $folder = $this->get_storage_folder($calendarId); - return DAVBackend::folder_update($folder, $mutations); + if ($folder = $this->get_storage_folder($calendarId)) { + DAVBackend::handle_propatch($folder, $propPatch); + } } /** * Delete a calendar and all it's objects * * @param mixed $calendarId * @return void */ public function deleteCalendar($calendarId) { console(__METHOD__, $calendarId); $folder = $this->get_storage_folder($calendarId); if ($folder && !kolab_storage::folder_delete($folder->name)) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting calendar folder $folder->name"), true, false); } } /** * Returns all calendar objects within a calendar. * * Every item contains an array with the following keys: * * id - unique identifier which will be used for subsequent updates * * calendardata - The iCalendar-compatible calendar data (optional) * * uri - a unique key which will be used to construct the uri. This can be any arbitrary string. * * lastmodified - a timestamp of the last modification time * * etag - An arbitrary string, surrounded by double-quotes. (e.g.: "abcdef"') * * calendarid - The calendarid as it was passed to this function. * * size - The size of the calendar objects, in bytes. * * Note that the etag is optional, but it's highly encouraged to return for * speed reasons. * * If neither etag or size are specified, the calendardata will be * used/fetched to determine these numbers. If both are specified the * amount of times this is needed is reduced by a great degree. * * @param mixed $calendarId * @return array */ public function getCalendarObjects($calendarId) { console(__METHOD__, $calendarId); $events = array(); $query = $this->_event_filter_query(); $storage = $this->get_storage_folder($calendarId); if ($storage) { foreach ($storage->select($query) as $event) { // post-filter events to suppress declined invitations if (!$this->_event_filter_compare($event)) { continue; } // get tags/categories from relations $this->load_tags($event); $events[] = array( 'id' => $event['uid'], 'uri' => VObjectUtils::uid2uri($event['uid'], '.ics'), 'lastmodified' => $event['changed'] ? $event['changed']->format('U') : null, 'calendarid' => $calendarId, 'etag' => self::_get_etag($event), 'size' => $event['_size'], ); } } return $events; } /** * Returns information from a single calendar object, based on it's object * uri. * * The returned array must have the same keys as getCalendarObjects. The * 'calendardata' object is required here though, while it's not required * for getCalendarObjects. * * @param mixed $calendarId * @param string $objectUri * @return array */ public function getCalendarObject($calendarId, $objectUri) { console(__METHOD__, $calendarId, $objectUri); $uid = VObjectUtils::uri2uid($objectUri, '.ics'); $storage = $this->get_storage_folder($calendarId); // attachment content is requested if (preg_match('!^(.+).ics:attachment:(\d+):.+$!', $objectUri, $m)) { $uid = VObjectUtils::uri2uid($m[1]); $part = $m[2]; } if ($storage && ($event = $storage->get_object($uid))) { // deliver attachment content directly if ($part && !empty($event['_attachments'])) { foreach ($event['_attachments'] as $attachment) { if ($attachment['id'] == $part) { header('Content-Type: ' . $attachment['mimetype']); header('Content-Disposition: inline; filename="' . $attachment['name'] . '"'); $storage->get_attachment($uid, $part, null, true); exit; } } } // map attributes $event['attachments'] = $event['_attachments']; // compose an absolute URI for referencing object attachments $base_uri = DAVBackend::abs_url(array( CalDAV\Plugin::CALENDAR_ROOT, preg_replace('!principals/!', '', $this->calendars[$calendarId]['principaluri']), $calendarId, VObjectUtils::uid2uri($event['uid'], '.ics'), )); // get tags/categories from relations $this->load_tags($event); // default response return array( 'id' => $event['uid'], 'uri' => VObjectUtils::uid2uri($event['uid'], '.ics'), 'lastmodified' => $event['changed'] ? $event['changed']->format('U') : null, 'calendarid' => $calendarId, 'calendardata' => $this->_to_ical($event, $base_uri, $storage), 'etag' => self::_get_etag($event), ); } return array(); } /** * Creates a new calendar object. * * It is possible return an etag from this function, which will be used in * the response to this PUT request. Note that the ETag must be surrounded * by double-quotes. * * However, you should only really return this ETag if you don't mangle the * calendar-data. If the result of a subsequent GET to this object is not * the exact same as this request body, you should omit the ETag. * * @param mixed $calendarId * @param string $objectUri * @param string $calendarData * @return string|null */ public function createCalendarObject($calendarId, $objectUri, $calendarData) { console(__METHOD__, $calendarId, $objectUri, $calendarData); $uid = VObjectUtils::uri2uid($objectUri, '.ics'); $storage = $this->get_storage_folder($calendarId); $object = $this->parse_calendar_data($calendarData, $uid); if (empty($object) || empty($object['uid'])) { throw new DAV\Exception('Parse error: not a valid iCalendar 2.0 object'); } // if URI doesn't match the content's UID, the object might already exist! if ($object['uid'] != $uid && $storage->get_object($object['uid'])) { $objectUri = VObjectUtils::uid2uri($object['uid'], '.ics'); Plugin::$redirect_basename = $objectUri; return $this->updateCalendarObject($calendarId, $objectUri, $calendarData); } // map attachments attribute $object['_attachments'] = $object['attachments']; unset($object['attachments']); // remove categories from object data (only for tasks yet) if ($object['_type'] == 'task') { $tags = (array)$object['categories']; unset($object['categories']); } $success = $storage->save($object, $object['_type']); if (!$success) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving $object[_type] object to Kolab server"), true, false); throw new DAV\Exception('Error saving calendar object to backend'); } // save tag relations on success (only available for tasks yet) if ($object['_type'] == 'task') { $this->save_tags($uid, $tags); $object['categories'] = $tags; // add again for etag computation } // send Location: header if URI doesn't match object's UID (Bug #2109) if ($object['uid'] != $uid) { Plugin::$redirect_basename = VObjectUtils::uid2uri($object['uid'], '.ics'); } // return new Etag return $success ? self::_get_etag($object) : null; } /** * Updates an existing calendarobject, based on it's uri. * * It is possible return an etag from this function, which will be used in * the response to this PUT request. Note that the ETag must be surrounded * by double-quotes. * * However, you should only really return this ETag if you don't mangle the * calendar-data. If the result of a subsequent GET to this object is not * the exact same as this request body, you should omit the ETag. * * @param mixed $calendarId * @param string $objectUri * @param string $calendarData * @return string|null */ public function updateCalendarObject($calendarId, $objectUri, $calendarData) { console(__METHOD__, $calendarId, $objectUri, $calendarData); $uid = VObjectUtils::uri2uid($objectUri, '.ics'); $storage = $this->get_storage_folder($calendarId); $object = $this->parse_calendar_data($calendarData, $uid); if (empty($object)) { throw new DAV\Exception('Parse error: not a valid iCalendar 2.0 object'); } // sanity check if ($object['uid'] != $uid) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error creating calendar object: UID doesn't match object URI"), true, false); throw new DAV\Exception\NotFound("UID doesn't match object URI"); } // 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; } // process attachments if (/* user agent known to handle attachments inline */ !empty($object['attachments'])) { $object['_attachments'] = $object['attachments']; unset($object['attachments']); // mark all existing attachments as deleted (update is always absolute) foreach ($old['_attachments'] as $key => $attach) { $object['_attachments'][$key] = false; } } // remove categories from object data (only for tasks yet) if ($object['_type'] == 'task') { $tags = (array)$object['categories']; unset($object['categories']); } // save object $saved = $storage->save($object, $object['_type'], $uid); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving event object to Kolab server"), true, false); Plugin::$redirect_basename = null; throw new DAV\Exception('Error saving event object to backend'); } // save tag relations on success (only available for tasks yet) if ($object['_type'] == 'task') { $this->save_tags($uid, $tags); $object['categories'] = $tags; // add again for etag computation } // return new Etag return self::_get_etag($object); } /** * Deletes an existing calendar object. * * @param mixed $calendarId * @param string $objectUri * @return void */ public function deleteCalendarObject($calendarId, $objectUri) { console(__METHOD__, $calendarId, $objectUri); $uid = VObjectUtils::uri2uid($objectUri, '.ics'); if ($storage = $this->get_storage_folder($calendarId)) { if ($storage->delete($uid)) { $this->save_tags($uid, null); } } } /** * Performs a calendar-query on the contents of this calendar. * * The calendar-query is defined in RFC4791 : CalDAV. Using the * calendar-query it is possible for a client to request a specific set of * object, based on contents of iCalendar properties, date-ranges and * iCalendar component types (VTODO, VEVENT). * * This method should just return a list of (relative) urls that match this * query. * * The list of filters are specified as an array. The exact array is * documented by Sabre\CalDAV\CalendarQueryParser. * * Note that it is extremely likely that getCalendarObject for every path * returned from this method will be called almost immediately after. You * may want to anticipate this to speed up these requests. * * Requests that are extremely common are: * * requests for just VEVENTS * * requests for just VTODO * * requests with a time-range-filter on either VEVENT or VTODO. * * ..and combinations of these requests. It may not be worth it to try to * handle every possible situation and just rely on the (relatively * easy to use) CalendarQueryValidator to handle the rest. * * Note that especially time-range-filters may be difficult to parse. A * time-range filter specified on a VEVENT must for instance also handle * recurrence rules correctly. * A good example of how to interprete all these filters can also simply * be found in Sabre\CalDAV\CalendarQueryFilter. This class is as correct * as possible, so it gives you a good idea on what type of stuff you need * to think of. * * @param mixed $calendarId * @param array $filters * @return array */ public function calendarQuery($calendarId, array $filters) { console(__METHOD__, $calendarId, $filters); // build kolab storage query from $filters $query = $this->_event_filter_query(); foreach ((array)$filters['comp-filters'] as $filter) { if ($filter['name'] != 'VEVENT') continue; if (is_array($filter['time-range'])) { if (!empty($filter['time-range']['end'])) { $query[] = array('dtstart', '<=', $filter['time-range']['end']); } if (!empty($filter['time-range']['start'])) { $query[] = array('dtend', '>=', $filter['time-range']['start']); } } } $results = array(); if ($storage = $this->get_storage_folder($calendarId)) { foreach ($storage->select($query) as $event) { // post-filter events to suppress declined invitations if ($this->_event_filter_compare($event)) { $results[] = $event['uid'] . '.ics'; } } } return $results; } /** * Set User-Agent string of the connected client */ public function setUserAgent($uastring) { $ua_classes = array( 'ical' => 'iCal/\d', 'outlook' => 'iCal4OL/\d', 'lightning' => 'Lightning/\d', ); foreach ($ua_classes as $class => $regex) { if (preg_match("!$regex!", $uastring)) { $this->useragent = $class; break; } } } /********** Data conversion utilities ***********/ /** * Get object tags */ private function load_tags(&$event) { // tag relations are only available for tasks yet if ($event['_type'] != 'task') { return; } $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($event['uid']); if (!empty($tags)) { $event['categories'] = array(); } foreach ($tags as $tag) { $event['categories'][] = $tag['name']; // modify changed time if relation is newer if ($tag['changed'] && !$event['changed'] || $tag['changed'] > $event['changed']) { $event['changed'] = $tag['changed']; } } } /** * Update object tags */ private function save_tags($uid, $tags) { $config = kolab_storage_config::get_instance(); $config->save_tags($uid, $tags); } /** * Parse the given iCal string into a hash array kolab_format_event can handle * * @param string iCal data block * @return array Hash array with event properties or null on failure */ private function parse_calendar_data($calendarData, $uid) { try { $ical = libcalendaring::get_ical(); // use already parsed object if (Plugin::$parsed_vevent && Plugin::$parsed_vevent->UID == $uid) { $objects = $ical->import_from_vobject(Plugin::$parsed_vcalendar); } else { $objects = $ical->import($calendarData); } // return the first object if (count($objects)) { return $objects[0]; } } catch (VObject\ParseException $e) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "iCal data parse error: " . $e->getMessage()), true, false); } return null; } /** * Build a valid iCal format block from the given event * * @param array Hash array with event/task properties from libkolab * @param string Absolute URI referenceing this event object * @param object RECURRENCE-ID property when serializing a recurrence exception * @return mixed VCALENDAR string containing the VEVENT data * or VObject\VEvent object with a recurrence exception instance * @see: \libvcalendar::export() */ private function _to_ical($event, $base_uri, $storage, $recurrence_id = null) { $ical = libcalendaring::get_ical(); $ical->set_prodid('-//Kolab//iRony DAV Server ' . KOLAB_DAV_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'); $ical->set_agent($this->useragent == 'ical' ? 'Apple' : ''); // list attachments as absolute URIs for Thunderbird if ($this->useragent == 'lightning') { $ical->set_attach_uri($base_uri . ':attachment:{{id}}:{{name}}'); $get_attachment = null; } else { // embed attachments for others $get_attachment = function($id, $event) use ($storage) { return $storage->get_attachment($event['uid'], $id); }; } return $ical->export(array($event), null, false, $get_attachment, false); } /** * Wrapper for libcalendaring::get_user_emails() */ private function get_user_emails() { $emails = libcalendaring::get_instance()->get_user_emails(); if (empty($emails)) { $emails = array(HTTPBasic::$current_user); } return $emails; } /** * Provide basic query for kolab_storage_folder::select() */ private function _event_filter_query() { // get email addresses of the current user $user_emails = $this->get_user_emails(); $query = array(); // add query to exclude declined invitations foreach ($user_emails as $email) { $query[] = array('tags', '!=', 'x-partstat:' . $email . ':declined'); // $query[] = array('tags', '!=', 'x-partstat:' . $email . ':needs-action'); } return $query; } /** * Check the given event if it matches the filter * * @return boolean True if matches, false if not */ private function _event_filter_compare($event) { static $user_emails; if (!is_array($user_emails)) { $user_emails = $this->get_user_emails(); } if (is_array($event['attendees'])) { foreach ($event['attendees'] as $attendee) { if (in_array($attendee['email'], $user_emails) && $attendee['status'] == 'DECLINED') { return false; } } } return true; } /** * Generate an Etag string from the given event data * * @param array Hash array with event properties from libkolab * @return string Etag string */ private static function _get_etag($event) { return sprintf('"%s-%d-%s"', substr(md5($event['uid']), 0, 16), $event['_msguid'], !empty($event['categories']) ? substr(md5(join(',', (array)$event['categories'])), 0, 16) : '0' ); } } diff --git a/lib/Kolab/CalDAV/Plugin.php b/lib/Kolab/CalDAV/Plugin.php index 5f8d7f2..815f37b 100644 --- a/lib/Kolab/CalDAV/Plugin.php +++ b/lib/Kolab/CalDAV/Plugin.php @@ -1,239 +1,239 @@ * * 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\CalDAV; use Sabre\DAV; use Sabre\CalDAV; use Sabre\VObject; use Kolab\DAV\Auth\HTTPBasic; /** * Extended CalDAV plugin to tweak data validation */ class Plugin extends CalDAV\Plugin { // make already parsed text/calednar blocks available for later use public static $parsed_vcalendar; public static $parsed_vevent; // allow the backend to force a redirect Location public static $redirect_basename; /** * Initializes the plugin * * @param DAV\Server $server * @return void */ public function initialize(DAV\Server $server) { parent::initialize($server); - $server->subscribeEvent('afterCreateFile', array($this, 'afterWriteContent')); - $server->subscribeEvent('afterWriteContent', array($this, 'afterWriteContent')); + $server->on('afterCreateFile', array($this, 'afterWriteContent')); + $server->on('afterWriteContent', array($this, 'afterWriteContent')); } /** * 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 string $path * @return void */ protected function validateICalendar(&$data, $path) { // If it's a stream, we convert it to a string first. if (is_resource($data)) { $data = stream_get_contents($data); } // Converting the data to unicode, if needed. $data = DAV\StringUtil::ensureUTF8($data); try { // modification: Set options to be more tolerant when parsing extended or invalid properties $vobj = VObject\Reader::read($data, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); // keep the parsed object in memory for later processing if ($vobj->name == 'VCALENDAR') { self::$parsed_vcalendar = $vobj; foreach ($vobj->getBaseComponents() as $vevent) { if ($vevent->name == 'VEVENT' || $vevent->name == 'VTODO') { self::$parsed_vevent = $vevent; break; } } } } catch (VObject\ParseException $e) { throw new DAV\Exception\UnsupportedMediaType('This resource requires valid iCalendar 2.0 data. Parse error: ' . $e->getMessage()); } if ($vobj->name !== 'VCALENDAR') { throw new DAV\Exception\UnsupportedMediaType('This collection can only support iCalendar objects.'); } // Get the Supported Components for the target calendar list($parentPath,$object) = DAV\URLUtil::splitPath($path); $calendarProperties = $this->server->getProperties($parentPath,array('{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set')); $supportedComponents = $calendarProperties['{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set']->getValue(); $foundType = null; $foundUID = null; foreach($vobj->getComponents() as $component) { switch($component->name) { case 'VTIMEZONE': continue 2; case 'VEVENT': case 'VTODO': case 'VJOURNAL': if (is_null($foundType)) { $foundType = $component->name; if (!in_array($foundType, $supportedComponents)) { throw new CalDAV\Exception\InvalidComponentType('This calendar only supports ' . implode(', ', $supportedComponents) . '. We found a ' . $foundType); } if (!isset($component->UID)) { throw new DAV\Exception\BadRequest('Every ' . $component->name . ' component must have an UID'); } $foundUID = (string)$component->UID; } else { if ($foundType !== $component->name) { throw new DAV\Exception\BadRequest('A calendar object must only contain 1 component. We found a ' . $component->name . ' as well as a ' . $foundType); } if ($foundUID !== (string)$component->UID) { throw new DAV\Exception\BadRequest('Every ' . $component->name . ' in this object must have identical UIDs'); } } break; default: throw new DAV\Exception\BadRequest('You are not allowed to create components of type: ' . $component->name . ' here'); } } if (!$foundType) throw new DAV\Exception\BadRequest('iCalendar object must contain at least 1 of VEVENT, VTODO or VJOURNAL'); } /** * Returns a list of features for the DAV: HTTP header. * Including 'calendar-schedule' to enable scheduling support in Thunderbird Lightning. * * @return array */ public function getFeatures() { $features = parent::getFeatures(); $features[] = 'calendar-schedule'; return $features; } /** * Returns free-busy information for a specific address. The returned * data is an array containing the following properties: * * calendar-data : A VFREEBUSY VObject * request-status : an iTip status code. * href: The principal's email address, as requested * * @param string $email address * @param \DateTime $start * @param \DateTime $end * @param VObject\Component $request * @return array */ protected function getFreeBusyForEmail($email, \DateTime $start, \DateTime $end, VObject\Component $request) { console(__METHOD__, $email, $start, $end); $email = preg_replace('/^mailto:/', '', $email); // pass-through the pre-generatd free/busy feed from Kolab's free/busy service if ($fburl = \kolab_storage::get_freebusy_url($email)) { // use PEAR::HTTP_Request2 for data fetching // @include_once('HTTP/Request2.php'); try { $rcube = \rcube::get_instance(); $request = new \HTTP_Request2($fburl); $request->setConfig(array( 'store_body' => true, 'follow_redirects' => true, 'ssl_verify_peer' => $rcube->config->get('kolab_ssl_verify_peer', true), )); $response = $request->send(); // authentication required if ($response->getStatus() == 401) { $request->setAuth(HTTPBasic::$current_user, HTTPBasic::$current_pass); $response = $request->send(); } // success! if ($response->getStatus() == 200) { $vcalendar = VObject\Reader::read($response->getBody(), VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); return array( 'calendar-data' => $vcalendar, 'request-status' => '2.0;Success', 'href' => 'mailto:' . $email, ); } } catch (\Exception $e) { // log failures \rcube::raise_error($e, true, false); } } else { // generate free/busy data from this user's calendars return parent::getFreeBusyForEmail($email, $start, $end, $request); } // return "not found" return array( 'request-status' => '3.7;Could not find principal', 'href' => 'mailto:' . $email, ); } } \ No newline at end of file diff --git a/lib/Kolab/CardDAV/ContactsBackend.php b/lib/Kolab/CardDAV/ContactsBackend.php index 1a92fe0..2dff2c7 100644 --- a/lib/Kolab/CardDAV/ContactsBackend.php +++ b/lib/Kolab/CardDAV/ContactsBackend.php @@ -1,1138 +1,1153 @@ * * 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 \rcube; use \rcube_charset; use \kolab_storage; use Sabre\DAV; use Sabre\CardDAV; use Sabre\VObject; +use Sabre\VObject\DateTimeParser; use Kolab\Utils\DAVBackend; 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 { public static $supported_address_data = array( array('contentType' => 'text/vcard', 'version' => '3.0'), array('contentType' => 'text/vcard', 'version' => '4.0'), ); public $ldap_directory; private $sources; private $folders; private $aliases; private $useragent; private $subscribed = null; // mapping of labelled X-AB properties to known vcard fields private $xab_labelled_map = array( 'X-ABDATE' => array( 'anniversary' => 'X-ANNIVERSARY', ), 'X-ABRELATEDNAMES' => array( 'child' => 'X-CHILDREN', 'spouse' => 'X-SPOUSE', 'manager' => 'X-MANAGER', 'assistant' => 'X-ASSISTANT', ), ); // known labels need to be quoted specially with _$!< >!$_ private $xab_known_labels = array('anniversary','child','parent','mother','father','brother','sister','friend','spouse','manager','assistant','partner','other'); // mapping of related types to internal contact properties private $related_map = array( 'child' => 'children', 'spouse' => 'spouse', 'manager' => 'manager', 'assistant' => 'assistant', ); /** * 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->subscribed); $this->sources = $this->folders = $this->aliases = array(); foreach (kolab_storage::sort_folders($folders) as $folder) { $id = $folder->get_uid(); $fdata = $folder->get_imap_data(); // fetch IMAP folder data for CTag generation $this->folders[$id] = $folder; $this->sources[$id] = array( 'id' => $id, 'uri' => $id, '{DAV:}displayname' => html_entity_decode($folder->get_name(), ENT_COMPAT, RCUBE_CHARSET), '{http://calendarserver.org/ns/}getctag' => sprintf('%d-%d-%d', $fdata['UIDVALIDITY'], $fdata['HIGHESTMODSEQ'], $fdata['UIDNEXT']), '{urn:ietf:params:xml:ns:carddav}supported-address-data' => new CardDAV\Property\SupportedAddressData(self::$supported_address_data), ); $this->aliases[$folder->name] = $id; // map default folder to the magic 'all' resource if ($folder->default) $this->aliases['__all__'] = $id; } 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) { // resolve alias name if ($this->aliases[$id]) { $id = $this->aliases[$id]; } if ($this->folders[$id]) { return $this->folders[$id]; } else { return DAVBackend::get_storage_folder($id, 'contact'); } } /** * Returns the list of addressbooks for a specific user. * * @param string $principalUri * @return array */ public function getAddressBooksForUser($principalUri) { console(__METHOD__, $principalUri, $this->useragent); $this->_read_sources(); // special case for the apple address book which only supports one (!) address book if ($this->useragent == 'macosx' && count($this->sources) > 1) { $source = $this->getAddressBookByName('__all__'); $source['principaluri'] = $principalUri; return array($source); } $addressBooks = array(); foreach ($this->sources as $id => $source) { $source['principaluri'] = $principalUri; $addressBooks[] = $source; } return $addressBooks; } /** * Returns properties for a specific node identified by name/uri * * @param string Node name/uri * @return array Hash array with addressbook properties or null if not found */ public function getAddressBookByName($addressBookUri) { console(__METHOD__, $addressBookUri); $this->_read_sources(); $id = $addressBookUri; // return the magic *single* address book for Apple's Address Book App if ($id == '__all__') { $ctags = array(); foreach ($this->sources as $source) { $ctags[] = $source['{http://calendarserver.org/ns/}getctag']; } return array( 'id' => '__all__', 'uri' => '__all__', '{DAV:}displayname' => 'All', '{http://calendarserver.org/ns/}getctag' => join(':', $ctags), '{urn:ietf:params:xml:ns:carddav}supported-address-data' => new CardDAV\Property\SupportedAddressData(self::$supported_address_data), ); } // resolve aliases (addressbook by folder name) if ($this->aliases[$addressBookUri]) { $id = $this->aliases[$addressBookUri]; } // retry with subscribed = false (#2701) if (empty($this->sources[$id]) && $this->subscribed === null && rcube::get_instance()->config->get('kolab_use_subscriptions')) { $this->subscribed = false; unset($this->sources); return $this->getAddressBookByName($addressBookUri); } return $this->sources[$id]; } /** - * Updates an addressbook's properties + * Updates properties for an address book. * - * See Sabre\DAV\IProperties for a description of the mutations array, as - * well as the return value. + * The list of mutations is stored in a Sabre\DAV\PropPatch object. + * To do the actual updates, you must tell this object which properties + * you're going to process with the handle() method. * - * @param mixed $addressBookId - * @param array $mutations - * @see Sabre\DAV\IProperties::updateProperties - * @return bool|array + * Calling the handle method is like telling the PropPatch object "I + * promise I can handle updating this property". + * + * Read the PropPatch documenation for more info and examples. + * + * @param string $addressBookId + * @param \Sabre\DAV\PropPatch $propPatch + * @return void */ - public function updateAddressBook($addressBookId, array $mutations) + public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) { - console(__METHOD__, $addressBookId, $mutations); + console(__METHOD__, $addressBookId, $propPatch); if ($addressBookId == '__all__') return false; - $folder = $this->get_storage_folder($addressBookId); - return $folder ? DAVBackend::folder_update($folder, $mutations) : false; + if ($folder = $this->get_storage_folder($addressBookId)) { + DAVBackend::handle_propatch($folder, $propPatch); + } } /** * 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) { console(__METHOD__, $principalUri, $url, $properties); return DAVBackend::folder_create('contact', $properties, $url); } /** * Deletes an entire addressbook and all its contents * * @param int $addressBookId * @return void */ public function deleteAddressBook($addressBookId) { console(__METHOD__, $addressBookId); if ($addressBookId == '__all__') return; $folder = $this->get_storage_folder($addressBookId); if ($folder && !kolab_storage::folder_delete($folder->name)) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting calendar folder $folder->name"), true, false); } } /** * 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); // recursively fetch contacts from all folders if ($addressBookId == '__all__') { $cards = array(); foreach ($this->sources as $id => $source) { $cards = array_merge($cards, $this->getCards($id)); } return $cards; } $groups_support = $this->useragent != 'thunderbird'; $query = array(array('type', '=', $groups_support ? array('contact','distribution-list') : 'contact')); $cards = array(); if ($storage = $this->get_storage_folder($addressBookId)) { foreach ($storage->select($query) as $contact) { $cards[] = array( 'id' => $contact['uid'], 'uri' => VObjectUtils::uid2uri($contact['uid'], '.vcf'), 'lastmodified' => is_a($contact['changed'], 'DateTime') ? $contact['changed']->format('U') : null, '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 = VObjectUtils::uri2uid($cardUri, '.vcf'); // search all folders for the given card if ($addressBookId == '__all__') { $contact = $this->get_card_by_uid($uid, $storage); } // read card data from LDAP directory else if ($addressBookId == LDAPDirectory::DIRECTORY_NAME) { if (is_object($this->ldap_directory)) { $contact = $this->ldap_directory->getContactObject($uid); } } else { $storage = $this->get_storage_folder($addressBookId); $contact = $storage->get_object($uid, '*'); } if ($contact) { return array( 'id' => $contact['uid'], 'uri' => VObjectUtils::uid2uri($contact['uid'], '.vcf'), 'lastmodified' => is_a($contact['changed'], 'DateTime') ? $contact['changed']->format('U') : null, '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 = VObjectUtils::uri2uid($cardUri, '.vcf'); $storage = $this->get_storage_folder($addressBookId); $object = $this->parse_vcard($cardData, $uid); if (empty($object) || empty($object['uid'])) { throw new DAV\Exception('Parse error: not a valid VCard object'); } // if URI doesn't match the content's UID, the object might already exist! $cardUri = VObjectUtils::uid2uri($object['uid'], '.vcf'); if ($object['uid'] != $uid && $this->getCard($addressBookId, $cardUri)) { Plugin::$redirect_basename = $cardUri; return $this->updateCard($addressBookId, $cardUri, $cardData); } $success = $storage->save($object, $object['_type']); if (!$success) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to Kolab server"), true, false); throw new DAV\Exception('Error saving contact card to backend'); } // send Location: header if URI doesn't match object's UID (Bug #2109) if ($object['uid'] != $uid) { Plugin::$redirect_basename = $cardUri; } // 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 = VObjectUtils::uri2uid($cardUri, '.vcf'); $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); throw new DAV\Exception\NotFound("UID doesn't match object URI"); } if ($addressBookId == '__all__') { $old = $this->get_card_by_uid($uid, $storage); } else { if ($storage = $this->get_storage_folder($addressBookId)) $old = $storage->get_object($uid); } if (!$storage) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Unable to find storage folder for contact $addressBookId/$cardUri"), true, false); throw new DAV\Exception\NotFound("Invalid address book URI"); } if (!$this->is_writeable($storage)) { throw new DAV\Exception\Forbidden('Insufficient privileges to update this card'); } // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($object[$key]) && $key[0] == '_') $object[$key] = $val; } // save object $saved = $storage->save($object, $object['_type'], $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); Plugin::$redirect_basename = null; throw new DAV\Exception('Error saving contact card to backend'); } // 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); $uid = VObjectUtils::uri2uid($cardUri, '.vcf'); if ($addressBookId == '__all__') { $this->get_card_by_uid($uid, $storage); } else { $storage = $this->get_storage_folder($addressBookId); } if (!$storage || !$this->is_writeable($storage)) { throw new DAV\Exception\MethodNotAllowed('Insufficient privileges to delete this card'); } if ($storage) { return $storage->delete($uid); } return false; } /** * Set User-Agent string of the connected client */ public function setUserAgent($uastring) { $ua_classes = array( 'thunderbird' => 'Thunderbird/\d', 'macosx' => '(Mac OS X/.+)?AddressBook/\d(.+\sCardDAVPlugin)?', 'ios' => '(iOS/\d|[Dd]ata[Aa]ccessd/\d)', 'vcard4' => '[Vv][Cc]ard([/ ])?4', ); foreach ($ua_classes as $class => $regex) { if (preg_match("!$regex!", $uastring)) { $this->useragent = $class; break; } } } /** * Find an object and the containing folder by UID * * @param string Object UID * @param object Return parameter for the kolab_storage_folder instance * @return array|false */ private function get_card_by_uid($uid, &$storage) { $obj = kolab_storage::get_object($uid, 'contact'); if ($obj) { $storage = kolab_storage::get_folder($obj['_mailbox']); return $obj; } return false; } /** * Internal helper method to determine whether the given kolab_storage_folder is writeable * */ private function is_writeable($storage) { $rights = $storage->get_myrights(); return (strpos($rights, 'i') !== false || $storage->get_namespace() == 'personal'); } /** * Helper method to determine whether the connected client is an Apple device */ private function is_apple() { return $this->useragent == 'macosx' || $this->useragent == 'ios'; } /** * Helper method to determine whether the connected client supports VCard4 */ private function is_vcard4() { return $this->useragent == 'vcard4'; } /********** Data conversion utilities ***********/ private $phonetypes = array( 'main' => 'voice', 'homefax' => 'fax', 'workfax' => 'fax', 'mobile' => 'cell', 'other' => 'textphone', ); private $improtocols = array( 'jabber' => 'xmpp', ); /** * 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 { // use already parsed object if (Plugin::$parsed_vcard && Plugin::$parsed_vcard->UID == $uid) { $vobject = Plugin::$parsed_vcard; } else { 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 */ public function to_vcard($contact) { $v4 = $this->is_vcard4(); $v4_prefix = $v4 ? '' : 'X-'; - $vc = VObject\Component::create('VCARD'); - $vc->version = $v4 ? '4.0' : '3.0'; - $vc->prodid = '-//Kolab//iRony DAV Server ' . KOLAB_DAV_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; + $vc = new VObject\Component\VCard(); + $vc->VERSION = $v4 ? '4.0' : '3.0'; + $vc->PRODID = '-//Kolab//iRony DAV Server ' . KOLAB_DAV_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; $vc->add('UID', $contact['uid']); $vc->add('FN', $contact['name']); // distlists are KIND:group if ($contact['_type'] == 'distribution-list') { // group cards are actually vcard version 4 if (!$this->is_apple()) { $vc->version = '4.0'; $prop_prefix = ''; } else { // prefix group properties for Apple $prop_prefix = 'X-ADDRESSBOOKSERVER-'; } $vc->add($prop_prefix . 'KIND', 'group'); foreach ((array)$contact['member'] as $member) { if ($member['uid']) $value = 'urn:uuid:' . $member['uid']; else if ($member['email'] && $member['name']) $value = 'mailto:' . urlencode(sprintf('"%s" <%s>', addcslashes($member['name'], '"'), $member['email'])); else if ($member['email']) $value = 'mailto:' . $member['email']; $vc->add($prop_prefix . 'MEMBER', $value); } } else if ($contact['surname'] . $contact['firstname'] . $contact['middlename'] . $contact['prefix'] . $contact['suffix'] != '') { - $n = VObject\Property::create('N'); - $n->setParts(array($contact['surname'], $contact['firstname'], $contact['middlename'], $contact['prefix'], $contact['suffix'])); + $n = $vc->create('N'); + $n->setParts(array(strval($contact['surname']), strval($contact['firstname']), strval($contact['middlename']), strval($contact['prefix']), strval($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('ROLE', $contact['profession']); if (!empty($contact['organization']) || !empty($contact['department'])) { - $org = VObject\Property::create('ORG'); + $org = $vc->create('ORG'); $org->setParts(array($contact['organization'], $contact['department'])); $vc->add($org); } // save as RELATED for VCard 4.0 if ($v4) { foreach ($this->related_map as $type => $field) { if (!empty($contact[$field])) { foreach ((array)$contact[$field] as $value) { - $vc->add(VObject\Property::create('RELATED', $value, array('type' => $type))); + $vc->add($vc->create('RELATED', $value, array('type' => $type))); } } } if (is_array($contact['related'])) { foreach ($contact['related'] as $value) { $vc->add('RELATED', $value); } } } else { foreach (array_values($this->related_map) as $field) { if (!empty($contact[$field])) { $vc->add(strtoupper('X-' . $field), join(',', (array)$contact[$field])); } } } foreach ((array)$contact['email'] as $email) { - $vemail = VObject\Property::create('EMAIL', $email['address'], array('type' => 'INTERNET')); - if (!empty($email['type'])) - $vemail->offsetSet(null, new VObject\Parameter('type', strtoupper($email['type']))); + $vemail = $vc->create('EMAIL', $email['address'], array('type' => 'INTERNET')); + if (!empty($email['type'])) { + $vemail['type']->addValue(strtoupper($email['type'])); + } $vc->add($vemail); } 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']))); } $improtocolmap = array_flip($this->improtocols); foreach ((array)$contact['im'] as $im) { list($prot, $val) = explode(':', $im, 2); if ($val) $vc->add('x-' . ($improtocolmap[$prot] ?: $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 = $vc->create('ADR', null, array('type' => strtoupper($adr['type']))); $vadr->setParts(array('','', $adr['street'], $adr['locality'], $adr['region'], $adr['code'], $adr['country'])); $vc->add($vadr); } if (!empty($contact['notes'])) $vc->add('NOTE', $contact['notes']); if (!empty($contact['gender'])) $vc->add($this->is_apple() ? 'SEX' : $v4_prefix . 'GENDER', $contact['gender']); // convert date cols to DateTime objects foreach (array('birthday','anniversary') as $key) { if (!empty($contact[$key]) && !$contact[$key] instanceof \DateTime) { try { $contact[$key] = new \DateTime(\rcube_utils::clean_datestr($contact[$key])); } catch (\Exception $e) { $contact[$key] = null; } } } if (!empty($contact['birthday']) && $contact['birthday'] instanceof \DateTime) { // FIXME: Date values are ignored by Thunderbird $contact['birthday']->_dateonly = true; - $vc->add(VObjectUtils::datetime_prop('BDAY', $contact['birthday'], false)); + $vc->add(VObjectUtils::datetime_prop($vc, 'BDAY', $contact['birthday'], false)); } if (!empty($contact['anniversary']) && $contact['anniversary'] instanceof \DateTime) { $contact['anniversary']->_dateonly = true; - $vc->add(VObjectUtils::datetime_prop($v4_prefix . 'ANNIVERSARY', $contact['anniversary'], false)); + $vc->add(VObjectUtils::datetime_prop($vc, $v4_prefix . 'ANNIVERSARY', $contact['anniversary'], false)); } if (!empty($contact['categories'])) { - $cat = VObject\Property::create('CATEGORIES'); + $cat = $vc->create('CATEGORIES'); $cat->setParts((array)$contact['categories']); $vc->add($cat); } if (!empty($contact['freebusyurl'])) { $vc->add('FBURL', $contact['freebusyurl']); } if (is_array($contact['lang'])) { foreach ($contact['lang'] as $value) { $vc->add('LANG', $value); } } if (!empty($contact['photo'])) { - $vc->PHOTO = base64_encode($contact['photo']); - $vc->PHOTO->add('BASE64', null); + $vc->PHOTO = $contact['photo']; + $vc->PHOTO['ENCODING'] = 'b'; } // add custom properties foreach ((array)$contact['x-custom'] as $prop) { $vc->add($prop[0], $prop[1]); } // send some known fields as itemN.X-AB* for Apple clients if ($this->is_apple()) { $this->_to_apple($contact, $vc); } - if (!empty($contact['changed'])) - $vc->add(VObjectUtils::datetime_prop('REV', $contact['changed'], true)); + if (!empty($contact['changed']) && is_a($contact['changed'], 'DateTime')) + $vc->REV = $contact['changed']->format('Ymd\\THis\\Z'); + + // convert to VCard4.0 + // if ($v4) { + // $vc->convert(VObject\Document::VCARD40); + //} 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( '_type' => 'contact', 'uid' => strval($vc->UID), 'name' => strval($vc->FN), 'x-custom' => array(), ); if ($vc->REV) { - try { $contact['changed'] = $vc->REV->getDateTime(); } + try { + $contact['changed'] = DateTimeParser::parseDateTime(strval($vc->REV)); + } catch (\Exception $e) { - try { $contact['changed'] = new \DateTime(strval($vc->REV)); } - catch (\Exception $e) { } + // ignore } } // normalize apple-style properties $this->_from_apple($vc); $phonetypemap = array_flip($this->phonetypes); // map attributes to internal fields foreach ($vc->children as $prop) { if (!($prop instanceof VObject\Property)) continue; + $value = strval($prop); + 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; + $contact['notes'] = $value; break; case 'TITLE': - $contact['jobtitle'] = $prop->value; + $contact['jobtitle'] = $value; break; case 'NICKNAME': - $contact[strtolower($prop->name)] = $prop->value; + $contact[strtolower($prop->name)] = $value; break; case 'ORG': list($contact['organization'], $contact['department']) = $prop->getParts(); break; case 'CATEGORY': case 'CATEGORIES': $contact['categories'] = $prop->getParts(); break; case 'EMAIL': $types = array_values(self::array_filter($prop->offsetGet('type'), 'internet,pref', true)); - $contact['email'][] = array('address' => $prop->value, 'type' => strtolower($types[0] ?: 'other')); + $contact['email'][] = array('address' => $value, 'type' => strtolower($types[0] ?: 'other')); break; case 'URL': $types = array_values(self::array_filter($prop->offsetGet('type'), 'internet,pref', true)); - $contact['website'][] = array('url' => $prop->value, 'type' => strtolower($types[0])); + $contact['website'][] = array('url' => $value, 'type' => strtolower($types[0])); break; case 'TEL': $types = array_values(self::array_filter($prop->offsetGet('type'), 'internet,pref', true)); $type = strtolower($types[0]); - $contact['phone'][] = array('number' => $prop->value, 'type' => $phonetypemap[$type] ?: $type); + $contact['phone'][] = array('number' => $value, 'type' => $phonetypemap[$type] ?: $type); break; case 'ADR': $type = $prop->offsetGet('type') ?: $prop->parameters[0]; - $adr = array('type' => strtolower($type->value ?: $type->name)); + $adr = array('type' => strtolower(strval($type) ?: $type->name)); list(,, $adr['street'], $adr['locality'], $adr['region'], $adr['code'], $adr['country']) = $prop->getParts(); $contact['address'][] = $adr; break; case 'BDAY': - $contact['birthday'] = new \DateTime($prop->value); + $contact['birthday'] = new \DateTime($value); $contact['birthday']->_dateonly = true; break; case 'ANNIVERSARY': case 'X-ANNIVERSARY': - $contact['anniversary'] = new \DateTime($prop->value); + $contact['anniversary'] = new \DateTime($value); $contact['anniversary']->_dateonly = true; break; case 'SEX': case 'GENDER': case 'X-GENDER': - $contact['gender'] = $prop->value; + $contact['gender'] = $value; break; case 'ROLE': case 'X-PROFESSION': - $contact['profession'] = $prop->value; + $contact['profession'] = $value; break; case 'X-MANAGER': case 'X-ASSISTANT': case 'X-CHILDREN': case 'X-SPOUSE': - $contact[strtolower(substr($prop->name, 2))] = explode(',', $prop->value); + $contact[strtolower(substr($prop->name, 2))] = explode(',', $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'][] = ($this->improtocols[$protocol] ?: $protocol) . ':' . preg_replace('/^[a-z]+:/i', '', $prop->value); + $contact['im'][] = ($this->improtocols[$protocol] ?: $protocol) . ':' . preg_replace('/^[a-z]+:/i', '', $value); break; case 'IMPP': $prot = null; - if (preg_match('/^[a-z]+:/i', $prop->value)) - list($prot, $val) = explode(':', $prop->value, 2); + if (preg_match('/^[a-z]+:/i', $value)) + list($prot, $val) = explode(':', $value, 2); else - $val = $prop->value; + $val = $value; $type = strtolower((string)$prop->offsetGet('X-SERVICE-TYPE')); $protocol = $type && (!$prot || $prot == 'aim') ? ($this->improtocols[$type] ?: $type) : $prot; $contact['im'][] = ($this->improtocols[$protocol] ?: $protocol) . ':' . urldecode($val); break; case 'PHOTO': $param = $prop->offsetGet('encoding') ?: $prop->parameters[0]; - if ($param->value && (strtolower($param->value) == 'b' || strtolower($param->value) == 'base64') || strtolower($param->name) == 'base64') { - $contact['photo'] = base64_decode($prop->value); + if (($pvalue = $param->getValue()) && (strtolower($pvalue) == 'b' || strtolower($pvalue) == 'base64') || strtolower($param->name) == 'base64') { + $contact['photo'] = $value; } break; // VCard 4.0 properties case 'FBURL': - $contact['freebusyurl'] = $prop->value; + $contact['freebusyurl'] = $value; break; case 'LANG': - $contact['lang'][] = $prop->value; + $contact['lang'][] = $value; break; case 'RELATED': $type = strtolower($prop->offsetGet('type')); if ($field = $this->related_map[$type]) { - $contact[$field][] = $prop->value; + $contact[$field][] = $value; } else { - $contact['related'][] = $prop->value; + $contact['related'][] = $value; } break; case 'KIND': case 'X-ADDRESSBOOKSERVER-KIND': - if (strtolower($prop->value) == 'group') { + if (strtolower($value) == 'group') { $contact['_type'] = 'distribution-list'; } break; case 'MEMBER': case 'X-ADDRESSBOOKSERVER-MEMBER': - if (strpos($prop->value, 'urn:uuid:') === 0) { - $contact['member'][] = array('uid' => substr($prop->value, 9)); + if (strpos($value, 'urn:uuid:') === 0) { + $contact['member'][] = array('uid' => substr($value, 9)); } - else if (strpos($prop->value, 'mailto:') === 0) { - $member = reset(\rcube_mime::decode_address_list(urldecode(substr($prop->value, 7)))); + else if (strpos($value, 'mailto:') === 0) { + $member = reset(\rcube_mime::decode_address_list(urldecode(substr($value, 7)))); if ($member['mailto']) $contact['member'][] = array('email' => $member['mailto'], 'name' => $member['name']); } break; // custom properties case 'CUSTOM1': case 'CUSTOM2': case 'CUSTOM3': case 'CUSTOM4': default: if (substr($prop->name, 0, 2) == 'X-' || substr($prop->name, 0, 6) == 'CUSTOM') { $prefix = $prop->group ? $prop->group . '.' : ''; - $contact['x-custom'][] = array($prefix . $prop->name, strval($prop->value)); + $contact['x-custom'][] = array($prefix . $prop->name, strval($value)); } break; } } if (is_array($contact['im'])) $contact['im'] = array_unique($contact['im']); return $contact; } /** * Convert Apple-style item1.X-AB* properties to flat X-AB*-Label values */ private function _from_apple($vc) { foreach ($this->xab_labelled_map as $propname => $known_map) { foreach ($vc->select($propname) as $prop) { $labelkey = $prop->group ? $prop->group . '.X-ABLABEL' : 'X-ABLABEL'; $labels = $vc->select($labelkey); - $field = !empty($labels) && ($label = reset($labels)) ? strtolower(trim($label->value, '_$!<>')) : null; + $field = !empty($labels) && ($label = reset($labels)) ? strtolower(trim(strval($label), '_$!<>')) : null; if ($field) { $prop->group = null; $prop->name = ($known_map[$field] ?: $propname . '-' . strtoupper($field)); unset($vc->{$labelkey}); } } // must be an apple client :-) $this->useragent = 'macosx'; } } /** * Translate custom fields back to Apple-style item1.X-AB* properties */ private function _to_apple($contact, $vc) { $this->item_count = 1; foreach ($this->xab_labelled_map as $propname => $known_map) { // convert known vcard properties into labelled ones foreach (array_flip($known_map) as $name => $label) { if ($vc->{$name}) { $this->_replace_with_labelled_prop($vc, $name, $propname, $label); } } // translate custom properties with a matching prefix to labelled items foreach ((array)$contact['x-custom'] as $prop) { $name = $prop[0]; if (strpos($name, $propname) === 0) { $label = strtolower(substr($name, strlen($propname)+1)); $this->_replace_with_labelled_prop($vc, $name, $propname, $label); } } } } /** * Helper method to replace a named property with a labelled one */ private function _replace_with_labelled_prop($vc, $name, $propname, $label) { $group = 'item' . ($this->item_count++); $prop = clone $vc->{$name}; $prop->name = $propname; $prop->group = $group; $vc->add($prop); - $ablabel = new VObject\Property('X-ABLabel'); - $ablabel->name = 'X-ABLabel'; + $ablabel = $vc->create('X-ABLabel'); $ablabel->group = $group; - $ablabel->value = in_array($label, $this->xab_known_labels) ? '_$!<'.ucfirst($label).'>!$_' : ucfirst($label); + $ablabel->setValue(in_array($label, $this->xab_known_labels) ? '_$!<'.ucfirst($label).'>!$_' : ucfirst($label)); $vc->add($ablabel); unset($vc->{$name}); } /** * 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); if (!empty($arr)) { 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), $contact['_msguid']); } } diff --git a/lib/Kolab/CardDAV/LDAPDirectory.php b/lib/Kolab/CardDAV/LDAPDirectory.php index 4030e11..7968ab9 100644 --- a/lib/Kolab/CardDAV/LDAPDirectory.php +++ b/lib/Kolab/CardDAV/LDAPDirectory.php @@ -1,578 +1,577 @@ * * 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\Property; 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'; private $config; private $ldap; private $carddavBackend; private $principalUri; private $addressBookInfo = array(); private $cache; 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:carddav}supported-address-data' => new 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 */ 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 = 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\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, + * Updates properties on this node. * - * @param array $mutations - * @return bool|array + * @param PropPatch $propPatch + * @return void */ - function updateProperties($mutations) + public function propPatch(DAV\PropPatch $propPatch) { - console(__METHOD__, $mutations); - return false; + 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 { $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 */ 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 1441d29..ef8a0ec 100644 --- a/lib/Kolab/CardDAV/Plugin.php +++ b/lib/Kolab/CardDAV/Plugin.php @@ -1,204 +1,204 @@ * * 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; /** * 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; /** * Initializes the plugin * * @param DAV\Server $server * @return void */ public function initialize(DAV\Server $server) { parent::initialize($server); - $server->subscribeEvent('beforeMethod', array($this, 'beforeMethod')); - $server->subscribeEvent('afterCreateFile', array($this, 'afterWriteContent')); - $server->subscribeEvent('afterWriteContent', array($this, 'afterWriteContent')); + $server->on('beforeMethod', array($this, 'beforeMethod')); + $server->on('afterCreateFile', array($this, 'afterWriteContent')); + $server->on('afterWriteContent', array($this, 'afterWriteContent')); } /** * 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('kolabdav_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) { if ($method == 'PUT' && $this->server->httpRequest->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($this->server->httpRequest->getHeader('User-Agent'), 'Thunderbird/') > 0) { unset($_SERVER['HTTP_IF_NONE_MATCH']); } } } /** * 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 * @return void */ protected function validateVCard(&$data) { // If it's a stream, we convert it to a string first. if (is_resource($data)) { $data = stream_get_contents($data); } // Converting the data to unicode, if needed. $data = DAV\StringUtil::ensureUTF8($data); try { VObject\Property::$classMap['REV'] = 'Sabre\\VObject\\Property\\DateTime'; $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.'); } } /** * 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 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'); } } } } // 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 6beb78a..56f76f0 100644 --- a/lib/Kolab/CardDAV/UserAddressBooks.php +++ b/lib/Kolab/CardDAV/UserAddressBooks.php @@ -1,136 +1,131 @@ * * 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 \rcube; use Sabre\DAV; use Sabre\DAVACL; use Sabre\CardDAV; /** * UserAddressBooks class * * The UserAddressBooks collection contains a list of addressbooks associated with a user */ class UserAddressBooks extends \Sabre\CardDAV\UserAddressBooks implements DAV\IExtendedCollection, DAV\IProperties, DAVACL\IACL { // pseudo-singleton instance private $ldap_directory; /** * Returns a list of addressbooks * * @return array */ public function getChildren() { $addressbooks = $this->carddavBackend->getAddressbooksForUser($this->principalUri); $objs = array(); foreach($addressbooks as $addressbook) { $objs[] = new AddressBook($this->carddavBackend, $addressbook); } if (rcube::get_instance()->config->get('kolabdav_ldap_directory')) { $objs[] = $this->getLDAPDirectory(); } return $objs; } /** * Returns a single addressbook, by name * * @param string $name * @return \AddressBook */ 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); } 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('kolabdav_ldap_directory'); $config['debug'] = $rcube->config->get('ldap_debug'); $this->ldap_directory = new LDAPDirectory($config, $this->principalUri, $this->carddavBackend); } return $this->ldap_directory; } /** * Returns the list of properties * * @param array $requestedProperties * @return array */ public function getProperties($requestedProperties) { // console(__METHOD__, $requestedProperties); $response = array(); foreach ($requestedProperties as $prop) { switch($prop) { case '{urn:ietf:params:xml:ns:carddav}supported-address-data': $response[$prop] = new CardDAV\Property\SupportedAddressData(ContactsBackend::$supported_address_data); break; } } return $response; } /** - * Updates properties such as the display name and description + * Updates properties on this node. * - * @param array $mutations - * @return array + * @param PropPatch $propPatch + * @return void */ - public function updateProperties($mutations) + public function propPatch(DAV\PropPatch $propPatch) { - $errors = array(); - - foreach ($mutations as $prop => $val) { - $errors[403][$prop] = null; - } - - return $errors; + console(__METHOD__, $propPatch); + // NOP } } diff --git a/lib/Kolab/DAVACL/PrincipalBackend.php b/lib/Kolab/DAVACL/PrincipalBackend.php index 908aca3..d22cd8e 100644 --- a/lib/Kolab/DAVACL/PrincipalBackend.php +++ b/lib/Kolab/DAVACL/PrincipalBackend.php @@ -1,255 +1,256 @@ * * 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\DAVACL; use \rcube; use Sabre\DAV\Exception; use Sabre\DAV\URLUtil; use Kolab\DAV\Auth\HTTPBasic; /** * Kolab Principal Backend */ -class PrincipalBackend implements \Sabre\DAVACL\PrincipalBackend\BackendInterface +class PrincipalBackend extends \Sabre\DAVACL\PrincipalBackend\AbstractBackend implements \Sabre\DAVACL\PrincipalBackend\BackendInterface { /** * Sets up the backend. */ public function __construct() { } /** * Returns a pricipal record for the currently authenticated user */ public function getCurrentUser() { // console(__METHOD__, HTTPBasic::$current_user); if (HTTPBasic::$current_user) { $user_email = rcube::get_instance()->get_user_email(); return array( 'uri' => 'principals/' . HTTPBasic::$current_user, '{DAV:}displayname' => HTTPBasic::$current_user, '{http://sabredav.org/ns}email-address' => $user_email, '{http://calendarserver.org/ns/}email-address-set' => $user_email, ); } return false; } /** * Returns a list of principals based on a prefix. * * This prefix will often contain something like 'principals'. You are only * expected to return principals that are in this base path. * * You are expected to return at least a 'uri' for every user, you can * return any additional properties if you wish so. Common properties are: * {DAV:}displayname * {http://sabredav.org/ns}email-address - This is a custom SabreDAV * field that's actualy injected in a number of other properties. If * you have an email address, use this property. * * @param string $prefixPath * @return array */ public function getPrincipalsByPrefix($prefixPath) { console(__METHOD__, $prefixPath); $principals = array(); if ($prefixPath == 'principals') { // TODO: list users from LDAP // we currently only advertise the authenticated user if ($user = $this->getCurrentUser()) { $principals[] = $user; } } return $principals; } /** * Returns a specific principal, specified by it's path. * The returned structure should be the exact same as from * getPrincipalsByPrefix. * * @param string $path * @return array */ public function getPrincipalByPath($path) { // console(__METHOD__, $path); list($prefix,$name) = explode('/', $path); if ($prefix == 'principals' && $name == HTTPBasic::$current_user) { return $this->getCurrentUser(); } else if ($prefix == 'principals' && \rcube_utils::check_email($name, false)) { // TODO: do a user lookup in LDAP list($localname,$domain) = explode('@', $name); return array( 'uri' => $path, '{DAV:}displayname' => $localname, '{http://sabredav.org/ns}email-address' => $name, '{http://calendarserver.org/ns/}email-address-set' => $name, ); } return null; } /** * Returns the list of members for a group-principal * * @param string $principal * @return array */ public function getGroupMemberSet($principal) { // TODO: for now the group principal has only one member, the user itself list($prefix, $name) = URLUtil::splitPath($principal); - $principal = $this->getPrincipalByPath($prefix); + $principal = $this->getPrincipalByPath($principal); if (!$principal) throw new Exception('Principal not found'); return array( $prefix ); } /** * Returns the list of groups a principal is a member of * * @param string $principal * @return array */ public function getGroupMembership($principal) { list($prefix,$name) = URLUtil::splitPath($principal); $group_membership = array(); if ($prefix == 'principals') { $principal = $this->getPrincipalByPath($principal); if (!$principal) throw new Exception('Principal not found'); // TODO: implement full calendar delegation (with information from LDAP kolabDelegate) return array( // Calendar delegation is not supported by our backend //'principals/'.$name.'/calendar-proxy-read', //'principals/'.$name.'/calendar-proxy-write', // The addressbook groups are not supported in Sabre, // see http://groups.google.com/group/sabredav-discuss/browse_thread/thread/ef2fa9759d55f8c#msg_5720afc11602e753 //'principals/'.$name.'/addressbook-proxy-read', //'principals/'.$name.'/addressbook-proxy-write', ); } return $group_membership; } /** * Updates the list of group members for a group principal. * * The principals should be passed as a list of uri's. * * @param string $principal * @param array $members * @return void */ public function setGroupMemberSet($principal, array $members) { throw new Exception('Setting members of the group is not supported yet'); } - function updatePrincipal($path, $mutations) + function updatePrincipal($path, \Sabre\DAV\PropPatch $propPatch) { return 0; } /** * This method is used to search for principals matching a set of * properties. * * This search is specifically used by RFC3744's principal-property-search * REPORT. You should at least allow searching on * http://sabredav.org/ns}email-address. * * The actual search should be a unicode-non-case-sensitive search. The * keys in searchProperties are the WebDAV property names, while the values * are the property values to search on. * * If multiple properties are being searched on, the search should be * AND'ed. * * This method should simply return an array with full principal uri's. * * If somebody attempted to search on a property the backend does not * support, you should simply return 0 results. * * @param string $prefixPath * @param array $searchProperties + * @param string $test * @return array */ - function searchPrincipals($prefixPath, array $searchProperties) + function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') { console(__METHOD__, $prefixPath, $searchProperties); $email = null; $results = array(); $current_user = $this->getCurrentUser(); foreach($searchProperties as $property => $value) { // check search property against the current user if ($current_user[$property] == $value) { $results[] = $current_user['uri']; continue; } switch($property) { case '{http://sabredav.org/ns}email-address': $email = $value; break; case '{DAV:}displayname': default : // Unsupported property return array(); } } // we only support search by email if (!empty($email)) { // TODO: search via LDAP } return array_unique($results); } } diff --git a/lib/Kolab/Utils/DAVBackend.php b/lib/Kolab/Utils/DAVBackend.php index cb3e56b..7bef0f6 100644 --- a/lib/Kolab/Utils/DAVBackend.php +++ b/lib/Kolab/Utils/DAVBackend.php @@ -1,229 +1,256 @@ * * 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\Utils; use \rcube; use \kolab_storage; use \rcube_utils; use \rcube_charset; /** * */ class DAVBackend { const IMAP_UID_KEY = '/shared/vendor/kolab/uniqueid'; const IMAP_UID_KEY_PRIVATE = '/private/vendor/kolab/uniqueid'; const IMAP_UID_KEY_CYRUS = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; public static $caldav_type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO'); /** * Getter for a kolab_storage_folder with the given UID * * @param string Folder UID (saved in annotation) * @param string Kolab folder type (for selecting candidates) * @return object \kolab_storage_folder instance */ public static function get_storage_folder($uid, $type) { foreach (kolab_storage::get_folders($type, false) as $folder) { if ($folder->get_uid() == $uid) return $folder; } return null; } /** * Build an absolute URL with the given parameters */ public static function abs_url($parts = array()) { $schema = 'http'; $default_port = 80; if (rcube_utils::https_check()) { $schema = 'https'; $default_port = 443; } $url = $schema . '://' . $_SERVER['HTTP_HOST']; if ($_SERVER['SERVER_PORT'] != $default_port) $url .= ':' . $_SERVER['SERVER_PORT']; if (dirname($_SERVER['SCRIPT_NAME']) != '/') $url .= dirname($_SERVER['SCRIPT_NAME']); $url .= '/' . join('/', array_map('urlencode', $parts)); return $url; } + /** + * Set callback handler for property changes on the given folder + * + * @param object $folder kolab_storage_folder instance to operate on + * @param oject $propPatch PropPatch instance with the property updates + */ + public static function handle_propatch($folder, \Sabre\DAV\PropPatch $propPatch) + { + $propPatch->handle( + array('{DAV:}displayname','{http://apple.com/ns/ical/}calendar-color'), + function($mutations) use ($folder) { + $result = DAVBackend::folder_update($folder, $mutations); + if (is_array($result)) { + $ret = array(); + foreach ($result as $code => $props) { + foreach (array_keys($props) as $prop) { + $ret[$prop] = $code; + } + } + } + else { + $ret = $result; + } + return $ret; + }); + } + /** * Updates properties for a recourse (kolab folder) * * The mutations array uses the propertyName in clark-notation as key, * and the array value for the property value. In the case a property * should be deleted, the property value will be null. * * This method must be atomic. If one property cannot be changed, the * entire operation must fail. * * If the operation was successful, true is returned. * If the operation failed, detailed information about any * failures is returned. * * @param object $folder kolab_storage_folder instance to operate on - * @param array $mutations Hash array with propeties to change - * @return bool|array + * @param object $mutations Hash array with propeties to change + * @return void */ public static function folder_update($folder, array $mutations) { $errors = array(); $updates = array(); foreach ($mutations as $prop => $val) { switch ($prop) { case '{DAV:}displayname': // abort if name didn't change if ($val == html_entity_decode($folder->get_name(), ENT_COMPAT, RCUBE_CHARSET)) { break; } // restrict renaming to personal folders only if ($folder->get_namespace() == 'personal') { $parts = preg_split('!(\s*/\s*|\s+[ยป:]\s+)!', $val); $updates['oldname'] = $folder->name; $updates['name'] = array_pop($parts); $updates['parent'] = join('/', $parts); } else { $updates['displayname'] = $val; } break; case '{http://apple.com/ns/ical/}calendar-color': $newcolor = substr(trim($val, '#'), 0, 6); if (strcasecmp($newcolor, $folder->get_color())) { $updates['color'] = $newcolor; } break; case '{urn:ietf:params:xml:ns:caldav}calendar-description': default: // unsupported property $errors[403][$prop] = null; } } // execute folder update if (!empty($updates)) { // 'name' and 'parent' properties are always required if (empty($updates['name'])) { $parts = explode('/', $folder->name); $updates['name'] = rcube_charset::convert(array_pop($parts), 'UTF7-IMAP'); $updates['parent'] = join('/', $parts); $updates['oldname'] = $folder->name; } if (!kolab_storage::folder_update($updates)) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error updating properties for folder $folder->name:" . kolab_storage::$last_error), true, false); return false; } } return empty($errors) ? true : $errors; } /** * Creates a new resource (i.e. IMAP folder) of a given type * * If the creation was a success, an id must be returned that can be used to reference * this resource in other methods. * * @param array $properties * @param string $type * @param string $uid * @return false|string */ public function folder_create($type, array $properties, $uid) { $props = array( 'type' => $type, 'name' => '', 'subscribed' => true, ); foreach ($properties as $prop => $val) { switch ($prop) { case '{DAV:}displayname': $parts = explode('/', $val); $props['name'] = array_pop($parts); $props['parent'] = join('/', $parts); break; case '{http://apple.com/ns/ical/}calendar-color': $props['color'] = substr(trim($val, '#'), 0, 6); break; case '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': $type_map = array_flip(self::$caldav_type_component_map); $comp_types = $val->getValue(); $comp_type = $comp_types[0]; if (!empty($type_map[$comp_type])) $type = $type_map[$comp_type]; break; case '{urn:ietf:params:xml:ns:caldav}calendar-description': default: // unsupported property } } // use UID as name if it doesn't seem to be a real UID // TODO: append number to default "Untitled" folder name if one already exists if (empty($props['name'])) { $props['name'] = strlen($uid) < 16 ? $uid : 'Untitled'; } if (!($fname = kolab_storage::folder_update($props))) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error creating a new $type folder '$props[name]':" . kolab_storage::$last_error), true, false); return false; } // save UID in folder annotations if ($folder = kolab_storage::get_folder($fname)) { $folder->set_uid($uid); } return $uid; } } diff --git a/lib/Kolab/Utils/DAVLogger.php b/lib/Kolab/Utils/DAVLogger.php index c1c3067..94a39ba 100644 --- a/lib/Kolab/Utils/DAVLogger.php +++ b/lib/Kolab/Utils/DAVLogger.php @@ -1,195 +1,195 @@ * * 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\Utils; use \rcube; use Sabre\DAV; use Kolab\DAV\Auth\HTTPBasic; /** * Utility class to log debug information about processed DAV requests */ class DAVLogger extends DAV\ServerPlugin { const CONSOLE = 1; const HTTP_REQUEST = 2; const HTTP_RESPONSE = 4; private $rcube; private $server; private $method; private $loglevel; /** * Default constructor */ public function __construct($level = 1) { $this->rcube = rcube::get_instance(); $this->loglevel = $level; } /** * This initializes the plugin. * This method should set up the required event subscriptions. * * @param Server $server */ public function initialize(DAV\Server $server) { $this->server = $server; - $server->subscribeEvent('beforeMethod', array($this, '_beforeMethod'), 15); - $server->subscribeEvent('exception', array($this, '_exception')); - $server->subscribeEvent('exit', array($this, '_exit')); + $server->on('beforeMethod', array($this, '_beforeMethod'), 15); + $server->on('exception', array($this, '_exception')); + $server->on('exit', array($this, '_exit')); // replace $server->httpResponse with a derived class that can do logging $server->httpResponse = new HTTPResponse(); } /** * Handler for 'beforeMethod' events */ public function _beforeMethod($method, $uri) { $this->method = $method; // turn on per-user http logging if the destination file exists if ($this->loglevel < 2 && $this->rcube->config->get('kolabdav_user_debug', false) && ($log_dir = $this->user_log_dir()) && file_exists($log_dir . '/httpraw')) { $this->loglevel |= (self::HTTP_REQUEST | self::HTTP_RESPONSE); } // log full HTTP request data if ($this->loglevel & self::HTTP_REQUEST) { $request = $this->server->httpRequest; $content_type = $request->getHeader('CONTENT_TYPE'); if (strpos($content_type, 'text/') === 0 || strpos($content_type, 'application/xml') === 0) { $http_body = $request->getBody(true); // Hack for reading php:://input because that stream can only be read once. // This is why we re-populate the request body with the existing data. $request->setBody($http_body); } else if (!empty($content_type)) { $http_body = '[binary data]'; } // catch all headers $http_headers = array(); foreach (apache_request_headers() as $hdr => $value) { if (strtolower($hdr) == 'authorization') { $method = preg_match('/^((basic|digest)\s+)/i', $value, $m) ? $m[1] : ''; $value = $method . str_repeat('*', strlen($value) - strlen($method)); } $http_headers[$hdr] = "$hdr: $value"; } - $this->write_log('httpraw', $request->getMethod() . ' ' . $request->getUri() . ' ' . $_SERVER['SERVER_PROTOCOL'] . "\n" . + $this->write_log('httpraw', $request->getMethod() . ' ' . $request->getUrl() . ' ' . $_SERVER['SERVER_PROTOCOL'] . "\n" . join("\n", $http_headers) . "\n\n" . $http_body); } // log to console if ($this->loglevel & self::CONSOLE) { $this->write_log('console', $method . ' ' . $uri); } } /** * Handler for 'exception' events */ public function _exception($e) { // log to console $this->console(get_class($e) . ' (EXCEPTION)', $e->getMessage() /*, $e->getTraceAsString()*/); } /** * Handler for 'exit' events */ public function _exit() { if ($this->loglevel & self::CONSOLE) { $time = microtime(true) - KOLAB_DAV_START; if (function_exists('memory_get_usage')) $mem = round(memory_get_usage() / 1024 / 1024, 1) . 'MB'; if (function_exists('memory_get_peak_usage')) $mem .= '/' . round(memory_get_peak_usage() / 1024 / 1024, 1) . 'MB'; $this->write_log('console', sprintf("/%s: %0.4f sec; %s", $this->method, $time, $mem)); } // log full HTTP reponse if ($this->loglevel & self::HTTP_RESPONSE) { $this->write_log('httpraw', "RESPONSE: " . $this->server->httpResponse->dump()); } } /** * Wrapper for rcube::cosole() to write per-user logs */ public function console(/* ... */) { if ($this->loglevel & self::CONSOLE) { $msg = array(); foreach (func_get_args() as $arg) { $msg[] = !is_string($arg) ? var_export($arg, true) : $arg; } $this->write_log('console', join(";\n", $msg)); } } /** * Wrapper for rcube::write_log() that can write per-user logs */ public function write_log($filename, $msg) { // dump data per user if ($this->rcube->config->get('kolabdav_user_debug', false)) { if ($this->user_log_dir()) { $filename = HTTPBasic::$current_user . '/' . $filename; } else { return; // don't log } } rcube::write_log($filename, $msg); } /** * Get the per-user log directory */ private function user_log_dir() { $log_dir = $this->rcube->config->get('log_dir', RCUBE_INSTALL_PATH . 'logs'); $user_log_dir = $log_dir . '/' . HTTPBasic::$current_user; return HTTPBasic::$current_user && is_writable($user_log_dir) ? $user_log_dir : false; } } \ No newline at end of file diff --git a/lib/Kolab/Utils/HTTPResponse.php b/lib/Kolab/Utils/HTTPResponse.php index 4f4e72d..7fefcba 100644 --- a/lib/Kolab/Utils/HTTPResponse.php +++ b/lib/Kolab/Utils/HTTPResponse.php @@ -1,92 +1,93 @@ * * 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\Utils; /** * This class represents a HTTP response. */ class HTTPResponse extends \Sabre\HTTP\Response { - private $status; - private $body = ''; - private $headers = array(); + protected $status; + protected $body = ''; + protected $_headers = array(); /** * Sends an HTTP status header to the client. * * @param int $code HTTP status code * @return bool */ public function sendStatus($code) { $this->status = $this->getStatusMessage($code, $this->defaultHttpVersion); return parent::sendStatus($code); } /** * Sets an HTTP header for the response * * @param string $name * @param string $value * @param bool $replace * @return bool */ - public function setHeader($name, $value, $replace = true) { - $this->headers[$name] = $value; + public function setHeader($name, $value, $replace = true) + { + $this->_headers[$name] = $value; return parent::setHeader($name, $value, $replace); } /** * Sends the entire response body * * This method can accept either an open filestream, or a string. * * @param mixed $body * @return void */ public function sendBody($body) { if (is_resource($body)) { fpassthru($body); $this->body = '[binary data]'; } else { echo $body; $this->body .= $body; } } /** * Dump the response data for logging */ public function dump() { $result_headers = ''; - foreach ($this->headers as $hdr => $value) { + foreach ($this->_headers as $hdr => $value) { $result_headers .= "\n$hdr: " . $value; } return $this->status . $result_headers . "\n\n" . $this->body; } } \ No newline at end of file diff --git a/lib/Kolab/Utils/VObjectUtils.php b/lib/Kolab/Utils/VObjectUtils.php index 82f934b..9131597 100644 --- a/lib/Kolab/Utils/VObjectUtils.php +++ b/lib/Kolab/Utils/VObjectUtils.php @@ -1,84 +1,93 @@ * * 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\Utils; use Sabre\VObject\Property; /** * Helper class proviting utility functions for VObject data encoding */ class VObjectUtils { /** * Convert an object URI into a valid UID value */ public static function uri2uid($uri, $suffix = '') { $base = basename($uri, $suffix); $uid = strtr($base, array('%2F' => '/')); // assume full URL encoding if (preg_match('/%[A-F0-9]{2}/', $uid)) { return urldecode($base); } return $uid; } /** * Encode an object UID into a valid URI */ public static function uid2uri($uid, $suffix = '') { $encode = strpos($uid, '/') !== false; return ($encode ? urlencode($uid) : $uid) . $suffix; } /** * 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) + public static function datetime_prop($root, $name, $dt, $utc = false) { - $vdt = new Property\DateTime($name); - $vdt->setDateTime($dt, $dt->_dateonly ? Property\DateTime::DATE : ($utc ? Property\DateTime::UTC : Property\DateTime::LOCALTZ)); + if ($utc) { + $dt->setTimeZone(new \DateTimeZone('UTC')); + } + + $vdt = $root->createProperty($name); + $vdt->setValue($dt); + + if ($dt->_dateonly) { + $vdt['VALUE'] = 'DATE'; + } + 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 diff --git a/public_html/index.php b/public_html/index.php index 04e7f53..9b9c201 100644 --- a/public_html/index.php +++ b/public_html/index.php @@ -1,197 +1,197 @@ * * Copyright (C) 2013-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 . */ // define some environment variables used throughout the app and libraries define('KOLAB_DAV_ROOT', realpath('../')); -define('KOLAB_DAV_VERSION', '0.3-dev'); +define('KOLAB_DAV_VERSION', '0.4-dev'); define('KOLAB_DAV_START', microtime(true)); define('RCUBE_INSTALL_PATH', KOLAB_DAV_ROOT . '/'); define('RCUBE_CONFIG_DIR', KOLAB_DAV_ROOT . '/config/'); define('RCUBE_PLUGINS_DIR', KOLAB_DAV_ROOT . '/lib/plugins/'); // suppress error notices ini_set('error_reporting', E_ALL &~ E_NOTICE &~ E_STRICT); /** * Mapping PHP errors to exceptions. * * While this is not strictly needed, it makes a lot of sense to do so. If an * E_NOTICE or anything appears in your code, this allows SabreDAV to intercept * the issue and send a proper response back to the client (HTTP/1.1 500). */ function exception_error_handler($errno, $errstr, $errfile, $errline ) { throw new ErrorException($errstr, 0, $errno, $errfile, $errline); } //set_error_handler("exception_error_handler"); // use composer's autoloader for dependencies $loader = require_once(KOLAB_DAV_ROOT . '/vendor/autoload.php'); $loader->set('Kolab', array(KOLAB_DAV_ROOT . '/lib')); // register iRony namespace(s) $loader->setUseIncludePath(true); // enable include_path to load PEAR classes from their default location // load the Roundcube framework with its autoloader require_once KOLAB_DAV_ROOT . '/lib/Roundcube/bootstrap.php'; // Roundcube framework initialization $rcube = rcube::get_instance(rcube::INIT_WITH_DB | rcube::INIT_WITH_PLUGINS); $rcube->config->load_from_file(RCUBE_CONFIG_DIR . 'dav.inc.php'); // Load plugins $plugins = (array)$rcube->config->get('kolabdav_plugins', array('kolab_auth')); $required = array('libkolab', 'libcalendaring'); $rcube->plugins->init($rcube); $rcube->plugins->load_plugins($plugins, $required); // enable logger if ($rcube->config->get('kolabdav_console') || $rcube->config->get('kolabdav_user_debug')) { $logger = new \Kolab\Utils\DAVLogger((\Kolab\Utils\DAVLogger::CONSOLE | $rcube->config->get('kolabdav_http_log', 0))); } // convenience function, you know it well :-) function console() { global $logger; if ($logger) { call_user_func_array(array($logger, 'console'), func_get_args()); } } // Make sure this setting is turned on and reflects the root url of the *DAV server. $base_uri = $rcube->config->get('base_uri', slashify(substr(dirname($_SERVER['SCRIPT_FILENAME']), strlen($_SERVER['DOCUMENT_ROOT'])))); // add filename to base URI when called without mod_rewrite (e.g. /dav/index.php/calendar) if (strpos($_SERVER['REQUEST_URI'], 'index.php')) $base_uri .= 'index.php/'; // create the various backend instances $auth_backend = new \Kolab\DAV\Auth\HTTPBasic(); $principal_backend = new \Kolab\DAVACL\PrincipalBackend(); $services = array(); foreach (array('CALDAV','CARDDAV','WEBDAV') as $skey) { if (getenv($skey)) $services[$skey] = 1; } // no config means *all* services if (empty($services)) $services = array('CALDAV' => 1, 'CARDDAV' => 1, 'WEBDAV' => 1); // add chwala directories to include path for autoloading if ($services['WEBDAV']) { $include_path = ini_get('include_path') . PATH_SEPARATOR; $include_path .= KOLAB_DAV_ROOT . '/lib/FileAPI' . PATH_SEPARATOR; $include_path .= KOLAB_DAV_ROOT . '/lib/FileAPI/kolab' . PATH_SEPARATOR; $include_path .= KOLAB_DAV_ROOT . '/lib/FileAPI/ext'; set_include_path($include_path); } // Build the directory tree // This is an array which contains the 'top-level' directories in the WebDAV server. if ($services['CALDAV'] || $services['CARDDAV']) { $nodes = array( new \Kolab\CalDAV\Principal\Collection($principal_backend), ); if ($services['CALDAV']) { $caldav_backend = new \Kolab\CalDAV\CalendarBackend(); $caldav_backend->setUserAgent($_SERVER['HTTP_USER_AGENT']); $nodes[] = new \Kolab\CalDAV\CalendarRootNode($principal_backend, $caldav_backend); } if ($services['CARDDAV']) { $carddav_backend = new \Kolab\CardDAV\ContactsBackend(); $carddav_backend->setUserAgent($_SERVER['HTTP_USER_AGENT']); $nodes[] = new \Kolab\CardDAV\AddressBookRoot($principal_backend, $carddav_backend); } if ($services['WEBDAV']) { $nodes[] = new \Kolab\DAV\Collection(\Kolab\DAV\Collection::ROOT_DIRECTORY); } } // register WebDAV service as root else if ($services['WEBDAV']) { $nodes = new \Kolab\DAV\Collection(''); } // the object tree needs in turn to be passed to the server class $server = new \Sabre\DAV\Server($nodes); $server->setBaseUri($base_uri); // connect logger if (is_object($logger)) { $server->addPlugin($logger); } // register some plugins $server->addPlugin(new \Sabre\DAV\Auth\Plugin($auth_backend, 'KolabDAV')); $server->addPlugin(new \Sabre\DAVACL\Plugin()); if ($services['CALDAV']) { $caldav_plugin = new \Kolab\CalDAV\Plugin(); - $caldav_plugin->setIMipHandler(new \Kolab\CalDAV\IMip()); + #$caldav_plugin->setIMipHandler(new \Kolab\CalDAV\IMip()); $server->addPlugin($caldav_plugin); } if ($services['CARDDAV']) { $server->addPlugin(new \Kolab\CardDAV\Plugin()); } if ($services['WEBDAV']) { // the lock manager is responsible for making sure users don't overwrite each others changes. $locks_backend = new \Kolab\DAV\Locks\Chwala(\Kolab\DAV\Collection::ROOT_DIRECTORY); $server->addPlugin(new \Sabre\DAV\Locks\Plugin($locks_backend)); // intercept some of the garbage files operation systems tend to generate when mounting a WebDAV share $server->addPlugin(new \Kolab\DAV\TempFilesPlugin(KOLAB_DAV_ROOT . '/temp')); } // HTML UI for browser-based access (recommended only for development) if (getenv('DAVBROWSER')) { $server->addPlugin(new \Sabre\DAV\Browser\Plugin()); } // log exceptions in iRony error log -$server->subscribeEvent('exception', function($e){ +$server->on('exception', function($e){ if (!($e instanceof \Sabre\DAV\Exception) || $e->getHTTPCode() == 500) { rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'file' => $e->getFile(), 'line' => $e->getLine(), 'message' => $e->getMessage() . " (error 500)\n" . $e->getTraceAsString(), ), true, false); } }); // finally, process the request $server->exec(); // trigger log -$server->broadcastEvent('exit', array()); +$server->emit('exit', array());