diff --git a/lib/Kolab/CalDAV/CalendarBackend.php b/lib/Kolab/CalDAV/CalendarBackend.php index 931a1c7..40244e2 100644 --- a/lib/Kolab/CalDAV/CalendarBackend.php +++ b/lib/Kolab/CalDAV/CalendarBackend.php @@ -1,1187 +1,1192 @@ * * 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 \rcube_message; 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 implements CalDAV\Backend\SchedulingSupport { 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; $default_calendar_id = null; 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++, ); if ($folder->default && $folder->type == 'event') { $default_calendar_id = $id; } $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; } } // put default calendar on top of the list: // {urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL is derived from the first item on this list if ($default_calendar_id) { $this->calendars = array($default_calendar_id => $this->calendars[$default_calendar_id]) + $this->calendars; } 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]) { DAVBackend::check_storage_folder($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); if (!$this->is_current_pricipal($principalUri)) { return array(); } + // Reset imap cache so we work with up-to-date folders list + // We do this only when a client requests list of calendars, + // and we assume clients do not ask for list too often (Bifrost#T175679) + rcube::get_instance()->get_storage()->clear_cache('mailboxes', true); + $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]) && !in_array($id, array('inbox','outbox','notifications')) && $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 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. * * 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 $path * @param \Sabre\DAV\PropPatch $propPatch * @return void */ public function updateCalendar($calendarId, \Sabre\DAV\PropPatch $propPatch) { console(__METHOD__, $calendarId, $propPatch); 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, true) 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']); } } if (is_array($filter['prop-filters'])) { foreach ($filter['prop-filters'] as $prop_filter) { $match = $prop_filter['text-match']; if ($match['value']) { $op = $match['negate-condition'] ? '!=' : '='; switch ($prop_filter['name']) { case 'UID': $query[] = array('uid', $op, $match['value']); break; } } } } } $results = array(); if ($storage = $this->get_storage_folder($calendarId)) { foreach ($storage->select($query, true) as $event) { // post-filter events to suppress declined invitations if ($this->_event_filter_compare($event)) { $results[] = VObjectUtils::uid2uri($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; } } } /********** SchedulingBackend methods ***********/ /** * Returns a single scheduling object for the inbox collection. * * The returned array should contain the following elements: * * uri - A unique basename for the object. This will be used to * construct a full uri. * * calendardata - The iCalendar object * * lastmodified - The last modification date. Can be an int for a unix * timestamp, or a PHP DateTime object. * * etag - A unique token that must change if the object changed. * * size - The size of the object, in bytes. * * @param string $principalUri * @param string $objectUri * @return array */ public function getSchedulingObject($principalUri, $objectUri) { console(__METHOD__, $principalUri, $objectUri); if (!$this->is_current_pricipal($principalUri)) { return array(); } $uid = VObjectUtils::uri2uid($objectUri, '.ics'); list($msguid, $mime_id) = explode('-', $uid, 2); $message = new rcube_message($msguid, 'INBOX'); if ($ical = $message->get_part_content($mime_id, null, false, 0, false)) { if ($event = $this->parse_calendar_data($ical, null)) { if ($event['_type'] == 'event') { $event['_msguid'] = $msguid; return array( 'uri' => $objectUri, 'calendardata' => $ical, 'lastmodified' => $event['changed'] ? $event['changed']->format('U') : null, 'etag' => self::_get_etag($event), ); } } } return array(); } /** * Returns all scheduling objects for the inbox collection. * * These objects should be returned as an array. Every item in the array * should follow the same structure as returned from getSchedulingObject. * * The main difference is that 'calendardata' is optional. * * @param string $principalUri * @return array */ public function getSchedulingObjects($principalUri) { console(__METHOD__, $principalUri); $results = array(); $threshold = new \DateTime('now - 7 days'); // we can only access the current user's calendars (if enabled) if (!$this->is_current_pricipal($principalUri) || !rcube::get_instance()->config->get('kolabdav_caldav_inbox', false)) { return $results; } // find iTip messages in users email INBOX and extract the ics attachment. foreach ($this->search_email_inbox() as $msg) { list($msguid, $mime_id) = $msg; $message = new rcube_message($msguid, 'INBOX'); if ($ical = $message->get_part_content($mime_id, null, false, 0, false)) { if ($event = $this->parse_calendar_data($ical, null)) { if ($event['_type'] != 'event') { continue; } // filter past event invitations if (is_a($event['end'], '\\DateTime') && $event['end'] < $threshold && empty($event['recurrence'])) { console(sprintf('Skip iTip message for past event: %s // %s // %s', $event['uid'], $event['title'], $event['end']->format('c'))); continue; } $event['_msguid'] = $msguid; $results[] = array( 'uri' => VObjectUtils::uid2uri($msguid . '-' . $mime_id, '.ics'), 'calendardata' => $ical, 'lastmodified' => $event['changed'] ? $event['changed']->format('U') : null, 'etag' => self::_get_etag($event), ); } } } return $results; } /** * Deletes a scheduling object from the inbox collection. * * @param string $principalUri * @param string $objectUri * @return void */ public function deleteSchedulingObject($principalUri, $objectUri) { console(__METHOD__, $principalUri, $objectUri); $rcube = rcube::get_instance(); // we can only access the current user's inbox (if enabled) if (!$this->is_current_pricipal($principalUri) || !$rcube->config->get('kolabdav_caldav_inbox', false)) { return; } // get the referenced iTip message from email INBOX and // copy it to the default calendar. This will also remove the message // from the email inbox as the message is considered 'processed'. $uid = VObjectUtils::uri2uid($objectUri, '.ics'); list($msguid, $mime_id) = explode('-', $uid, 2); $message = new rcube_message($msguid, 'INBOX'); if ($ical = $message->get_part_content($mime_id, null, false, 0, false)) { if ($object = $this->parse_calendar_data($ical, null)) { if ($object['_type'] != 'event') { return; } console('Copy iTip Schedule object', $object); // get default calendar and search for an existing copy $calendars = $this->_read_calendars(); $calendarId = reset(array_keys($calendars)); // select private/confidential calendar folder if (!empty($object['sensitivity'])) { foreach ($calendars as $calid => $calprop) { if (($folder = $this->folders[$calid]) && $object['sensitivity'] === $folder->subtype) { $calendarId = $calid; } } } $storage = $this->get_storage_folder($calendarId); $existing = $storage->get_object($object['uid']); $update = true; // copy meta data (starting with _) from old object if (!empty($existing)) { // ignore update if existing is newer if ($existing['sequence'] > $object['sequence']) { $update = false; } foreach ((array)$existing as $key => $val) { if (!isset($object[$key]) && $key[0] == '_') $object[$key] = $val; } } // act according to the scheduling method switch ($object['_method']) { case 'REQUEST': // store the new version break; case 'REPLY': if (!empty($existing)) { // TODO: only update attendee status(es) on the existing event // as in pykolab/wallace/module_invitationpolicy/process_itip_reply() // FIXME: replies can refer to single recurrence instances $attendee = null; $object = $existing; $update = false; } else { $update = false; } break; case 'CANCEL': // set status to cancelled if (!empty($existing)) { $existing['cancelled'] = true; $existing['status'] = 'cancelled'; $object = $existing; } else { $update = false; } break; default: console('iTip method ' . $object['_method'] . ' not supported; ignoring'); $update = false; } if ($update) { // map attachments attribute $object['_attachments'] = $object['attachments']; unset($object['attachments']); $success = $storage->save($object, $object['_type'], $existing['uid']); 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'); } } // remove iTip message from email inbox or move to Trash instead ? $imap = $rcube->get_storage(); $done = $imap->move_message($msguid, 'Trash', 'INBOX'); console("MOVE $msguid to Trash", $done); //console("DELETE $msguid", $imap->delete_message($msguid, 'INBOX')); } } } /** * Creates a new scheduling object. This should land in a users' inbox. * * @param string $principalUri * @param string $objectUri * @param string $objectData * @return void */ public function createSchedulingObject($principalUri, $objectUri, $objectData) { console(__METHOD__, $principalUri, $objectUri, $objectData); // accept only for current user principal (we don't have permissions for other users) if ($this->is_current_pricipal($principalUri)) { } else { // send as iTip? } } /** * Return the ctag value for the scheduling inbox */ public function getSchedulingInboxCtag($principalUri) { $rcube = rcube::get_instance(); if ($this->is_current_pricipal($principalUri) && $rcube->config->get('kolabdav_caldav_inbox', false)) { // we could use the INBOX imap folder properties but these are likely subject to // frequent changes without new invitations. Let's count potential iTip messages: $candidates = $this->search_email_inbox(); if (count($candidates)) { $fdata = $rcube->storage->folder_data('INBOX'); return sprintf('%d-%d-%d-%d', $fdata['UIDVALIDITY'], $fdata['HIGHESTMODSEQ'], $fdata['UIDNEXT'], count($candidates) ); } } return "empty-000"; } /** * */ protected function search_email_inbox() { $result = array(); $rcube = rcube::get_instance(); $imap = $rcube->get_storage(); // FIXME: search by content-type doesn't return any results from Cyrus IMAP // $query = $imap->search_once('INBOX', 'UNDELETED OR OR HEADER Content-Type text/calendar HEADER Content-Type multipart/mixed HEADER Content-Type multipart/alternative'); $query = $imap->search_once('INBOX', 'UNDELETED'); if ($query && $query->count() > 0) { foreach ($query->get() as $msguid) { // get bodystructure and check for iTip parts $message = new rcube_message($msguid, 'INBOX'); foreach ((array)$message->mime_parts as $part) { if (self::part_is_itip($part)) { $result[] = array($msguid, $part->mime_id); } } } } return $result; } /** * Checks if specified message part is a vcalendar data * * @param rcube_message_part Part object * @return boolean True if part is of type vcard */ protected static function part_is_itip($part) { return ( in_array($part->mimetype, array('text/calendar', 'text/x-vcalendar', 'application/ics')) || ($part->mimetype == 'application/x-any' && $part->filename && preg_match('/\.ics$/i', $part->filename)) ) && !empty($part->ctype_parameters['method']); } /********** 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)) { $objects[0]['_method'] = $ical->method; 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); }; } $events = array($event); // add more instances from exceptions (not recurrence) to the output if (!empty($event['exceptions']) && empty($event['recurrence'])) { $events = array_merge($events, $event['exceptions']); } return $ical->export($events, 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() * * @param boolean Filter for inbox events (i.e. status=NEEDS-ACTION) * @return array List of query parameters for kolab_storage_folder::select() */ private function _event_filter_query($inbox = false) { // get email addresses of the current user $user_emails = $this->get_user_emails(); $query = $subquery = array(); // add query to exclude declined invitations foreach ($user_emails as $email) { if ($inbox) { $subquery[] = array('tags', '=', 'x-partstat:' . $email . ':needs-action'); $subquery[] = array('tags', '=', 'x-partstat:' . $email . ':needs-action'); } else { $query[] = array('tags', '!=', 'x-partstat:' . $email . ':declined'); } } if (!empty($subquery)) { $query[] = array($subquery, 'OR'); } return $query; } /** * Check the given event if it matches the filter * * @param array Hash array with event properties * @param boolean Filter for inbox events (i.e. status=NEEDS-ACTION) * @return boolean True if matches, false if not */ private function _event_filter_compare($event, $inbox = false) { 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)) { if ($attendee['status'] == 'DECLINED') { return false; } else if ($inbox && $attendee['status'] == 'NEEDS-ACTION') { return true; } } } } return !$inbox; } /** * 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' ); } /** * Helpter method to determine whether the given principal URI * matches the authenticated user principal. */ private function is_current_pricipal($principalUri) { return $principalUri === 'principals/' . HTTPBasic::$current_user; } } diff --git a/lib/Kolab/CardDAV/ContactsBackend.php b/lib/Kolab/CardDAV/ContactsBackend.php index ef55d1e..7fa9cee 100644 --- a/lib/Kolab/CardDAV/ContactsBackend.php +++ b/lib/Kolab/CardDAV/ContactsBackend.php @@ -1,1174 +1,1179 @@ * * 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_mime; 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', ); private $phonetypes = array( 'main' => 'voice', 'homefax' => 'home,fax', 'workfax' => 'work,fax', 'mobile' => 'cell', 'other' => 'textphone', ); private $improtocols = array( 'jabber' => 'xmpp', ); /** * 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]) { DAVBackend::check_storage_folder($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); + // Reset imap cache so we work with up-to-date folders list + // We do this only when a client requests list of address books, + // and we assume clients do not ask for list too often (Bifrost#T175679) + rcube::get_instance()->get_storage()->clear_cache('mailboxes', true); + $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 properties for an address book. * * 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. * * 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, \Sabre\DAV\PropPatch $propPatch) { console(__METHOD__, $addressBookId, $propPatch); if ($addressBookId == '__all__') return 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, true) 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 Plugin::$vcard_version == 'vcard4' || $this->useragent == 'vcard4'; } /********** Data conversion utilities ***********/ /** * Parse the given VCard string into a hash array kolab_format_contact can handle * * @param string VCard data block * @param string Contact UID * * @return array Hash array with contact properties or null on failure */ public function parse_vcard($cardData, $uid = null) { try { // use already parsed object if (Plugin::$parsed_vcard && Plugin::$parsed_vcard->UID == $uid) { $vobject = Plugin::$parsed_vcard; } else { $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 = new VObject\Component\VCard(); $vc->VERSION = '3.0'; // always set to 3.0 and let Sabre/DAV convert to 4.0 if necessary $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 = $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 = $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($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) { $types = array('INTERNET'); if (!empty($email['type'])) $types = array_merge($types, explode(',', strtoupper($email['type']))); $vc->add('EMAIL', $email['address'], array('type' => $types)); } foreach ((array)$contact['phone'] as $phone) { $type = $this->phonetypes[$phone['type']] ?: $phone['type']; $params = !empty($type) ? array('type' => explode(',', strtoupper($type))) : array(); $vc->add('TEL', $phone['number'], $params); } foreach ((array)$contact['website'] as $website) { $params = !empty($website['type']) ? array('type' => explode(',', strtoupper($website['type']))) : array(); $vc->add('URL', $website['url'], $params); } $improtocolmap = array_flip($this->improtocols); foreach ((array)$contact['im'] as $im) { list($prot, $val) = explode(':', $im, 2); if ($val && !$v4) $vc->add('x-' . ($improtocolmap[$prot] ?: $prot), $val); else $vc->add('IMPP', $im); } foreach ((array)$contact['address'] as $adr) { $params = !empty($adr['type']) ? array('type' => strtoupper($adr['type'])) : array(); $vadr = $vc->create('ADR', null, $params); $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($vc, 'BDAY', $contact['birthday'], false)); } if (!empty($contact['anniversary']) && $contact['anniversary'] instanceof \DateTime) { $contact['anniversary']->_dateonly = true; $vc->add(VObjectUtils::datetime_prop($vc, $v4_prefix . 'ANNIVERSARY', $contact['anniversary'], false)); } if (!empty($contact['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 = $contact['photo']; $vc->PHOTO['ENCODING'] = 'b'; $vc->PHOTO['TYPE'] = strtoupper(substr(rcube_mime::image_content_type($contact['photo']), 6)); } // 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']) && is_a($contact['changed'], 'DateTime')) { $vc->REV = $contact['changed']->format('Ymd\\THis\\Z'); } 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'] = DateTimeParser::parseDateTime(strval($vc->REV)); } catch (\Exception $e) { // ignore } } // normalize apple-style properties $this->_from_apple($vc); $phonetypemap = array_flip($this->phonetypes); $phonetypemap['fax,home'] = 'homefax'; $phonetypemap['fax,work'] = 'workfax'; // 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'] = $value; break; case 'TITLE': $contact['jobtitle'] = $value; break; case 'NICKNAME': $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::prop_filter($prop->offsetGet('type'), 'internet,pref', true)); $contact['email'][] = array('address' => $value, 'type' => strtolower($types[0] ?: 'other')); break; case 'URL': $types = array_values(self::prop_filter($prop->offsetGet('type'), 'internet,pref', true)); $contact['website'][] = array('url' => $value, 'type' => strtolower($types[0])); break; case 'TEL': $types = array_values(self::prop_filter($prop->offsetGet('type'), 'voice,pref', true)); $types_ = strtolower(join(',', $types)); $type = isset($phonetypemap[$types_]) ? $types_ : strtolower($types[0]); $contact['phone'][] = array('number' => $value, 'type' => $phonetypemap[$type] ?: $type); break; case 'ADR': $types = array_values(self::prop_filter($prop->offsetGet('type'), 'pref', true)); $adr = array('type' => strtolower(!empty($types) ? strval($types[0]) : $prop->parameters[0]->name)); list(,, $adr['street'], $adr['locality'], $adr['region'], $adr['code'], $adr['country']) = $prop->getParts(); $contact['address'][] = $adr; break; case 'BDAY': case 'ANNIVERSARY': case 'X-ANNIVERSARY': // We use getDateTime() method to support partial date format from vCard 4 // and to not throw exceptions on invalid input (T2492) if (method_exists($prop, 'getDateTime')) { $key = $prop->name == 'BDAY' ? 'birthday' : 'anniversary'; try { $contact[$key] = $prop->getDateTime(); $contact[$key]->_dateonly = true; } catch (\Exception $e) { // ignore } } break; case 'SEX': case 'GENDER': case 'X-GENDER': $contact['gender'] = $value; break; case 'ROLE': case 'X-PROFESSION': $contact['profession'] = $value; break; case 'X-MANAGER': case 'X-ASSISTANT': case 'X-CHILDREN': case 'X-SPOUSE': case 'X-MS-MANAGER': case 'X-MS-ASSISTANT': case 'X-MS-CHILDREN': case 'X-MS-SPOUSE': $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', '', $value); break; case 'IMPP': $prot = null; if (preg_match('/^[a-z]+:/i', $value)) list($prot, $val) = explode(':', $value, 2); else $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': if ($prop instanceof VObject\Property\Binary && $value) { $contact['photo'] = $value; } else if ($prop instanceof VObject\Property\Uri && preg_match('|^data:image/[a-z]+;base64,|i', $value, $m)) { $contact['photo'] = base64_decode(substr($value, strlen($m[0]))); } break; // VCard 4.0 properties case 'FBURL': $contact['freebusyurl'] = $value; break; case 'LANG': $contact['lang'][] = $value; break; case 'RELATED': $type = strtolower($prop->offsetGet('type')); if ($field = $this->related_map[$type]) { $contact[$field][] = $value; } else { $contact['related'][] = $value; } break; case 'KIND': case 'X-ADDRESSBOOKSERVER-KIND': $value_ = strtolower($value); if ($value_ == 'group') { $contact['_type'] = 'distribution-list'; } else if ($value_ == 'org') { // store vcard 4 KIND as custom property $contact['x-custom'][] = array('X-ABSHOWAS', 'COMPANY'); } break; case 'MEMBER': case 'X-ADDRESSBOOKSERVER-MEMBER': if (strpos($value, 'urn:uuid:') === 0) { $contact['member'][] = array('uid' => substr($value, 9)); } 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($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(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 ($vc->{$name} && 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 = $vc->create('X-ABLabel'); $ablabel->group = $group; $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 prop_filter($arr, $values, $inverse = false) { if (!is_array($values)) { $values = explode(',', $values); } // explode single, comma-separated value if (count($arr) == 1 && strpos($arr[0], ',')) { $arr = explode(',', $arr[0]); } $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']); } }