diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -3658,9 +3658,10 @@ }); // register dbl-click handler to open calendar edit dialog - $(rcmail.gui_objects.calendarslist).on('dblclick', ':not(.virtual) > .calname', function(e){ + $(rcmail.gui_objects.calendarslist).on('dblclick', ':not(.virtual) > .calname', function(e) { var id = $(this).closest('li').attr('id').replace(/^rcmlical/, ''); - me.calendar_edit_dialog(me.calendars[id]); + if (me.calendars[id] && me.calendars[id].driver != 'caldav') + me.calendar_edit_dialog(me.calendars[id]); }); // Make Elastic checkboxes pretty diff --git a/plugins/calendar/config.inc.php.dist b/plugins/calendar/config.inc.php.dist --- a/plugins/calendar/config.inc.php.dist +++ b/plugins/calendar/config.inc.php.dist @@ -25,9 +25,12 @@ +-------------------------------------------------------------------------+ */ -// backend type (database, kolab) +// backend type (database, kolab, caldav) $config['calendar_driver'] = "database"; +// CalDAV server location (required when calendar_driver = caldav) +$config['calendar_caldav_server'] = "http://localhost"; + // default calendar view (agendaDay, agendaWeek, month) $config['calendar_default_view'] = "agendaWeek"; diff --git a/plugins/calendar/drivers/caldav/caldav_calendar.php b/plugins/calendar/drivers/caldav/caldav_calendar.php new file mode 100644 --- /dev/null +++ b/plugins/calendar/drivers/caldav/caldav_calendar.php @@ -0,0 +1,904 @@ + + * + * Copyright (C) 2012-2022, Apheleia IT 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 . + */ + +class caldav_calendar extends kolab_storage_dav_folder +{ + public $ready = false; + public $rights = 'lrs'; + public $editable = false; + public $attachments = false; // TODO + public $alarms = false; + public $history = false; + public $subscriptions = false; + public $categories = []; + public $storage; + + public $type = 'event'; + + protected $cal; + protected $events = []; + protected $search_fields = ['title', 'description', 'location', 'attendees', 'categories']; + + /** + * Factory method to instantiate a caldav_calendar object + * + * @param string $id Calendar ID (encoded IMAP folder name) + * @param object $calendar Calendar plugin object + * + * @return caldav_calendar Self instance + */ + public static function factory($id, $calendar) + { + return new caldav_calendar($id, $calendar); + } + + /** + * Default constructor + */ + public function __construct($folder_or_id, $calendar) + { + if ($folder_or_id instanceof kolab_storage_dav_folder) { + $this->storage = $folder_or_id; + } + else { + // $this->storage = kolab_storage_dav::get_folder($folder_or_id); + } + + $this->cal = $calendar; + $this->id = $this->storage->id; + $this->attributes = $this->storage->attributes; + $this->ready = true; + + // Set writeable and alarms flags according to folder permissions + if ($this->ready) { + if ($this->storage->get_namespace() == 'personal') { + $this->editable = true; + $this->rights = 'lrswikxteav'; + $this->alarms = true; + } + else { + $rights = $this->storage->get_myrights(); + if ($rights && !PEAR::isError($rights)) { + $this->rights = $rights; + if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) { + $this->editable = strpos($rights, 'i');; + } + } + } + + // user-specific alarms settings win + $prefs = $this->cal->rc->config->get('kolab_calendars', []); + if (isset($prefs[$this->id]['showalarms'])) { + $this->alarms = $prefs[$this->id]['showalarms']; + } + } + + $this->default = $this->storage->default; + $this->subtype = $this->storage->subtype; + } + + /** + * Getter for the folder name + * + * @return string Name of the folder + */ + public function get_realname() + { + return $this->get_name(); + } + + /** + * Return color to display this calendar + */ + public function get_color($default = null) + { + if ($color = $this->storage->get_color()) { + return $color; + } + + return $default ?: 'cc0000'; + } + + /** + * Compose an URL for CalDAV access to this calendar (if configured) + */ + public function get_caldav_url() + { +/* + if ($template = $this->cal->rc->config->get('calendar_caldav_url', null)) { + return strtr($template, [ + '%h' => $_SERVER['HTTP_HOST'], + '%u' => urlencode($this->cal->rc->get_user_name()), + '%i' => urlencode($this->storage->get_uid()), + '%n' => urlencode($this->name), + ]); + } +*/ + return false; + } + + /** + * Update properties of this calendar folder + * + * @see caldav_driver::edit_calendar() + */ + public function update(&$prop) + { + // TODO + return null; + } + + /** + * Getter for a single event object + */ + public function get_event($id) + { + // remove our occurrence identifier if it's there + $master_id = preg_replace('/-\d{8}(T\d{6})?$/', '', $id); + + // directly access storage object + if (empty($this->events[$id]) && $master_id == $id && ($record = $this->storage->get_object($id))) { + $this->events[$id] = $record = $this->_to_driver_event($record, true); + } + + // maybe a recurring instance is requested + if (empty($this->events[$id]) && $master_id != $id) { + $instance_id = substr($id, strlen($master_id) + 1); + + if ($record = $this->storage->get_object($master_id)) { + $master = $record = $this->_to_driver_event($record); + } + + if (!empty($master)) { + // check for match in top-level exceptions (aka loose single occurrences) + if (!empty($master['_formatobj']) && ($instance = $master['_formatobj']->get_instance($instance_id))) { + $this->events[$id] = $this->_to_driver_event($instance, false, true, $master); + } + // check for match on the first instance already + else if (!empty($master['_instance']) && $master['_instance'] == $instance_id) { + $this->events[$id] = $master; + } + else if (!empty($master['recurrence'])) { + $start_date = $master['start']; + // For performance reasons we'll get only the specific instance + if (($date = substr($id, strlen($master_id) + 1, 8)) && strlen($date) == 8 && is_numeric($date)) { + $start_date = new DateTime($date . 'T000000', $master['start']->getTimezone()); + } + + $this->get_recurring_events($record, $start_date, null, $id, 1); + } + } + } + + return $this->events[$id]; + } + + /** + * Get attachment body + * @see calendar_driver::get_attachment_body() + */ + public function get_attachment_body($id, $event) + { + if (!$this->ready) { + return false; + } + + $data = $this->storage->get_attachment($event['id'], $id); + + if ($data == null) { + // try again with master UID + $uid = preg_replace('/-\d+(T\d{6})?$/', '', $event['id']); + if ($uid != $event['id']) { + $data = $this->storage->get_attachment($uid, $id); + } + } + + return $data; + } + + /** + * @param int Event's new start (unix timestamp) + * @param int Event's new end (unix timestamp) + * @param string Search query (optional) + * @param bool Include virtual events (optional) + * @param array Additional parameters to query storage + * @param array Additional query to filter events + * + * @return array A list of event records + */ + public function list_events($start, $end, $search = null, $virtual = 1, $query = [], $filter_query = null) + { + // convert to DateTime for comparisons + // #5190: make the range a little bit wider + // to workaround possible timezone differences + try { + $start = new DateTime('@' . ($start - 12 * 3600)); + } + catch (Exception $e) { + $start = new DateTime('@0'); + } + try { + $end = new DateTime('@' . ($end + 12 * 3600)); + } + catch (Exception $e) { + $end = new DateTime('today +10 years'); + } + + // get email addresses of the current user + $user_emails = $this->cal->get_user_emails(); + + // query Kolab storage + $query[] = ['dtstart', '<=', $end]; + $query[] = ['dtend', '>=', $start]; + + if (is_array($filter_query)) { + $query = array_merge($query, $filter_query); + } + + $words = []; + $partstat_exclude = []; + $events = []; + + if (!empty($search)) { + $search = mb_strtolower($search); + $words = rcube_utils::tokenize_string($search, 1); + foreach (rcube_utils::normalize_string($search, true) as $word) { + $query[] = ['words', 'LIKE', $word]; + } + } + + // set partstat filter to skip pending and declined invitations + if (empty($filter_query) + && $this->cal->rc->config->get('kolab_invitation_calendars') + && $this->get_namespace() != 'other' + ) { + $partstat_exclude = ['NEEDS-ACTION', 'DECLINED']; + } + + foreach ($this->storage->select($query) as $record) { + $event = $this->_to_driver_event($record, !$virtual, false); + + // remember seen categories + if (!empty($event['categories'])) { + $cat = is_array($event['categories']) ? $event['categories'][0] : $event['categories']; + $this->categories[$cat]++; + } + + // list events in requested time window + if ($event['start'] <= $end && $event['end'] >= $start) { + unset($event['_attendees']); + $add = true; + + // skip the first instance of a recurring event if listed in exdate + if ($virtual && !empty($event['recurrence']['EXDATE'])) { + $event_date = $event['start']->format('Ymd'); + $event_tz = $event['start']->getTimezone(); + + foreach ((array) $event['recurrence']['EXDATE'] as $exdate) { + $ex = clone $exdate; + $ex->setTimezone($event_tz); + + if ($ex->format('Ymd') == $event_date) { + $add = false; + break; + } + } + } + + // find and merge exception for the first instance + if ($virtual && !empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS'])) { + foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { + if ($event['_instance'] == $exception['_instance']) { + unset($exception['calendar'], $exception['className'], $exception['_folder_id']); + // clone date objects from main event before adjusting them with exception data + if (is_object($event['start'])) { + $event['start'] = clone $record['start']; + } + if (is_object($event['end'])) { + $event['end'] = clone $record['end']; + } + kolab_driver::merge_exception_data($event, $exception); + } + } + } + + if ($add) { + $events[] = $event; + } + } + + // resolve recurring events + if (!empty($event['recurrence']) && $virtual == 1) { + $events = array_merge($events, $this->get_recurring_events($event, $start, $end)); + } + // add top-level exceptions (aka loose single occurrences) + else if (!empty($record['exceptions'])) { + foreach ($record['exceptions'] as $ex) { + $component = $this->_to_driver_event($ex, false, false, $record); + if ($component['start'] <= $end && $component['end'] >= $start) { + $events[] = $component; + } + } + } + } + + // post-filter all events by fulltext search and partstat values + $me = $this; + $events = array_filter($events, function($event) use ($words, $partstat_exclude, $user_emails, $me) { + // fulltext search + if (count($words)) { + $hits = 0; + foreach ($words as $word) { + $hits += $me->fulltext_match($event, $word, false); + } + if ($hits < count($words)) { + return false; + } + } + + // partstat filter + if (count($partstat_exclude) && !empty($event['attendees'])) { + foreach ($event['attendees'] as $attendee) { + if ( + in_array($attendee['email'], $user_emails) + && in_array($attendee['status'], $partstat_exclude) + ) { + return false; + } + } + } + + return true; + }); + + // Apply event-to-mail relations + // $config = kolab_storage_config::get_instance(); + // $config->apply_links($events); + + // avoid session race conditions that will loose temporary subscriptions + $this->cal->rc->session->nowrite = true; + + return $events; + } + + /** + * Get number of events in the given calendar + * + * @param int Date range start (unix timestamp) + * @param int Date range end (unix timestamp) + * @param array Additional query to filter events + * + * @return int Number of events + */ + public function count_events($start, $end = null, $filter_query = null) + { + // convert to DateTime for comparisons + try { + $start = new DateTime('@'.$start); + } + catch (Exception $e) { + $start = new DateTime('@0'); + } + if ($end) { + try { + $end = new DateTime('@'.$end); + } + catch (Exception $e) { + $end = null; + } + } + + // query Kolab storage + $query[] = ['dtend', '>=', $start]; + + if ($end) { + $query[] = ['dtstart', '<=', $end]; + } + + // add query to exclude pending/declined invitations + if (empty($filter_query)) { + foreach ($this->cal->get_user_emails() as $email) { + $query[] = ['tags', '!=', 'x-partstat:' . $email . ':needs-action']; + $query[] = ['tags', '!=', 'x-partstat:' . $email . ':declined']; + } + } + else if (is_array($filter_query)) { + $query = array_merge($query, $filter_query); + } + + return $this->storage->count($query); + } + + /** + * Create a new event record + * + * @see calendar_driver::new_event() + * + * @return array|false The created record ID on success, False on error + */ + public function insert_event($event) + { + if (!is_array($event)) { + return false; + } + + // email links are stored separately + // $links = !empty($event['links']) ? $event['links'] : []; + // unset($event['links']); + + // generate new event from RC input + $object = $this->_from_driver_event($event); + $saved = $this->storage->save($object, 'event'); + + if (!$saved) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving event object to DAV server" + ], + true, false + ); + return false; + } + + // save links in configuration.relation object + // if ($this->save_links($event['uid'], $links)) { + // $object['links'] = $links; + // } + + $this->events = [$event['uid'] => $this->_to_driver_event($object, true)]; + + return true; + } + + /** + * Update a specific event record + * + * @return bool True on success, False on error + */ + public function update_event($event, $exception_id = null) + { + $updated = false; + $old = $this->storage->get_object(!empty($event['uid']) ? $event['uid'] : $event['id']); + + if (!$old || PEAR::isError($old)) { + return false; + } + + // email links are stored separately + // $links = !empty($event['links']) ? $event['links'] : []; + // unset($event['links']); + + $object = $this->_from_driver_event($event, $old); + $saved = $this->storage->save($object, 'event', $old['uid']); + + if (!$saved) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving event object to CalDAV server" + ], + true, false + ); + } + else { + // save links in configuration.relation object + // if ($this->save_links($event['uid'], $links)) { + // $object['links'] = $links; + // } + + $updated = true; + $this->events = [$event['uid'] => $this->_to_driver_event($object, true)]; + + // refresh local cache with recurring instances + if ($exception_id) { + $this->get_recurring_events($object, $event['start'], $event['end'], $exception_id); + } + } + + return $updated; + } + + /** + * Delete an event record + * + * @see calendar_driver::remove_event() + * + * @return bool True on success, False on error + */ + public function delete_event($event, $force = true) + { + $uid = !empty($event['uid']) ? $event['uid'] : $event['id']; + $deleted = $this->storage->delete($uid, $force); + + if (!$deleted) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error deleting event '{$uid}' from CalDAV server" + ], + true, false + ); + } + + return $deleted; + } + + /** + * Restore deleted event record + * + * @see calendar_driver::undelete_event() + * + * @return bool True on success, False on error + */ + public function restore_event($event) + { + // TODO + return false; + } + + /** + * Find messages linked with an event + */ + protected function get_links($uid) + { + return []; // TODO + $storage = kolab_storage_config::get_instance(); + return $storage->get_object_links($uid); + } + + /** + * Save message references (links) to an event + */ + protected function save_links($uid, $links) + { + return false; // TODO + $storage = kolab_storage_config::get_instance(); + return $storage->save_object_links($uid, (array) $links); + } + + /** + * Create instances of a recurring event + * + * @param array $event Hash array with event properties + * @param DateTime $start Start date of the recurrence window + * @param DateTime $end End date of the recurrence window + * @param string $event_id ID of a specific recurring event instance + * @param int $limit Max. number of instances to return + * + * @return array List of recurring event instances + */ + public function get_recurring_events($event, $start, $end = null, $event_id = null, $limit = null) + { + $object = $event['_formatobj']; + + if (!is_object($object)) { + return []; + } + + // determine a reasonable end date if none given + if (!$end) { + $end = clone $event['start']; + $end->add(new DateInterval('P100Y')); + } + + // read recurrence exceptions first + $events = []; + $exdata = []; + $futuredata = []; + $recurrence_id_format = libcalendaring::recurrence_id_format($event); + + if (!empty($event['recurrence'])) { + // copy the recurrence rule from the master event (to be used in the UI) + $recurrence_rule = $event['recurrence']; + unset($recurrence_rule['EXCEPTIONS'], $recurrence_rule['EXDATE']); + + if (!empty($event['recurrence']['EXCEPTIONS'])) { + foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { + if (empty($exception['_instance'])) { + $exception['_instance'] = libcalendaring::recurrence_instance_identifier($exception, !empty($event['allday'])); + } + + $rec_event = $this->_to_driver_event($exception, false, false, $event); + $rec_event['id'] = $event['uid'] . '-' . $exception['_instance']; + $rec_event['isexception'] = 1; + + // found the specifically requested instance: register exception (single occurrence wins) + if ( + $rec_event['id'] == $event_id + && (empty($this->events[$event_id]) || !empty($this->events[$event_id]['thisandfuture'])) + ) { + $rec_event['recurrence'] = $recurrence_rule; + $rec_event['recurrence_id'] = $event['uid']; + $this->events[$rec_event['id']] = $rec_event; + } + + // remember this exception's date + $exdate = substr($exception['_instance'], 0, 8); + if (empty($exdata[$exdate]) || !empty($exdata[$exdate]['thisandfuture'])) { + $exdata[$exdate] = $rec_event; + } + if (!empty($rec_event['thisandfuture'])) { + $futuredata[$exdate] = $rec_event; + } + } + } + } + + // found the specifically requested instance, exiting... + if ($event_id && !empty($this->events[$event_id])) { + return [$this->events[$event_id]]; + } + + // Check first occurrence, it might have been moved + if ($first = $exdata[$event['start']->format('Ymd')]) { + // return it only if not already in the result, but in the requested period + if (!($event['start'] <= $end && $event['end'] >= $start) + && ($first['start'] <= $end && $first['end'] >= $start) + ) { + $events[] = $first; + } + } + + if ($limit && count($events) >= $limit) { + return $events; + } + + // use libkolab to compute recurring events + $recurrence = new kolab_date_recurrence($object); + + $i = 0; + while ($next_event = $recurrence->next_instance()) { + $datestr = $next_event['start']->format('Ymd'); + $instance_id = $next_event['start']->format($recurrence_id_format); + + // use this event data for future recurring instances + if (!empty($futuredata[$datestr])) { + $overlay_data = $futuredata[$datestr]; + } + + $rec_id = $event['uid'] . '-' . $instance_id; + $exception = !empty($exdata[$datestr]) ? $exdata[$datestr] : $overlay_data; + $event_start = $next_event['start']; + $event_end = $next_event['end']; + + // copy some event from exception to get proper start/end dates + if ($exception) { + $event_copy = $next_event; + caldav_driver::merge_exception_dates($event_copy, $exception); + $event_start = $event_copy['start']; + $event_end = $event_copy['end']; + } + + // add to output if in range + if (($event_start <= $end && $event_end >= $start) || ($event_id && $rec_id == $event_id)) { + $rec_event = $this->_to_driver_event($next_event, false, false, $event); + $rec_event['_instance'] = $instance_id; + $rec_event['_count'] = $i + 1; + + if ($exception) { + // copy data from exception + caldav_driver::merge_exception_data($rec_event, $exception); + } + + $rec_event['id'] = $rec_id; + $rec_event['recurrence_id'] = $event['uid']; + $rec_event['recurrence'] = $recurrence_rule; + unset($rec_event['_attendees']); + $events[] = $rec_event; + + if ($rec_id == $event_id) { + $this->events[$rec_id] = $rec_event; + break; + } + + if ($limit && count($events) >= $limit) { + return $events; + } + } + else if ($next_event['start'] > $end) { + // stop loop if out of range + break; + } + + // avoid endless recursion loops + if (++$i > 100000) { + break; + } + } + + return $events; + } + + /** + * Convert from storage format to internal representation + */ + private function _to_driver_event($record, $noinst = false, $links = true, $master_event = null) + { + $record['calendar'] = $this->id; + + // remove (possibly outdated) cached parameters + unset($record['_folder_id'], $record['className']); + + // if ($links && !array_key_exists('links', $record)) { + // $record['links'] = $this->get_links($record['uid']); + // } + + $ns = $this->get_namespace(); + + if ($ns == 'other') { + $record['className'] = 'fc-event-ns-other'; + } + + if ($ns == 'other' || !$this->cal->rc->config->get('kolab_invitation_calendars')) { + $record = caldav_driver::add_partstat_class($record, ['NEEDS-ACTION', 'DECLINED'], $this->get_owner()); + + // Modify invitation status class name, when invitation calendars are disabled + // we'll use opacity only for declined/needs-action events + $record['className'] = str_replace('-invitation', '', $record['className']); + } + + // add instance identifier to first occurrence (master event) + $recurrence_id_format = libcalendaring::recurrence_id_format($master_event ? $master_event : $record); + if (!$noinst && !empty($record['recurrence']) && empty($record['recurrence_id']) && empty($record['_instance'])) { + $record['_instance'] = $record['start']->format($recurrence_id_format); + } + else if (isset($record['recurrence_date']) && is_a($record['recurrence_date'], 'DateTime')) { + $record['_instance'] = $record['recurrence_date']->format($recurrence_id_format); + } + + // clean up exception data + if (!empty($record['recurrence']) && !empty($record['recurrence']['EXCEPTIONS'])) { + array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { + unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']); + }); + } + + // Load the given event data into a libkolabxml container + // it's needed for recurrence resolving, which uses libcalendaring + // TODO: Drop dependency on libkolabxml? + $event_xml = new kolab_format_event(); + $event_xml->set($record); + $record['_formatobj'] = $event_xml; + + return $record; + } + + /** + * Convert the given event record into a data structure that can be passed to the storage backend for saving + * (opposite of self::_to_driver_event()) + */ + private function _from_driver_event($event, $old = []) + { + // set current user as ORGANIZER + if ($identity = $this->cal->rc->user->list_emails(true)) { + $event['attendees'] = !empty($event['attendees']) ? $event['attendees'] : []; + $found = false; + + // there can be only resources on attendees list (T1484) + // let's check the existence of an organizer + foreach ($event['attendees'] as $attendee) { + if (!empty($attendee['role']) && $attendee['role'] == 'ORGANIZER') { + $found = true; + break; + } + } + + if (!$found) { + $event['attendees'][] = ['role' => 'ORGANIZER', 'name' => $identity['name'], 'email' => $identity['email']]; + } + + $event['_owner'] = $identity['email']; + } + + // remove EXDATE values if RDATE is given + if (!empty($event['recurrence']['RDATE'])) { + $event['recurrence']['EXDATE'] = []; + } + + // remove recurrence information (e.g. EXDATES and EXCEPTIONS) entirely + if (!empty($event['recurrence']) && empty($event['recurrence']['FREQ']) && empty($event['recurrence']['RDATE'])) { + $event['recurrence'] = []; + } + + // keep 'comment' from initial itip invitation + if (!empty($old['comment'])) { + $event['comment'] = $old['comment']; + } + + // remove some internal properties which should not be cached + $cleanup_fn = function(&$event) { + unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_folder_id'], + $event['calendar'], $event['className'], $event['recurrence_id'], + $event['attachments'], $event['deleted_attachments']); + }; + + $cleanup_fn($event); + + // clean up exception data + if (!empty($event['exceptions'])) { + array_walk($event['exceptions'], function(&$exception) use ($cleanup_fn) { + unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj']); + $cleanup_fn($exception); + }); + } + + // copy meta data (starting with _) from old object + foreach ((array) $old as $key => $val) { + if (!isset($event[$key]) && $key[0] == '_') { + $event[$key] = $val; + } + } + + return $event; + } + + /** + * Match the given word in the event contents + */ + public function fulltext_match($event, $word, $recursive = true) + { + $hits = 0; + foreach ($this->search_fields as $col) { + if (empty($event[$col])) { + continue; + } + + $sval = is_array($event[$col]) ? self::_complex2string($event[$col]) : $event[$col]; + if (empty($sval)) { + continue; + } + + // do a simple substring matching (to be improved) + $val = mb_strtolower($sval); + if (strpos($val, $word) !== false) { + $hits++; + break; + } + } + + return $hits; + } + + /** + * Convert a complex event attribute to a string value + */ + private static function _complex2string($prop) + { + static $ignorekeys = ['role', 'status', 'rsvp']; + + $out = ''; + if (is_array($prop)) { + foreach ($prop as $key => $val) { + if (is_numeric($key)) { + $out .= self::_complex2string($val); + } + else if (!in_array($key, $ignorekeys)) { + $out .= $val . ' '; + } + } + } + else if (is_string($prop) || is_numeric($prop)) { + $out .= $prop . ' '; + } + + return rtrim($out); + } +} diff --git a/plugins/calendar/drivers/caldav/caldav_driver.php b/plugins/calendar/drivers/caldav/caldav_driver.php new file mode 100644 --- /dev/null +++ b/plugins/calendar/drivers/caldav/caldav_driver.php @@ -0,0 +1,530 @@ + + * + * Copyright (C) 2012-2022, Apheleia IT 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 . + */ + +require_once(__DIR__ . '/../kolab/kolab_driver.php'); + +class caldav_driver extends kolab_driver +{ + // features this backend supports + public $alarms = true; + public $attendees = true; + public $freebusy = true; + public $attachments = false; // TODO + public $undelete = false; // TODO + public $alarm_types = ['DISPLAY', 'AUDIO']; + public $categoriesimmutable = true; + + /** + * Default constructor + */ + public function __construct($cal) + { + $cal->require_plugin('libkolab'); + + // load helper classes *after* libkolab has been loaded (#3248) + require_once(__DIR__ . '/caldav_calendar.php'); + // require_once(__DIR__ . '/kolab_user_calendar.php'); + // require_once(__DIR__ . '/caldav_invitation_calendar.php'); + + $this->cal = $cal; + $this->rc = $cal->rc; + + // Initialize the CalDAV storage + $url = $this->rc->config->get('calendar_caldav_server', 'http://localhost'); + $this->storage = new kolab_storage_dav($url); + + $this->cal->register_action('push-freebusy', [$this, 'push_freebusy']); + $this->cal->register_action('calendar-acl', [$this, 'calendar_acl']); + + // $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); + + // TODO: get configuration for the Bonnie API + // $this->bonnie_api = libkolab::get_bonnie_api(); + } + + /** + * Read available calendars from server + */ + protected function _read_calendars() + { + // already read sources + if (isset($this->calendars)) { + return $this->calendars; + } + + // get all folders that support VEVENT, sorted by namespace/name + $folders = $this->storage->get_folders('event'); + // + $this->storage->get_user_folders('event', true); + + $this->calendars = []; + + foreach ($folders as $folder) { + $calendar = $this->_to_calendar($folder); + if ($calendar->ready) { + $this->calendars[$calendar->id] = $calendar; + if ($calendar->editable) { + $this->has_writeable = true; + } + } + } + + return $this->calendars; + } + + /** + * Convert kolab_storage_folder into caldav_calendar + */ + protected function _to_calendar($folder) + { + if ($folder instanceof caldav_calendar) { + return $folder; + } + + if ($folder instanceof kolab_storage_folder_user) { + $calendar = new kolab_user_calendar($folder, $this->cal); + $calendar->subscriptions = count($folder->children) > 0; + } + else { + $calendar = new caldav_calendar($folder, $this->cal); + } + + return $calendar; + } + + /** + * Get a list of available calendars from this source. + * + * @param int $filter Bitmask defining filter criterias + * @param object $tree Reference to hierarchical folder tree object + * + * @return array List of calendars + */ + public function list_calendars($filter = 0, &$tree = null) + { + $this->_read_calendars(); + + $folders = $this->filter_calendars($filter); + $calendars = []; + + // include virtual folders for a full folder tree +/* + if (!is_null($tree)) { + $folders = $this->storage->folder_hierarchy($folders, $tree); + } +*/ + $parents = array_keys($this->calendars); + + foreach ($folders as $id => $cal) { +/* + $path = explode('/', $cal->name); + + // find parent + do { + array_pop($path); + $parent_id = $this->storage->folder_id(implode('/', $path)); + } + while (count($path) > 1 && !in_array($parent_id, $parents)); + + // restore "real" parent ID + if ($parent_id && !in_array($parent_id, $parents)) { + $parent_id = $this->storage->folder_id($cal->get_parent()); + } + + $parents[] = $cal->id; + + if ($cal->virtual) { + $calendars[$cal->id] = [ + 'id' => $cal->id, + 'name' => $cal->get_name(), + 'listname' => $cal->get_foldername(), + 'editname' => $cal->get_foldername(), + 'virtual' => true, + 'editable' => false, + 'group' => $cal->get_namespace(), + ]; + } + else { +*/ + // additional folders may come from kolab_storage_dav::folder_hierarchy() above + // make sure we deal with caldav_calendar instances + $cal = $this->_to_calendar($cal); + $this->calendars[$cal->id] = $cal; + + $is_user = ($cal instanceof caldav_user_calendar); + + $calendars[$cal->id] = [ + 'id' => $cal->id, + 'name' => $cal->get_name(), + 'listname' => $cal->get_foldername(), + 'editname' => $cal->get_foldername(), + 'title' => '', // $cal->get_title(), + 'color' => $cal->get_color(), + 'editable' => $cal->editable, + 'group' => $is_user ? 'other user' : $cal->get_namespace(), + 'active' => $cal->is_active(), + 'owner' => $cal->get_owner(), + 'removable' => !$cal->default, + // extras to hide some elements in the UI + 'subscriptions' => false, + 'driver' => 'caldav', + ]; + + if (!$is_user) { + $calendars[$cal->id] += [ + 'default' => $cal->default, + 'rights' => $cal->rights, + 'showalarms' => $cal->alarms, + 'history' => !empty($this->bonnie_api), + 'children' => true, // TODO: determine if that folder indeed has child folders + 'parent' => $parent_id, + 'subtype' => $cal->subtype, + 'caldavurl' => '', // $cal->get_caldav_url(), + ]; + } +/* + } +*/ + if ($cal->subscriptions) { + $calendars[$cal->id]['subscribed'] = $cal->is_subscribed(); + } + } +/* + // list virtual calendars showing invitations + if ($this->rc->config->get('kolab_invitation_calendars') && !($filter & self::FILTER_INSERTABLE)) { + foreach ([self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED] as $id) { + $cal = new caldav_invitation_calendar($id, $this->cal); + if (!($filter & self::FILTER_ACTIVE) || $cal->is_active()) { + $calendars[$id] = [ + 'id' => $cal->id, + 'name' => $cal->get_name(), + 'listname' => $cal->get_name(), + 'editname' => $cal->get_foldername(), + 'title' => $cal->get_title(), + 'color' => $cal->get_color(), + 'editable' => $cal->editable, + 'rights' => $cal->rights, + 'showalarms' => $cal->alarms, + 'history' => !empty($this->bonnie_api), + 'group' => 'x-invitations', + 'default' => false, + 'active' => $cal->is_active(), + 'owner' => $cal->get_owner(), + 'children' => false, + 'counts' => $id == self::INVITATIONS_CALENDAR_PENDING, + ]; + + + if (is_object($tree)) { + $tree->children[] = $cal; + } + } + } + } +*/ + // append the virtual birthdays calendar + if ($this->rc->config->get('calendar_contact_birthdays', false) && !($filter & self::FILTER_INSERTABLE)) { + $id = self::BIRTHDAY_CALENDAR_ID; + $prefs = $this->rc->config->get('kolab_calendars', []); // read local prefs + + if (!($filter & self::FILTER_ACTIVE) || !empty($prefs[$id]['active'])) { + $calendars[$id] = [ + 'id' => $id, + 'name' => $this->cal->gettext('birthdays'), + 'listname' => $this->cal->gettext('birthdays'), + 'color' => !empty($prefs[$id]['color']) ? $prefs[$id]['color'] : '87CEFA', + 'active' => !empty($prefs[$id]['active']), + 'showalarms' => (bool) $this->rc->config->get('calendar_birthdays_alarm_type'), + 'group' => 'x-birthdays', + 'editable' => false, + 'default' => false, + 'children' => false, + 'history' => false, + ]; + } + } + + return $calendars; + } + + /** + * Get the caldav_calendar instance for the given calendar ID + * + * @param string Calendar identifier (encoded imap folder name) + * + * @return ?caldav_calendar Object nor null if calendar doesn't exist + */ + public function get_calendar($id) + { + $this->_read_calendars(); + + // create calendar object if necessary + if (empty($this->calendars[$id])) { + if (in_array($id, [self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED])) { + return new caldav_invitation_calendar($id, $this->cal); + } + + // for unsubscribed calendar folders + if ($id !== self::BIRTHDAY_CALENDAR_ID) { + $calendar = caldav_calendar::factory($id, $this->cal); + if ($calendar->ready) { + $this->calendars[$calendar->id] = $calendar; + } + } + } + + return !empty($this->calendars[$id]) ? $this->calendars[$id] : null; + } + + /** + * Search for shared or otherwise not listed calendars the user has access + * + * @param string Search string + * @param string Section/source to search + * + * @return array List of calendars + */ + public function search_calendars($query, $source) + { + $this->calendars = []; + $this->search_more_results = false; +/* + // find unsubscribed IMAP folders that have "event" type + if ($source == 'folders') { + foreach ((array) $this->storage->search_folders('event', $query, ['other']) as $folder) { + $calendar = new kolab_calendar($folder->name, $this->cal); + $this->calendars[$calendar->id] = $calendar; + } + } + // find other user's virtual calendars + else if ($source == 'users') { + // we have slightly more space, so display twice the number + $limit = $this->rc->config->get('autocomplete_max', 15) * 2; + + foreach ($this->storage->search_users($query, 0, [], $limit, $count) as $user) { + $calendar = new caldav_user_calendar($user, $this->cal); + $this->calendars[$calendar->id] = $calendar; + + // search for calendar folders shared by this user + foreach ($this->storage->list_user_folders($user, 'event', false) as $foldername) { + $cal = new caldav_calendar($foldername, $this->cal); + $this->calendars[$cal->id] = $cal; + $calendar->subscriptions = true; + } + } + + if ($count > $limit) { + $this->search_more_results = true; + } + } + + // don't list the birthday calendar + $this->rc->config->set('calendar_contact_birthdays', false); + $this->rc->config->set('kolab_invitation_calendars', false); +*/ + return $this->list_calendars(); + } + + /** + * Get events from source. + * + * @param int Event's new start (unix timestamp) + * @param int Event's new end (unix timestamp) + * @param string Search query (optional) + * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) + * @param bool Include virtual events (optional) + * @param int Only list events modified since this time (unix timestamp) + * + * @return array A list of event records + */ + public function load_events($start, $end, $search = null, $calendars = null, $virtual = 1, $modifiedsince = null) + { + if ($calendars && is_string($calendars)) { + $calendars = explode(',', $calendars); + } + else if (!$calendars) { + $this->_read_calendars(); + $calendars = array_keys($this->calendars); + } + + $query = []; + $events = []; + $categories = []; + + if ($modifiedsince) { + $query[] = ['changed', '>=', $modifiedsince]; + } + + foreach ($calendars as $cid) { + if ($storage = $this->get_calendar($cid)) { + $events = array_merge($events, $storage->list_events($start, $end, $search, $virtual, $query)); + $categories += $storage->categories; + } + } + + // add events from the address books birthday calendar + if (in_array(self::BIRTHDAY_CALENDAR_ID, $calendars)) { + $events = array_merge($events, $this->load_birthday_events($start, $end, $search, $modifiedsince)); + } + + // add new categories to user prefs + $old_categories = $this->rc->config->get('calendar_categories', $this->default_categories); + $newcats = array_udiff( + array_keys($categories), + array_keys($old_categories), + function($a, $b) { return strcasecmp($a, $b); } + ); + + if (!empty($newcats)) { + foreach ($newcats as $category) { + $old_categories[$category] = ''; // no color set yet + } + $this->rc->user->save_prefs(['calendar_categories' => $old_categories]); + } + + array_walk($events, 'caldav_driver::to_rcube_event'); + + return $events; + } + + /** + * Create instances of a recurring event + * + * @param array Hash array with event properties + * @param DateTime Start date of the recurrence window + * @param DateTime End date of the recurrence window + * + * @return array List of recurring event instances + */ + public function get_recurring_events($event, $start, $end = null) + { + // load the given event data into a libkolabxml container + $event_xml = new kolab_format_event(); + $event_xml->set($event); + $event['_formatobj'] = $event_xml; + + $this->_read_calendars(); + $storage = reset($this->calendars); + + return $storage->get_recurring_events($event, $start, $end); + } + + /** + * + */ + protected function get_recurrence_count($event, $dtstart) + { + // load the given event data into a libkolabxml container + $event_xml = new kolab_format_event(); + $event_xml->set($event); + $event['_formatobj'] = $event_xml; + + // use libkolab to compute recurring events + $recurrence = new kolab_date_recurrence($event['_formatobj']); + + $count = 0; + while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { + $count++; + } + + return $count; + } + + /** + * Callback function to produce driver-specific calendar create/edit form + * + * @param string Request action 'form-edit|form-new' + * @param array Calendar properties (e.g. id, color) + * @param array Edit form fields + * + * @return string HTML content of the form + */ + public function calendar_form($action, $calendar, $formfields) + { + $special_calendars = [ + self::BIRTHDAY_CALENDAR_ID, + self::INVITATIONS_CALENDAR_PENDING, + self::INVITATIONS_CALENDAR_DECLINED + ]; + + // show default dialog for birthday calendar + if (in_array($calendar['id'], $special_calendars)) { + if ($calendar['id'] != self::BIRTHDAY_CALENDAR_ID) { + unset($formfields['showalarms']); + } + + // General tab + $form['props'] = [ + 'name' => $this->rc->gettext('properties'), + 'fields' => $formfields, + ]; + + return kolab_utils::folder_form($form, '', 'calendar'); + } + + $this->_read_calendars(); + + if (!empty($calendar['id']) && ($cal = $this->calendars[$calendar['id']])) { + $folder = $cal->get_realname(); // UTF7 + $color = $cal->get_color(); + } + else { + $folder = ''; + $color = ''; + } + + $hidden_fields[] = ['name' => 'oldname', 'value' => $folder]; + + $form = []; + $protected = false; // TODO + + // Disable folder name input + if ($protected) { + $input_name = new html_hiddenfield(['name' => 'name', 'id' => 'calendar-name']); + $formfields['name']['value'] = $this->storage->object_name($folder) + . $input_name->show($folder); + } + + // calendar name (default field) + $form['props']['fields']['location'] = $formfields['name']; + + if ($protected) { + // prevent user from moving folder + $hidden_fields[] = ['name' => 'parent', 'value' => '']; // TODO + } + else { + $select = $this->storage->folder_selector('event', ['name' => 'parent', 'id' => 'calendar-parent'], $folder); + + $form['props']['fields']['path'] = [ + 'id' => 'calendar-parent', + 'label' => $this->cal->gettext('parentcalendar'), + 'value' => $select->show(strlen($folder) ? '' : ''), // TODO + ]; + } + + // calendar color (default field) + $form['props']['fields']['color'] = $formfields['color']; + $form['props']['fields']['alarms'] = $formfields['showalarms']; + + return kolab_utils::folder_form($form, $folder, 'calendar', $hidden_fields); + } +} diff --git a/plugins/calendar/drivers/caldav/caldav_invitation_calendar.php b/plugins/calendar/drivers/caldav/caldav_invitation_calendar.php new file mode 100644 --- /dev/null +++ b/plugins/calendar/drivers/caldav/caldav_invitation_calendar.php @@ -0,0 +1,47 @@ + + * + * Copyright (C) 2014-2022, Apheleia IT 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 . + */ + +require_once(__DIR__ . '/../kolab/kolab_driver.php'); +require_once(__DIR__ . '/../kolab/kolab_invitation_calendar.php'); + +class caldav_invitation_calendar extends kolab_invitation_calendar +{ + public $id = '__caldav_invitation__'; + + /** + * Default constructor + */ + public function __construct($id, $calendar) + { + $this->cal = $calendar; + $this->id = $id; + } + + /** + * Compose an URL for CalDAV access to this calendar (if configured) + */ + public function get_caldav_url() + { + return false; // TODO + } +} diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -37,12 +37,13 @@ public $alarm_types = ['DISPLAY', 'AUDIO']; public $categoriesimmutable = true; - private $rc; - private $cal; - private $calendars; - private $has_writeable = false; - private $freebusy_trigger = false; - private $bonnie_api = false; + protected $rc; + protected $cal; + protected $calendars; + protected $storage; + protected $has_writeable = false; + protected $freebusy_trigger = false; + protected $bonnie_api = false; /** * Default constructor @@ -56,8 +57,9 @@ require_once(__DIR__ . '/kolab_user_calendar.php'); require_once(__DIR__ . '/kolab_invitation_calendar.php'); - $this->cal = $cal; - $this->rc = $cal->rc; + $this->cal = $cal; + $this->rc = $cal->rc; + $this->storage = new kolab_storage(); $this->cal->register_action('push-freebusy', [$this, 'push_freebusy']); $this->cal->register_action('calendar-acl', [$this, 'calendar_acl']); @@ -79,7 +81,7 @@ /** * Read available calendars from server */ - private function _read_calendars() + protected function _read_calendars() { // already read sources if (isset($this->calendars)) { @@ -87,8 +89,8 @@ } // get all folders that have "event" type, sorted by namespace/name - $folders = kolab_storage::sort_folders( - kolab_storage::get_folders('event') + kolab_storage::get_user_folders('event', true) + $folders = $this->storage->sort_folders( + $this->storage->get_folders('event') + kolab_storage::get_user_folders('event', true) ); $this->calendars = []; @@ -109,7 +111,7 @@ /** * Convert kolab_storage_folder into kolab_calendar */ - private function _to_calendar($folder) + protected function _to_calendar($folder) { if ($folder instanceof kolab_calendar) { return $folder; @@ -152,7 +154,7 @@ // include virtual folders for a full folder tree if (!is_null($tree)) { - $folders = kolab_storage::folder_hierarchy($folders, $tree); + $folders = $this->storage->folder_hierarchy($folders, $tree); } $parents = array_keys($this->calendars); @@ -163,13 +165,13 @@ // find parent do { array_pop($imap_path); - $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); + $parent_id = $this->storage->folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !in_array($parent_id, $parents)); // restore "real" parent ID if ($parent_id && !in_array($parent_id, $parents)) { - $parent_id = kolab_storage::folder_id($cal->get_parent()); + $parent_id = $this->storage->folder_id($cal->get_parent()); } $parents[] = $cal->id; @@ -385,15 +387,15 @@ $prop['active'] = true; $prop['subscribed'] = true; - $folder = kolab_storage::folder_update($prop); + $folder = $this->storage->folder_update($prop); if ($folder === false) { - $this->last_error = $this->cal->gettext(kolab_storage::$last_error); + $this->last_error = $this->cal->gettext($this->storage->last_error); return false; } // create ID - $id = kolab_storage::folder_id($folder); + $id = $this->storage->folder_id($folder); // save color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); @@ -467,22 +469,22 @@ // apply to child folders, too if (!empty($prop['recursive'])) { - foreach ((array) kolab_storage::list_folders($cal->storage->name, '*', 'event') as $subfolder) { + foreach ((array) $this->storage->list_folders($cal->storage->name, '*', 'event') as $subfolder) { if (isset($prop['permanent'])) { if ($prop['permanent']) { - kolab_storage::folder_subscribe($subfolder); + $this->storage->folder_subscribe($subfolder); } else { - kolab_storage::folder_unsubscribe($subfolder); + $this->storage->folder_unsubscribe($subfolder); } } if (isset($prop['active'])) { if ($prop['active']) { - kolab_storage::folder_activate($subfolder); + $this->storage->folder_activate($subfolder); } else { - kolab_storage::folder_deactivate($subfolder); + $this->storage->folder_deactivate($subfolder); } } } @@ -511,7 +513,7 @@ $folder = $cal->get_realname(); // TODO: unsubscribe if no admin rights - if (kolab_storage::folder_delete($folder)) { + if ($this->storage->folder_delete($folder)) { // remove color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); unset($prefs['kolab_calendars'][$prop['id']]); @@ -520,7 +522,7 @@ return true; } else { - $this->last_error = kolab_storage::$last_error; + $this->last_error = $this->storage->last_error; } } @@ -537,7 +539,7 @@ */ public function search_calendars($query, $source) { - if (!kolab_storage::setup()) { + if (!$this->storage->setup()) { return []; } @@ -546,7 +548,7 @@ // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { - foreach ((array) kolab_storage::search_folders('event', $query, ['other']) as $folder) { + foreach ((array) $this->storage->search_folders('event', $query, ['other']) as $folder) { $calendar = new kolab_calendar($folder->name, $this->cal); $this->calendars[$calendar->id] = $calendar; } @@ -556,12 +558,12 @@ // we have slightly more space, so display twice the number $limit = $this->rc->config->get('autocomplete_max', 15) * 2; - foreach (kolab_storage::search_users($query, 0, [], $limit, $count) as $user) { + foreach ($this->storage->search_users($query, 0, [], $limit, $count) as $user) { $calendar = new kolab_user_calendar($user, $this->cal); $this->calendars[$calendar->id] = $calendar; // search for calendar folders shared by this user - foreach (kolab_storage::list_user_folders($user, 'event', false) as $foldername) { + foreach ($this->storage->list_user_folders($user, 'event', false) as $foldername) { $cal = new kolab_calendar($foldername, $this->cal); $this->calendars[$cal->id] = $cal; $calendar->subscriptions = true; @@ -1001,7 +1003,7 @@ /** * Wrapper to update an event object depending on the given savemode */ - private function update_event($event) + protected function update_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) { return false; @@ -1970,7 +1972,7 @@ /** * */ - private function get_recurrence_count($event, $dtstart) + protected function get_recurrence_count($event, $dtstart) { // load the given event data into a libkolabxml container if (empty($event['_formatobj'])) { @@ -2014,7 +2016,7 @@ 'follow_redirects' => true, ]; - $request = libkolab::http_request(kolab_storage::get_freebusy_url($email), 'GET', $request_config); + $request = libkolab::http_request($this->storage->get_freebusy_url($email), 'GET', $request_config); $response = $request->send(); // authentication required @@ -2501,7 +2503,7 @@ * * @return array (uid,folder,msguid) tuple */ - private function _resolve_event_identity($event) + protected function _resolve_event_identity($event) { $mailbox = $msguid = null; @@ -2602,7 +2604,7 @@ // Disable folder name input if ($protected) { $input_name = new html_hiddenfield(['name' => 'name', 'id' => 'calendar-name']); - $formfields['name']['value'] = kolab_storage::object_name($folder) + $formfields['name']['value'] = $this->storage->object_name($folder) . $input_name->show($folder); } @@ -2614,7 +2616,7 @@ $hidden_fields[] = ['name' => 'parent', 'value' => $path_imap]; } else { - $select = kolab_storage::folder_selector('event', ['name' => 'parent', 'id' => 'calendar-parent'], $folder); + $select = $this->storage->folder_selector('event', ['name' => 'parent', 'id' => 'calendar-parent'], $folder); $form['props']['fields']['path'] = [ 'id' => 'calendar-parent', diff --git a/plugins/calendar/lib/calendar_ui.php b/plugins/calendar/lib/calendar_ui.php --- a/plugins/calendar/lib/calendar_ui.php +++ b/plugins/calendar/lib/calendar_ui.php @@ -381,15 +381,17 @@ ); } - $content .= html::tag('input', [ - 'type' => 'checkbox', - 'name' => '_cal[]', - 'value' => $id, - 'checked' => !empty($prop['active']), - 'aria-labelledby' => $label_id - ]) - . html::span('actions', $actions) - . html::span(['class' => 'handle', 'style' => "background-color: #$color"], ' '); + if (!isset($prop['subscriptions']) || $prop['subscriptions'] !== false) { + $content .= html::tag('input', [ + 'type' => 'checkbox', + 'name' => '_cal[]', + 'value' => $id, + 'checked' => !empty($prop['active']), + 'aria-labelledby' => $label_id + ]) + . html::span('actions', $actions) + . html::span(['class' => 'handle', 'style' => "background-color: #$color"], ' '); + } } $content = html::div(join(' ', $classes), $content); diff --git a/plugins/calendar/skins/elastic/templates/calendar.html b/plugins/calendar/skins/elastic/templates/calendar.html --- a/plugins/calendar/skins/elastic/templates/calendar.html +++ b/plugins/calendar/skins/elastic/templates/calendar.html @@ -8,9 +8,11 @@
+ +
diff --git a/plugins/kolab_addressbook/config.inc.php.dist b/plugins/kolab_addressbook/config.inc.php.dist --- a/plugins/kolab_addressbook/config.inc.php.dist +++ b/plugins/kolab_addressbook/config.inc.php.dist @@ -1,5 +1,10 @@ diff --git a/plugins/kolab_addressbook/kolab_addressbook.php b/plugins/kolab_addressbook/kolab_addressbook.php --- a/plugins/kolab_addressbook/kolab_addressbook.php +++ b/plugins/kolab_addressbook/kolab_addressbook.php @@ -31,12 +31,13 @@ { public $task = '?(?!logout).*'; + public $driver; + public $bonnie_api = false; + private $sources; - private $folders; private $rc; private $ui; - - public $bonnie_api = false; + private $driver_class; const GLOBAL_FIRST = 0; const PERSONAL_FIRST = 1; @@ -48,13 +49,17 @@ */ public function init() { - require_once(dirname(__FILE__) . '/lib/rcube_kolab_contacts.php'); - $this->rc = rcube::get_instance(); // load required plugin $this->require_plugin('libkolab'); + $this->load_config(); + + $this->driver = $this->rc->config->get('kolab_addressbook_driver') ?: 'kolab'; + $this->driver_class = 'rcube_' . $this->driver . '_contacts'; + require_once(dirname(__FILE__) . '/lib/' . $this->driver_class . '.php'); + // register hooks $this->add_hook('addressbooks_list', array($this, 'address_sources')); $this->add_hook('addressbook_get', array($this, 'get_address_book')); @@ -81,7 +86,6 @@ // Load UI elements if ($this->api->output->type == 'html') { - $this->load_config(); require_once($this->home . '/lib/kolab_addressbook_ui.php'); $this->ui = new kolab_addressbook_ui($this); @@ -103,9 +107,11 @@ $this->add_hook('preferences_save', array($this, 'prefs_save')); } - $this->add_hook('folder_delete', array($this, 'prefs_folder_delete')); - $this->add_hook('folder_rename', array($this, 'prefs_folder_rename')); - $this->add_hook('folder_update', array($this, 'prefs_folder_update')); + if ($this->driver == 'kolab') { + $this->add_hook('folder_delete', array($this, 'prefs_folder_delete')); + $this->add_hook('folder_rename', array($this, 'prefs_folder_rename')); + $this->add_hook('folder_update', array($this, 'prefs_folder_update')); + } } /** @@ -211,10 +217,22 @@ $out .= $this->addressbook_list_item($id, $source, $jsdata) . ''; } + $filter = function($source) { return !empty($source['kolab']) && empty($source['hidden']); }; + $folders = array_filter($sources, $filter); + // render a hierarchical list of kolab contact folders - kolab_storage::folder_hierarchy($this->folders, $tree); - if ($tree && !empty($tree->children)) { - $out .= $this->folder_tree_html($tree, $sources, $jsdata); + // TODO: Move this to the drivers + if ($this->driver == 'kolab') { + kolab_storage::folder_hierarchy($folders, $tree); + if ($tree && !empty($tree->children)) { + $out .= $this->folder_tree_html($tree, $sources, $jsdata); + } + } + else { + foreach ($folders as $j => $source) { + $id = strval(strlen($source['id']) ? $source['id'] : $j); + $out .= $this->addressbook_list_item($id, $source, $jsdata) . ''; + } } $this->rc->output->set_env('contactgroups', array_filter($jsdata, function($src){ return $src['type'] == 'group'; })); @@ -302,7 +320,7 @@ ), $name) ); - if (isset($source['subscribed'])) { + if ($this->driver == 'kolab' && isset($source['subscribed'])) { $inner .= html::span(array( 'class' => 'subscribed', 'title' => $this->gettext('foldersubscribe'), @@ -395,7 +413,6 @@ return $args; } - /** * Getter for the rcube_addressbook instance * @@ -406,16 +423,8 @@ public function get_address_book($p) { if ($p['id']) { - $id = kolab_storage::id_decode($p['id']); - $folder = kolab_storage::get_folder($id); - - // try with unencoded (old-style) identifier - if ((!$folder || $folder->type != 'contact') && $id != $p['id']) { - $folder = kolab_storage::get_folder($p['id']); - } - - if ($folder && $folder->type == 'contact') { - $p['instance'] = new rcube_kolab_contacts($folder->name); + if ($source = $this->driver_class::get_address_book($p['id'])) { + $p['instance'] = $source; // flag source as writeable if 'i' right is given if ($p['writeable'] && $this->rc->action == 'save' && strpos($p['instance']->rights, 'i') !== false) { @@ -430,57 +439,33 @@ return $p; } - + /** + * List addressbook sources list + */ private function _list_sources() { // already read sources - if (isset($this->sources)) + if (isset($this->sources)) { return $this->sources; + } - kolab_storage::$encode_ids = true; - $this->sources = array(); - $this->folders = array(); + $this->sources = []; $abook_prio = $this->addressbook_prio(); // Personal address source(s) disabled? - if ($abook_prio == self::GLOBAL_ONLY) { + if ($abook_prio == kolab_addressbook::GLOBAL_ONLY) { return $this->sources; } // get all folders that have "contact" type - $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact')); - - if (PEAR::isError($folders)) { - rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage()), - true, false); - } - else { - // we need at least one folder to prevent from errors in Roundcube core - // when there's also no sql nor ldap addressbook (Bug #2086) - if (empty($folders)) { - if ($folder = kolab_storage::create_default_folder('contact')) { - $folders = array(new kolab_storage_folder($folder, 'contact')); - } - } - - // convert to UTF8 and sort - foreach ($folders as $folder) { - // create instance of rcube_contacts - $abook_id = $folder->id; - $abook = new rcube_kolab_contacts($folder->name); - $this->sources[$abook_id] = $abook; - $this->folders[$abook_id] = $folder; - } + foreach ($this->driver_class::list_folders() as $id => $source) { + $this->sources[$id] = $source; } return $this->sources; } - /** * Plugin hook called before rendering the contact form or detail view * @@ -800,17 +785,17 @@ */ private function _sort_form_fields($contents, $source) { - $block = array(); + $block = []; - foreach (array_keys($source->coltypes) as $col) { - if (isset($contents[$col])) - $block[$col] = $contents[$col]; - } + foreach (array_keys($source->coltypes) as $col) { + if (isset($contents[$col])) { + $block[$col] = $contents[$col]; + } + } - return $block; + return $block; } - /** * Handler for user preferences form (preferences_list hook) * @@ -958,9 +943,9 @@ */ public function book_search() { - $results = array(); - $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); - $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); + $results = []; + $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); + $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); kolab_storage::$encode_ids = true; $search_more_results = false; @@ -1108,12 +1093,6 @@ */ private function addressbook_prio() { - // Load configuration - if (!$this->config_loaded) { - $this->load_config(); - $this->config_loaded = true; - } - $abook_prio = (int) $this->rc->config->get('kolab_addressbook_prio'); // Make sure any global addressbooks are defined diff --git a/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php b/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php --- a/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php +++ b/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php @@ -54,6 +54,10 @@ // Include stylesheet (for directorylist) $this->plugin->include_stylesheet($this->plugin->local_skin_path().'/kolab_addressbook.css'); + if ($this->plugin->driver != 'kolab') { + return; + } + // include kolab folderlist widget if available if (in_array('libkolab', $this->plugin->api->loaded_plugins())) { $this->plugin->api->include_script('libkolab/libkolab.js'); @@ -66,20 +70,20 @@ $idx = 0; if ($dav_url = $this->rc->config->get('kolab_addressbook_carddav_url')) { - $options[] = 'book-showurl'; - $this->rc->output->set_env('kolab_addressbook_carddav_url', true); - - // set CardDAV URI for specified ldap addressbook - if ($ldap_abook = $this->rc->config->get('kolab_addressbook_carddav_ldap')) { - $dav_ldap_url = strtr($dav_url, array( - '%h' => $_SERVER['HTTP_HOST'], - '%u' => urlencode($this->rc->get_user_name()), - '%i' => 'ldap-directory', - '%n' => '', - )); - $this->rc->output->set_env('kolab_addressbook_carddav_ldap', $ldap_abook); - $this->rc->output->set_env('kolab_addressbook_carddav_ldap_url', $dav_ldap_url); - } + $options[] = 'book-showurl'; + $this->rc->output->set_env('kolab_addressbook_carddav_url', true); + + // set CardDAV URI for specified ldap addressbook + if ($ldap_abook = $this->rc->config->get('kolab_addressbook_carddav_ldap')) { + $dav_ldap_url = strtr($dav_url, array( + '%h' => $_SERVER['HTTP_HOST'], + '%u' => urlencode($this->rc->get_user_name()), + '%i' => 'ldap-directory', + '%n' => '', + )); + $this->rc->output->set_env('kolab_addressbook_carddav_ldap', $ldap_abook); + $this->rc->output->set_env('kolab_addressbook_carddav_ldap_url', $dav_ldap_url); + } } foreach ($options as $command) { diff --git a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php b/plugins/kolab_addressbook/lib/rcube_carddav_contacts.php copy from plugins/kolab_addressbook/lib/rcube_kolab_contacts.php copy to plugins/kolab_addressbook/lib/rcube_carddav_contacts.php --- a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php +++ b/plugins/kolab_addressbook/lib/rcube_carddav_contacts.php @@ -1,15 +1,15 @@ - * @author Aleksander Machniak + * @author Aleksander Machniak * - * Copyright (C) 2011, Kolab Systems AG + * Copyright (C) 2011-2022, 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 @@ -26,95 +26,59 @@ * * @see rcube_addressbook */ -class rcube_kolab_contacts extends rcube_addressbook +class rcube_carddav_contacts extends rcube_addressbook { public $primary_key = 'ID'; public $rights = 'lrs'; public $readonly = true; - public $undelete = true; - public $groups = true; - public $coltypes = array( - 'name' => array('limit' => 1), - 'firstname' => array('limit' => 1), - 'surname' => array('limit' => 1), - 'middlename' => array('limit' => 1), - 'prefix' => array('limit' => 1), - 'suffix' => array('limit' => 1), - 'nickname' => array('limit' => 1), - 'jobtitle' => array('limit' => 1), - 'organization' => array('limit' => 1), - 'department' => array('limit' => 1), - 'email' => array('subtypes' => array('home','work','other')), - 'phone' => array(), - 'address' => array('subtypes' => array('home','work','office')), - 'website' => array('subtypes' => array('homepage','blog')), - 'im' => array('subtypes' => null), - 'gender' => array('limit' => 1), - 'birthday' => array('limit' => 1), - 'anniversary' => array('limit' => 1), - 'profession' => array( - 'type' => 'text', - 'size' => 40, - 'maxlength' => 80, - 'limit' => 1, - 'label' => 'kolab_addressbook.profession', - 'category' => 'personal' - ), - 'manager' => array('limit' => null), - 'assistant' => array('limit' => null), - 'spouse' => array('limit' => 1), - 'children' => array( - 'type' => 'text', - 'size' => 40, - 'maxlength' => 80, - 'limit' => null, - 'label' => 'kolab_addressbook.children', - 'category' => 'personal' - ), - 'freebusyurl' => array( - 'type' => 'text', - 'size' => 40, - 'limit' => 1, - 'label' => 'kolab_addressbook.freebusyurl' - ), - 'pgppublickey' => array( - 'type' => 'textarea', - 'size' => 70, - 'rows' => 10, - 'limit' => 1, - 'label' => 'kolab_addressbook.pgppublickey' - ), - 'pkcs7publickey' => array( - 'type' => 'textarea', - 'size' => 70, - 'rows' => 10, - 'limit' => 1, - 'label' => 'kolab_addressbook.pkcs7publickey' - ), - 'notes' => array('limit' => 1), - 'photo' => array('limit' => 1), - // TODO: define more Kolab-specific fields such as: language, latitude, longitude, crypto settings - ); - - /** - * vCard additional fields mapping - */ - public $vcard_map = array( - 'profession' => 'X-PROFESSION', - 'officelocation' => 'X-OFFICE-LOCATION', - 'initials' => 'X-INITIALS', - 'children' => 'X-CHILDREN', - 'freebusyurl' => 'X-FREEBUSY-URL', - 'pgppublickey' => 'KEY', - ); + public $undelete = false; + public $groups = false; // TODO + + public $coltypes = [ + 'name' => ['limit' => 1], + 'firstname' => ['limit' => 1], + 'surname' => ['limit' => 1], + 'middlename' => ['limit' => 1], + 'prefix' => ['limit' => 1], + 'suffix' => ['limit' => 1], + 'nickname' => ['limit' => 1], + 'jobtitle' => ['limit' => 1], + 'organization' => ['limit' => 1], + 'department' => ['limit' => 1], + 'email' => ['subtypes' => ['home','work','other']], + 'phone' => [], + 'address' => ['subtypes' => ['home','work','office']], + 'website' => ['subtypes' => ['homepage','blog']], + 'im' => ['subtypes' => null], + 'gender' => ['limit' => 1], + 'birthday' => ['limit' => 1], + 'anniversary' => ['limit' => 1], + 'manager' => ['limit' => null], + 'assistant' => ['limit' => null], + 'spouse' => ['limit' => 1], + 'notes' => ['limit' => 1], + 'photo' => ['limit' => 1], + ]; + + public $vcard_map = [ + // 'profession' => 'X-PROFESSION', + // 'officelocation' => 'X-OFFICE-LOCATION', + // 'initials' => 'X-INITIALS', + // 'children' => 'X-CHILDREN', + // 'freebusyurl' => 'X-FREEBUSY-URL', + // 'pgppublickey' => 'KEY', + 'uid' => 'UID', + ]; /** * List of date type fields */ - public $date_cols = array('birthday', 'anniversary'); + public $date_cols = ['birthday', 'anniversary']; + + public $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email']; private $gid; - private $storagefolder; + private $storage; private $dataset; private $sortindex; private $contacts; @@ -123,11 +87,10 @@ private $filter; private $result; private $namespace; - private $imap_folder = 'INBOX/Contacts'; private $action; // list of fields used for searching in "All fields" mode - private $search_fields = array( + private $search_fields = [ 'name', 'firstname', 'surname', @@ -141,48 +104,31 @@ 'email', 'phone', 'address', - 'profession', +// 'profession', 'manager', 'assistant', 'spouse', 'children', 'notes', - ); + ]; - public function __construct($imap_folder = null) + /** + * Object constructor + */ + public function __construct($dav_folder = null) { - if ($imap_folder) { - $this->imap_folder = $imap_folder; - } - - // extend coltypes configuration - $format = kolab_format::factory('contact'); - - $this->coltypes['phone']['subtypes'] = array_keys($format->phonetypes); - $this->coltypes['address']['subtypes'] = array_keys($format->addresstypes); - - $rcube = rcube::get_instance(); - - // set localized labels for proprietary cols - foreach ($this->coltypes as $col => $prop) { - if (is_string($prop['label'])) { - $this->coltypes[$col]['label'] = $rcube->gettext($prop['label']); - } - } - - // fetch objects from the given IMAP folder - $this->storagefolder = kolab_storage::get_folder($this->imap_folder); - $this->ready = $this->storagefolder && !PEAR::isError($this->storagefolder); + $this->storage = $dav_folder; + $this->ready = !empty($this->storage); // Set readonly and rights flags according to folder permissions if ($this->ready) { - if ($this->storagefolder->get_owner() == $_SESSION['username']) { + if ($this->storage->get_owner() == $_SESSION['username']) { $this->readonly = false; $this->rights = 'lrswikxtea'; } else { - $rights = $this->storagefolder->get_myrights(); + $rights = $this->storage->get_myrights(); if ($rights && !PEAR::isError($rights)) { $this->rights = $rights; if (strpos($rights, 'i') !== false && strpos($rights, 't') !== false) { @@ -202,7 +148,7 @@ */ public function get_name() { - return $this->storagefolder->get_name(); + return $this->storage->get_name(); } /** @@ -210,17 +156,17 @@ */ public function get_foldername() { - return $this->storagefolder->get_foldername(); + return $this->storage->get_foldername(); } /** - * Getter for the IMAP folder name + * Getter for the folder name * - * @return string Name of the IMAP folder + * @return string Name of the folder */ public function get_realname() { - return $this->imap_folder; + return $this->get_name(); } /** @@ -231,7 +177,7 @@ public function get_namespace() { if ($this->namespace === null && $this->ready) { - $this->namespace = $this->storagefolder->get_namespace(); + $this->namespace = $this->storage->get_namespace(); } return $this->namespace; @@ -244,7 +190,7 @@ */ public function get_parent() { - return $this->storagefolder->get_parent(); + return $this->storage->get_parent(); } /** @@ -254,7 +200,7 @@ */ public function is_subscribed() { - return kolab_storage::folder_is_subscribed($this->imap_folder); + return true; } /** @@ -262,16 +208,17 @@ */ public function get_carddav_url() { +/* $rcmail = rcmail::get_instance(); if ($template = $rcmail->config->get('kolab_addressbook_carddav_url', null)) { - return strtr($template, array( + return strtr($template, [ '%h' => $_SERVER['HTTP_HOST'], '%u' => urlencode($rcmail->get_user_name()), - '%i' => urlencode($this->storagefolder->get_uid()), + '%i' => urlencode($this->storage->get_uid()), '%n' => urlencode($this->imap_folder), - )); + ]); } - +*/ return false; } @@ -312,6 +259,50 @@ $this->filter = null; } + /** + * List addressbook sources (folders) + */ + public static function list_folders() + { + $storage = self::get_storage(); + $sources = []; + + // get all folders that have "contact" type + foreach ($storage->get_folders('contact') as $folder) { + $sources[$folder->id] = new rcube_carddav_contacts($folder); + } + + return $sources; + } + + /** + * Getter for the rcube_addressbook instance + * + * @param string $id Addressbook (folder) ID + * + * @return ?rcube_carddav_contacts + */ + public static function get_address_book($id) + { + $storage = self::get_storage(); + $folder = $storage->get_folder($id, 'contact'); + + if ($folder) { + return new rcube_carddav_contacts($folder); + } + } + + /** + * Initialize kolab_storage_dav instance + */ + protected static function get_storage() + { + $rcube = rcube::get_instance(); + $url = $rcube->config->get('kolab_addressbook_carddav_server', 'http://localhost'); + + return new kolab_storage_dav($url); + } + /** * List all active contact groups of this source * @@ -323,11 +314,11 @@ function list_groups($search = null, $mode = 0) { $this->_fetch_groups(); - $groups = array(); + $groups = []; foreach ((array)$this->distlists as $group) { if (!$search || strstr(mb_strtolower($group['name']), mb_strtolower($search))) { - $groups[$group['ID']] = array('ID' => $group['ID'], 'name' => $group['name']); + $groups[$group['ID']] = ['ID' => $group['ID'], 'name' => $group['name']]; } } @@ -357,10 +348,10 @@ if ($this->gid) { $this->_fetch_groups(); - $this->sortindex = array(); - $this->contacts = array(); - $local_sortindex = array(); - $uids = array(); + $this->sortindex = []; + $this->contacts = []; + $local_sortindex = []; + $uids = []; // get members with email specified foreach ((array)$this->distlists[$this->gid]['member'] as $member) { @@ -381,15 +372,15 @@ // get members by UID if (!empty($uids)) { - $this->_fetch_contacts($query = array(array('uid', '=', $uids)), $fetch_all ? false : count($uids), $fast_mode); + $this->_fetch_contacts($query = [['uid', '=', $uids]], $fetch_all ? false : count($uids), $fast_mode); $this->sortindex = array_merge($this->sortindex, $local_sortindex); } } else if (is_array($this->filter['ids'])) { $ids = $this->filter['ids']; if (count($ids)) { - $uids = array_map(array($this, 'id2uid'), $this->filter['ids']); - $this->_fetch_contacts($query = array(array('uid', '=', $uids)), count($ids), $fast_mode); + $uids = array_map([$this, 'id2uid'], $this->filter['ids']); + $this->_fetch_contacts($query = [['uid', '=', $uids]], count($ids), $fast_mode); } } else { @@ -419,7 +410,7 @@ $this->result->count = count($this->dataset) + $this->page_size * ($this->list_page - 1); } else { - $this->result->count = $this->storagefolder->count($query); + $this->result->count = $this->storage->count($query); } $start_row = $subset < 0 ? $this->page_size + $subset : 0; @@ -449,7 +440,7 @@ * * @return rcube_result_set List of contact records and 'count' value */ - public function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array()) + public function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = []) { // search by ID if ($fields == $this->primary_key) { @@ -469,10 +460,10 @@ } if (!is_array($fields)) { - $fields = array($fields); + $fields = [$fields]; } if (!is_array($required) && !empty($required)) { - $required = array($required); + $required = [$required]; } // advanced search @@ -494,16 +485,16 @@ // add magic selector to select contacts with birthday dates only if (in_array('birthday', $required)) { - $squery[] = array('tags', '=', 'x-has-birthday'); + $squery[] = ['tags', '=', 'x-has-birthday']; } - $squery[] = array('type', '=', 'contact'); + $squery[] = ['type', '=', 'contact']; // get all/matching records $this->_fetch_contacts($squery); // save searching conditions - $this->filter = array('fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => array()); + $this->filter = ['fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => []]; // search by iterating over all records in dataset foreach ($this->dataset as $record) { @@ -520,8 +511,9 @@ } } - $found = array(); + $found = []; $contents = ''; + foreach (preg_grep($regexp, array_keys($contact)) as $col) { $pos = strpos($col, ':'); $colname = $pos ? substr($col, 0, $pos) : $col; @@ -579,7 +571,7 @@ $count = count($this->filter['ids']); } else { - $count = $this->storagefolder->count('contact'); + $count = $this->storage->count('contact'); } return new rcube_result_set($count, ($this->list_page-1) * $this->page_size); @@ -614,6 +606,7 @@ $rec = $this->contacts[$id]; $this->readonly = true; // set source to read-only } +/* else if (!empty($rev)) { $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->get_plugin('kolab_addressbook'); @@ -623,7 +616,8 @@ } $this->readonly = true; // set source to read-only } - else if ($object = $this->storagefolder->get_object($uid)) { +*/ + else if ($object = $this->storage->get_object($uid)) { $rec = $this->_to_rcube_contact($object); } @@ -645,7 +639,7 @@ */ public function get_record_groups($id) { - $out = array(); + $out = []; $this->_fetch_groups(); if (!empty($this->groupmembers[$id])) { @@ -689,22 +683,22 @@ } if (!$existing) { - // remove existing id attributes (#1101) + // Unset contact ID (e.g. when copying/moving from another addressbook) unset($save_data['ID'], $save_data['uid']); // generate new Kolab contact item $object = $this->_from_rcube_contact($save_data); - $saved = $this->storagefolder->save($object, 'contact'); + $saved = $this->storage->save($object, 'contact'); if (!$saved) { - rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving contact object to Kolab server"), - true, false); + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving contact object to CardDAV server" + ], + true, false); } else { - $insert_id = $this->uid2id($object['uid']); + $insert_id = $object['uid']; } } @@ -724,14 +718,14 @@ public function update($id, $save_data) { $updated = false; - if ($old = $this->storagefolder->get_object($this->id2uid($id))) { + if ($old = $this->storage->get_object($this->id2uid($id))) { $object = $this->_from_rcube_contact($save_data, $old); - if (!$this->storagefolder->save($object, 'contact', $old['uid'])) { - rcube::raise_error(array( + if (!$this->storage->save($object, 'contact', $old['uid'])) { + rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving contact object to Kolab server" - ), + 'message' => "Error saving contact object to CardDAV server" + ], true, false ); } @@ -753,7 +747,7 @@ * * @return int Number of records deleted */ - public function delete($ids, $force=true) + public function delete($ids, $force = true) { $this->_fetch_groups(); @@ -765,13 +759,13 @@ foreach ($ids as $id) { if ($uid = $this->id2uid($id)) { $is_mailto = strpos($uid, 'mailto:') === 0; - $deleted = $is_mailto || $this->storagefolder->delete($uid, $force); + $deleted = $is_mailto || $this->storage->delete($uid, $force); if (!$deleted) { - rcube::raise_error(array( + rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error deleting a contact object $uid from the Kolab server" - ), + 'message' => "Error deleting a contact object $uid from the CardDAV server" + ], true, false ); } @@ -810,14 +804,14 @@ $count = 0; foreach ($ids as $id) { $uid = $this->id2uid($id); - if ($this->storagefolder->undelete($uid)) { + if ($this->storage->undelete($uid)) { $count++; } else { - rcube::raise_error(array( + rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error undeleting a contact object $uid from the Kolab server" - ), + 'message' => "Error undeleting a contact object $uid from the CardDav server" + ], true, false ); } @@ -833,9 +827,9 @@ */ public function delete_all($with_groups = false) { - if ($this->storagefolder->delete_all()) { - $this->contacts = array(); - $this->sortindex = array(); + if ($this->storage->delete_all()) { + $this->contacts = []; + $this->sortindex = []; $this->dataset = null; $this->result = null; } @@ -847,6 +841,7 @@ */ public function close() { + // NOP } /** @@ -861,17 +856,17 @@ $this->_fetch_groups(); $result = false; - $list = array( + $list = [ 'name' => $name, - 'member' => array(), - ); - $saved = $this->storagefolder->save($list, 'distribution-list'); + 'member' => [], + ]; + $saved = $this->storage->save($list, 'distribution-list'); if (!$saved) { - rcube::raise_error(array( + rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list object to Kolab server" - ), + 'message' => "Error saving distribution-list object to CardDAV server" + ], true, false ); return false; @@ -879,7 +874,7 @@ else { $id = $this->uid2id($list['uid']); $this->distlists[$id] = $list; - $result = array('id' => $id, 'name' => $name); + $result = ['id' => $id, 'name' => $name]; } return $result; @@ -898,14 +893,14 @@ $result = false; if ($list = $this->distlists[$gid]) { - $deleted = $this->storagefolder->delete($list['uid']); + $deleted = $this->storage->delete($list['uid']); } if (!$deleted) { - rcube::raise_error(array( + rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error deleting distribution-list object from the Kolab server" - ), + 'message' => "Error deleting distribution-list object from the CardDAV server" + ], true, false ); } @@ -932,14 +927,14 @@ if ($newname != $list['name']) { $list['name'] = $newname; - $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); + $saved = $this->storage->save($list, 'distribution-list', $list['uid']); } if (!$saved) { - rcube::raise_error(array( + rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list object to Kolab server" - ), + 'message' => "Error saving distribution-list object to CardDAV server" + ], true, false ); return false; @@ -965,8 +960,8 @@ $list = $this->distlists[$gid]; $added = 0; - $uids = array(); - $exists = array(); + $uids = []; + $exists = []; foreach ((array)$list['member'] as $member) { $exists[] = $member['ID']; @@ -979,10 +974,10 @@ foreach ($ids as $contact_id) { $uid = $this->id2uid($contact_id); if (strpos($uid, 'mailto:') === 0 && ($contact = $this->contacts[$contact_id])) { - $list['member'][] = array( + $list['member'][] = [ 'email' => $contact['email'], 'name' => $contact['name'], - ); + ]; $this->groupmembers[$contact_id][] = $gid; $added++; } @@ -994,24 +989,24 @@ // add members with UID if (!empty($uids)) { foreach ($uids as $uid => $contact_id) { - $list['member'][] = array('uid' => $uid); + $list['member'][] = ['uid' => $uid]; $this->groupmembers[$contact_id][] = $gid; $added++; } } if ($added) { - $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); + $saved = $this->storage->save($list, 'distribution-list', $list['uid']); } else { $saved = true; } if (!$saved) { - rcube::raise_error(array( + rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list to Kolab server" - ), + 'message' => "Error saving distribution-list to CardDAV server" + ], true, false ); @@ -1030,7 +1025,8 @@ * * @param string Group identifier * @param array List of contact identifiers to be removed - * @return int Number of deleted group members + * + * @return bool */ function remove_from_group($gid, $ids) { @@ -1043,8 +1039,8 @@ return false; } - $new_member = array(); - foreach ((array)$list['member'] as $member) { + $new_member = []; + foreach ((array) $list['member'] as $member) { if (!in_array($member['ID'], $ids)) { $new_member[] = $member; } @@ -1052,13 +1048,13 @@ // write distribution list back to server $list['member'] = $new_member; - $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); + $saved = $this->storage->save($list, 'distribution-list', $list['uid']); if (!$saved) { - rcube::raise_error(array( + rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list object to Kolab server" - ), + 'message' => "Error saving distribution-list object to CardDAV server" + ], true, false ); } @@ -1107,16 +1103,16 @@ /** * Query storage layer and store records in private member var */ - private function _fetch_contacts($query = array(), $limit = false, $fast_mode = false) + private function _fetch_contacts($query = [], $limit = false, $fast_mode = false) { if (!isset($this->dataset) || !empty($query)) { if ($limit) { $size = is_int($limit) && $limit < $this->page_size ? $limit : $this->page_size; - $this->storagefolder->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page-1) * $this->page_size); + $this->storage->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page-1) * $this->page_size); } - $this->sortindex = array(); - $this->dataset = $this->storagefolder->select($query, $fast_mode); + $this->sortindex = []; + $this->dataset = $this->storage->select($query, $fast_mode); foreach ($this->dataset as $idx => $record) { $contact = $this->_to_rcube_contact($record); @@ -1149,6 +1145,7 @@ } $str .= is_array($rec['email']) ? $rec['email'][0] : $rec['email']; + return mb_strtolower($str); } @@ -1157,11 +1154,12 @@ */ private function _sort_columns() { - $sortcols = array(); + $sortcols = []; switch ($this->sort_col) { case 'name': $sortcols[] = 'name'; + case 'firstname': $sortcols[] = 'firstname'; break; @@ -1180,9 +1178,11 @@ */ private function _fetch_groups($with_contacts = false) { + return; // TODO + if (!isset($this->distlists)) { - $this->distlists = $this->groupmembers = array(); - foreach ($this->storagefolder->select('distribution-list', true) as $record) { + $this->distlists = $this->groupmembers = []; + foreach ($this->storage->select('distribution-list', true) as $record) { $record['ID'] = $this->uid2id($record['uid']); foreach ((array)$record['member'] as $i => $member) { $mid = $this->uid2id($member['uid'] ? $member['uid'] : 'mailto:' . $member['email']); @@ -1220,20 +1220,12 @@ */ private function _search_query($fields, $value, $mode) { - $query = array(); - $cols = array(); + $query = []; + $cols = []; - // $fulltext_cols might contain composite field names e.g. 'email:address' while $fields not - foreach (kolab_format_contact::$fulltext_cols as $col) { - if ($pos = strpos($col, ':')) { - $col = substr($col, 0, $pos); - } - if (in_array($col, $fields)) { - $cols[] = $col; - } - } + $cols = array_intersect($fields, $this->fulltext_cols); - if (count($cols) == count($fields)) { + if (count($cols)) { if ($mode & rcube_addressbook::SEARCH_STRICT) { $prefix = '^'; $suffix = '$'; } @@ -1246,7 +1238,7 @@ $search_string = is_array($value) ? join(' ', $value) : $value; foreach (rcube_utils::normalize_string($search_string, true) as $word) { - $query[] = array('words', 'LIKE', $prefix . $word . $suffix); + $query[] = ['words', 'LIKE', $prefix . $word . $suffix]; } } @@ -1260,57 +1252,11 @@ { $record['ID'] = $this->uid2id($record['uid']); - // convert email, website, phone values - foreach (array('email'=>'address', 'website'=>'url', 'phone'=>'number') as $col => $propname) { - if (is_array($record[$col])) { - $values = $record[$col]; - unset($record[$col]); - foreach ((array)$values as $i => $val) { - $key = $col . ($val['type'] ? ':' . $val['type'] : ''); - $record[$key][] = $val[$propname]; - } - } - } - - if (is_array($record['address'])) { - $addresses = $record['address']; - unset($record['address']); - foreach ($addresses as $i => $adr) { - $key = 'address' . ($adr['type'] ? ':' . $adr['type'] : ''); - $record[$key][] = array( - 'street' => $adr['street'], - 'locality' => $adr['locality'], - 'zipcode' => $adr['code'], - 'region' => $adr['region'], - 'country' => $adr['country'], - ); - } - } - - // photo is stored as separate attachment - if ($record['photo'] && strlen($record['photo']) < 255 && !empty($record['_attachments'][$record['photo']])) { - $att = $record['_attachments'][$record['photo']]; - // only fetch photo content if requested - if ($this->action == 'photo') { - if (!empty($att['content'])) { - $record['photo'] = $att['content']; - } - else { - $record['photo'] = $this->storagefolder->get_attachment($record['uid'], $att['id']); - } - } - } - - // truncate publickey value for display - if (!empty($record['pgppublickey']) && $this->action == 'show') { - $record['pgppublickey'] = substr($record['pgppublickey'], 0, 140) . '...'; - } - // remove empty fields $record = array_filter($record); - // remove kolab_storage internal data - unset($record['_msguid'], $record['_formatobj'], $record['_mailbox'], $record['_type'], $record['_size']); + // Set _type for proper icon on the list + $record['_type'] = 'person'; return $record; } @@ -1318,83 +1264,22 @@ /** * Map fields from Roundcube format to internal kolab_format_contact properties */ - private function _from_rcube_contact($contact, $old = array()) + private function _from_rcube_contact($contact, $old = []) { - if (!$contact['uid'] && $contact['ID']) { + if (empty($contact['uid']) && !empty($contact['ID'])) { $contact['uid'] = $this->id2uid($contact['ID']); } - else if (!$contact['uid'] && $old['uid']) { + else if (empty($contact['uid']) && !empty($old['uid'])) { $contact['uid'] = $old['uid']; } - - $contact['im'] = array_filter($this->get_col_values('im', $contact, true)); - - // convert email, website, phone values - foreach (array('email'=>'address', 'website'=>'url', 'phone'=>'number') as $col => $propname) { - $col_values = $this->get_col_values($col, $contact); - $contact[$col] = array(); - foreach ($col_values as $type => $values) { - foreach ((array)$values as $val) { - if (!empty($val)) { - $contact[$col][] = array($propname => $val, 'type' => $type); - } - } - unset($contact[$col.':'.$type]); - } - } - - $addresses = array(); - foreach ($this->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]); + else if (empty($contact['uid'])) { + $rcube = rcube::get_instance(); + $contact['uid'] = strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($rcube->user->get_username()), 0, 16)); } - $contact['address'] = $addresses; - - // categories are not supported in the web client but should be preserved (#2608) - $contact['categories'] = $old['categories']; - - // copy meta data (starting with _) from old object - foreach ((array)$old as $key => $val) { - if (!isset($contact[$key]) && $key[0] == '_') { - $contact[$key] = $val; - } - } - - // convert one-item-array elements into string element - // this is needed e.g. to properly import birthday field - foreach ($this->coltypes as $type => $col_def) { - if ($col_def['limit'] == 1 && is_array($contact[$type])) { - $contact[$type] = array_shift(array_filter($contact[$type])); - } - } - - // When importing contacts 'vcard' data is added, we don't need it (Bug #1711) + // When importing contacts 'vcard' data might be added, we don't need it (Bug #1711) unset($contact['vcard']); - // add empty values for some fields which can be removed in the UI - return array_filter($contact) + array( - 'nickname' => '', - 'birthday' => '', - 'anniversary' => '', - 'freebusyurl' => '', - 'photo' => $contact['photo'] - ); + return $contact; } } diff --git a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php b/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php --- a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php +++ b/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php @@ -312,6 +312,64 @@ $this->filter = null; } + /** + * List addressbook sources (folders) + */ + public static function list_folders() + { + kolab_storage::$encode_ids = true; + + // get all folders that have "contact" type + $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact')); + + if (PEAR::isError($folders)) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage() + ], + true, false); + + return []; + } + + // we need at least one folder to prevent from errors in Roundcube core + // when there's also no sql nor ldap addressbook (Bug #2086) + if (empty($folders)) { + if ($folder = kolab_storage::create_default_folder('contact')) { + $folders = [new kolab_storage_folder($folder, 'contact')]; + } + } + + $sources = []; + foreach ($folders as $folder) { + $sources[$folder->id] = new rcube_kolab_contacts($folder->name); + } + + return $sources; + } + + /** + * Getter for the rcube_addressbook instance + * + * @param string $id Addressbook (folder) ID + * + * @return ?rcube_kolab_contacts + */ + public static function get_address_book($id) + { + $folderId = kolab_storage::id_decode($id); + $folder = kolab_storage::get_folder($folderId); + + // try with unencoded (old-style) identifier + if ((!$folder || $folder->type != 'contact') && $folderId != $id) { + $folder = kolab_storage::get_folder($id); + } + + if ($folder && $folder->type == 'contact') { + return new rcube_kolab_contacts($folder->name); + } + } + /** * List all active contact groups of this source * diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -1353,6 +1353,9 @@ if (!empty($ex['thisandfuture'])) { $recurrence_id->add('RANGE', 'THISANDFUTURE'); } + + $ex['uid'] = $ve->UID; + $this->_to_ical($ex, $vcal, $get_attachment, $recurrence_id); } } diff --git a/plugins/libkolab/SQL/mysql.initial.sql b/plugins/libkolab/SQL/mysql.initial.sql --- a/plugins/libkolab/SQL/mysql.initial.sql +++ b/plugins/libkolab/SQL/mysql.initial.sql @@ -1,10 +1,3 @@ -/** - * libkolab database schema - * - * @author Thomas Bruederli - * @licence GNU AGPL - */ - SET FOREIGN_KEY_CHECKS=0; DROP TABLE IF EXISTS `kolab_folders`; @@ -177,6 +170,46 @@ INDEX `freebusy_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +DROP TABLE IF EXISTS `kolab_cache_dav_contact`; + +CREATE TABLE `kolab_cache_dav_contact` ( + `folder_id` BIGINT UNSIGNED NOT NULL, + `uid` VARCHAR(512) NOT NULL, + `etag` VARCHAR(128) DEFAULT NULL, + `created` DATETIME DEFAULT NULL, + `changed` DATETIME DEFAULT NULL, + `data` LONGTEXT NOT NULL, + `tags` TEXT NOT NULL, + `words` TEXT NOT NULL, + `type` VARCHAR(32) CHARACTER SET ascii NOT NULL, + `name` VARCHAR(255) NOT NULL, + `firstname` VARCHAR(255) NOT NULL, + `surname` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NOT NULL, + CONSTRAINT `fk_kolab_cache_dav_contact_folder` FOREIGN KEY (`folder_id`) + REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY(`folder_id`,`uid`), + INDEX `contact_type` (`folder_id`,`type`) +) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `kolab_cache_dav_event`; + +CREATE TABLE `kolab_cache_dav_event` ( + `folder_id` BIGINT UNSIGNED NOT NULL, + `uid` VARCHAR(512) NOT NULL, + `etag` VARCHAR(128) DEFAULT NULL, + `created` DATETIME DEFAULT NULL, + `changed` DATETIME DEFAULT NULL, + `data` LONGTEXT NOT NULL, + `tags` TEXT NOT NULL, + `words` TEXT NOT NULL, + `dtstart` DATETIME, + `dtend` DATETIME, + CONSTRAINT `fk_kolab_cache_dav_event_folder` FOREIGN KEY (`folder_id`) + REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY(`folder_id`,`uid`) +) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + SET FOREIGN_KEY_CHECKS=1; -REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2021101100'); +REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2022100500'); diff --git a/plugins/libkolab/SQL/mysql/2022100500.sql b/plugins/libkolab/SQL/mysql/2022100500.sql new file mode 100644 --- /dev/null +++ b/plugins/libkolab/SQL/mysql/2022100500.sql @@ -0,0 +1,39 @@ +DROP TABLE IF EXISTS `kolab_cache_dav_contact`; + +CREATE TABLE `kolab_cache_dav_contact` ( + `folder_id` BIGINT UNSIGNED NOT NULL, + `uid` VARCHAR(512) NOT NULL, + `etag` VARCHAR(128) DEFAULT NULL, + `created` DATETIME DEFAULT NULL, + `changed` DATETIME DEFAULT NULL, + `data` LONGTEXT NOT NULL, + `tags` TEXT NOT NULL, + `words` TEXT NOT NULL, + `type` VARCHAR(32) CHARACTER SET ascii NOT NULL, + `name` VARCHAR(255) NOT NULL, + `firstname` VARCHAR(255) NOT NULL, + `surname` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NOT NULL, + CONSTRAINT `fk_kolab_cache_dav_contact_folder` FOREIGN KEY (`folder_id`) + REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY(`folder_id`,`uid`), + INDEX `contact_type` (`folder_id`,`type`) +) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +DROP TABLE IF EXISTS `kolab_cache_dav_event`; + +CREATE TABLE `kolab_cache_dav_event` ( + `folder_id` BIGINT UNSIGNED NOT NULL, + `uid` VARCHAR(512) NOT NULL, + `etag` VARCHAR(128) DEFAULT NULL, + `created` DATETIME DEFAULT NULL, + `changed` DATETIME DEFAULT NULL, + `data` LONGTEXT NOT NULL, + `tags` TEXT NOT NULL, + `words` TEXT NOT NULL, + `dtstart` DATETIME, + `dtend` DATETIME, + CONSTRAINT `fk_kolab_cache_dav_event_folder` FOREIGN KEY (`folder_id`) + REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY(`folder_id`,`uid`) +) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/plugins/libkolab/SQL/oracle.initial.sql b/plugins/libkolab/SQL/oracle.initial.sql --- a/plugins/libkolab/SQL/oracle.initial.sql +++ b/plugins/libkolab/SQL/oracle.initial.sql @@ -1,10 +1,3 @@ -/** - * libkolab database schema - * - * @author Aleksander Machniak - * @licence GNU AGPL - */ - CREATE TABLE "kolab_folders" ( "folder_id" number NOT NULL PRIMARY KEY, "resource" VARCHAR(255) NOT NULL, diff --git a/plugins/libkolab/SQL/sqlite.initial.sql b/plugins/libkolab/SQL/sqlite.initial.sql --- a/plugins/libkolab/SQL/sqlite.initial.sql +++ b/plugins/libkolab/SQL/sqlite.initial.sql @@ -1,10 +1,3 @@ -/** - * libkolab database schema - * - * @author Thomas Bruederli - * @licence GNU AGPL - */ - CREATE TABLE kolab_folders ( folder_id INTEGER NOT NULL PRIMARY KEY, resource VARCHAR(255) NOT NULL, diff --git a/plugins/libkolab/lib/kolab_dav_client.php b/plugins/libkolab/lib/kolab_dav_client.php new file mode 100644 --- /dev/null +++ b/plugins/libkolab/lib/kolab_dav_client.php @@ -0,0 +1,571 @@ + + * + * Copyright (C) 2022, Apheleia IT 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 . + */ + +class kolab_dav_client +{ + public $url; + + protected $user; + protected $password; + protected $rc; + protected $responseHeaders = []; + + /** + * Object constructor + */ + public function __construct($url) + { + $this->rc = rcube::get_instance(); + + $parsedUrl = parse_url($url); + + if (!empty($parsedUrl['user']) && !empty($parsedUrl['pass'])) { + $this->user = rawurldecode($parsedUrl['user']); + $this->password = rawurldecode($parsedUrl['pass']); + + $url = str_replace(rawurlencode($this->user) . ':' . rawurlencode($this->password) . '@', '', $url); + } + else { + $this->user = $this->rc->user->get_username(); + $this->password = $this->rc->decrypt($_SESSION['password']); + } + + $this->url = $url; + } + + /** + * Execute HTTP request to a DAV server + */ + protected function request($path, $method, $body = '', $headers = []) + { + $rcube = rcube::get_instance(); + $debug = (array) $rcube->config->get('dav_debug'); + + $request_config = [ + 'store_body' => true, + 'follow_redirects' => true, + ]; + + $this->responseHeaders = []; + + if ($path && ($rootPath = parse_url($this->url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) { + $path = substr($path, strlen($rootPath)); + } + + try { + $request = $this->initRequest($this->url . $path, $method, $request_config); + + $request->setAuth($this->user, $this->password); + + if ($body) { + $request->setBody($body); + $request->setHeader(['Content-Type' => 'application/xml; charset=utf-8']); + } + + if (!empty($headers)) { + $request->setHeader($headers); + } + + if ($debug) { + rcube::write_log('dav', "C: {$method}: " . (string) $request->getUrl() + . "\n" . $this->debugBody($body, $request->getHeaders())); + } + + $response = $request->send(); + + $body = $response->getBody(); + $code = $response->getStatus(); + + if ($debug) { + rcube::write_log('dav', "S: [{$code}]\n" . $this->debugBody($body, $response->getHeader())); + } + + if ($code >= 300) { + throw new Exception("DAV Error ($code):\n{$body}"); + } + + $this->responseHeaders = $response->getHeader(); + + return $this->parseXML($body); + } + catch (Exception $e) { + rcube::raise_error($e, true, false); + return false; + } + } + + /** + * Discover DAV folders of specified type on the server + */ + public function discover($component = 'VEVENT') + { + $roots = [ + 'VEVENT' => 'calendars', + 'VTODO' => 'calendars', + 'VCARD' => 'addressbooks', + ]; + + $path = parse_url($this->url, PHP_URL_PATH); + + $body = '' + . '' + . '' + . '' + . '' + . ''; + + // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) + $response = $this->request('/' . $roots[$component], 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); + + $elements = $response->getElementsByTagName('response'); + + foreach ($elements as $element) { + foreach ($element->getElementsByTagName('prop') as $prop) { + $principal_href = $prop->nodeValue; + break; + } + } + + if ($path && strpos($principal_href, $path) === 0) { + $principal_href = substr($principal_href, strlen($path)); + } + + $homes = [ + 'VEVENT' => 'calendar-home-set', + 'VTODO' => 'calendar-home-set', + 'VCARD' => 'addressbook-home-set', + ]; + + $ns = [ + 'VEVENT' => 'caldav', + 'VTODO' => 'caldav', + 'VCARD' => 'carddav', + ]; + + $body = '' + . '' + . '' + . '' + . '' + . ''; + + $response = $this->request($principal_href, 'PROPFIND', $body); + + $elements = $response->getElementsByTagName('response'); + + foreach ($elements as $element) { + foreach ($element->getElementsByTagName('prop') as $prop) { + $root_href = $prop->nodeValue; + break; + } + } + + if (!empty($root_href)) { + if ($path && strpos($root_href, $path) === 0) { + $root_href = substr($root_href, strlen($path)); + } + } + else { + // Kolab iRony's calendar root + $root_href = '/' . $roots[$component] . '/' . rawurlencode($this->user); + } + + if ($component == 'VCARD') { + $add_ns = ''; + $add_props = ''; + } + else { + $add_ns = ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"'; + $add_props = ''; + } + + $body = '' + . '' + . '' + . '' + . '' + // . '' + . '' + . $add_props + . '' + . ''; + + // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) + $response = $this->request($root_href, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); + + if (empty($response)) { + return false; + } + + $folders = []; + foreach ($response->getElementsByTagName('response') as $element) { + $folder = $this->getFolderPropertiesFromResponse($element); + + // Note: Addressbooks don't have 'type' specified + if (($component == 'VCARD' && in_array('addressbook', $folder['resource_type'])) + || $folder['type'] === $component + ) { + $folders[] = $folder; + } + } + + return $folders; + } + + /** + * Create a DAV object in a folder + */ + public function create($location, $content, $component = 'VEVENT') + { + $ctype = [ + 'VEVENT' => 'text/calendar', + 'VTODO' => 'text/calendar', + 'VCARD' => 'text/vcard', + ]; + + $headers = ['Content-Type' => $ctype[$component] . '; charset=utf-8']; + + $response = $this->request($location, 'PUT', $content, $headers); + + if ($response !== false) { + $etag = $this->responseHeaders['etag']; + + if (preg_match('|^".*"$|', $etag)) { + $etag = substr($etag, 1, -1); + } + + return $etag; + } + + return false; + } + + /** + * Update a DAV object in a folder + */ + public function update($location, $content, $component = 'VEVENT') + { + return $this->create($location, $content, $component); + } + + /** + * Delete a DAV object from a folder + */ + public function delete($location) + { + $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']); + + return $response !== false; + } + + /** + * Fetch DAV objects metadata (ETag, href) a folder + */ + public function getIndex($location, $component = 'VEVENT') + { + $queries = [ + 'VEVENT' => 'calendar-query', + 'VTODO' => 'calendar-query', + 'VCARD' => 'addressbook-query', + ]; + + $ns = [ + 'VEVENT' => 'caldav', + 'VTODO' => 'caldav', + 'VCARD' => 'carddav', + ]; + + $filter = ''; + if ($component != 'VCARD') { + $filter = '' + . '' + . ''; + } + + $body = '' + .' ' + . '' + . '' + . '' + . ($filter ? "$filter" : '') + . ''; + + $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); + + if (empty($response)) { + return false; + } + + $objects = []; + + foreach ($response->getElementsByTagName('response') as $element) { + $objects[] = $this->getObjectPropertiesFromResponse($element); + } + + return $objects; + } + + /** + * Fetch DAV objects data from a folder + */ + public function getData($location, $component = 'VEVENT', $hrefs = []) + { + if (empty($hrefs)) { + return []; + } + + $body = ''; + foreach ($hrefs as $href) { + $body .= '' . $href . ''; + } + + $queries = [ + 'VEVENT' => 'calendar-multiget', + 'VTODO' => 'calendar-multiget', + 'VCARD' => 'addressbook-multiget', + ]; + + $ns = [ + 'VEVENT' => 'caldav', + 'VTODO' => 'caldav', + 'VCARD' => 'carddav', + ]; + + $types = [ + 'VEVENT' => 'calendar-data', + 'VTODO' => 'calendar-data', + 'VCARD' => 'address-data', + ]; + + $body = '' + .' ' + . '' + . '' + . '' + . '' + . $body + . ''; + + $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); + + if (empty($response)) { + return false; + } + + $objects = []; + + foreach ($response->getElementsByTagName('response') as $element) { + $objects[] = $this->getObjectPropertiesFromResponse($element); + } + + return $objects; + } + + /** + * Parse XML content + */ + protected function parseXML($xml) + { + $doc = new DOMDocument('1.0', 'UTF-8'); + + if (stripos($xml, 'loadXML($xml)) { + throw new Exception("Failed to parse XML"); + } + + $doc->formatOutput = true; + } + + return $doc; + } + + /** + * Parse request/response body for debug purposes + */ + protected function debugBody($body, $headers) + { + $head = ''; + foreach ($headers as $header_name => $header_value) { + $head .= "{$header_name}: {$header_value}\n"; + } + + if (stripos($body, 'formatOutput = true; + $doc->preserveWhiteSpace = false; + + if (!$doc->loadXML($body)) { + throw new Exception("Failed to parse XML"); + } + + $body = $doc->saveXML(); + } + + return $head . "\n" . rtrim($body); + } + + /** + * Extract folder properties from a server 'response' element + */ + protected function getFolderPropertiesFromResponse(DOMNode $element) + { + + if ($href = $element->getElementsByTagName('href')->item(0)) { + $href = $href->nodeValue; +/* + $path = parse_url($this->url, PHP_URL_PATH); + if ($path && strpos($href, $path) === 0) { + $href = substr($href, strlen($path)); + } +*/ + } + + if ($color = $element->getElementsByTagName('calendar-color')->item(0)) { + if (preg_match('/^#[0-9A-F]{8}$/', $color->nodeValue)) { + $color = substr($color->nodeValue, 1, -2); + } else { + $color = null; + } + } + + if ($name = $element->getElementsByTagName('displayname')->item(0)) { + $name = $name->nodeValue; + } + + if ($ctag = $element->getElementsByTagName('getctag')->item(0)) { + $ctag = $ctag->nodeValue; + } + + $component = null; + if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) { + if ($comp_element = $set_element->getElementsByTagName('comp')->item(0)) { + $component = $comp_element->attributes->getNamedItem('name')->nodeValue; + } + } + + $types = []; + if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) { + foreach ($type_element->childNodes as $node) { + $_type = explode(':', $node->nodeName); + $types[] = count($_type) > 1 ? $_type[1] : $_type[0]; + } + } + + return [ + 'href' => $href, + 'name' => $name, + 'ctag' => $ctag, + 'color' => $color, + 'type' => $component, + 'resource_type' => $types, + ]; + } + + /** + * Extract object properties from a server 'response' element + */ + protected function getObjectPropertiesFromResponse(DOMNode $element) + { + $uid = null; + if ($href = $element->getElementsByTagName('href')->item(0)) { + $href = $href->nodeValue; +/* + $path = parse_url($this->url, PHP_URL_PATH); + if ($path && strpos($href, $path) === 0) { + $href = substr($href, strlen($path)); + } +*/ + // Extract UID from the URL + $href_parts = explode('/', $href); + $uid = preg_replace('/\.[a-z]+$/', '', $href_parts[count($href_parts)-1]); + } + + if ($data = $element->getElementsByTagName('calendar-data')->item(0)) { + $data = $data->nodeValue; + } + else if ($data = $element->getElementsByTagName('address-data')->item(0)) { + $data = $data->nodeValue; + } + + if ($etag = $element->getElementsByTagName('getetag')->item(0)) { + $etag = $etag->nodeValue; + if (preg_match('|^".*"$|', $etag)) { + $etag = substr($etag, 1, -1); + } + } + + return [ + 'href' => $href, + 'data' => $data, + 'etag' => $etag, + 'uid' => $uid, + ]; + } + + /** + * Initialize HTTP request object + */ + protected function initRequest($url = '', $method = 'GET', $config = array()) + { + $rcube = rcube::get_instance(); + $http_config = (array) $rcube->config->get('kolab_http_request'); + + // deprecated configuration options + if (empty($http_config)) { + foreach (array('ssl_verify_peer', 'ssl_verify_host') as $option) { + $value = $rcube->config->get('kolab_' . $option, true); + if (is_bool($value)) { + $http_config[$option] = $value; + } + } + } + + if (!empty($config)) { + $http_config = array_merge($http_config, $config); + } + + // load HTTP_Request2 (support both composer-installed and system-installed package) + if (!class_exists('HTTP_Request2')) { + require_once 'HTTP/Request2.php'; + } + + try { + $request = new HTTP_Request2(); + $request->setConfig($http_config); + + // proxy User-Agent string + $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); + + // cleanup + $request->setBody(''); + $request->setUrl($url); + $request->setMethod($method); + + return $request; + } + catch (Exception $e) { + rcube::raise_error($e, true, true); + } + } +} diff --git a/plugins/libkolab/lib/kolab_storage.php b/plugins/libkolab/lib/kolab_storage.php --- a/plugins/libkolab/lib/kolab_storage.php +++ b/plugins/libkolab/lib/kolab_storage.php @@ -944,7 +944,7 @@ * Wrapper for rcube_imap::list_folders_subscribed() * with support for temporarily subscribed folders */ - protected static function _imap_list_subscribed($root, $mbox) + protected static function _imap_list_subscribed($root, $mbox, $filter = null) { $folders = self::$imap->list_folders_subscribed($root, $mbox); diff --git a/plugins/libkolab/lib/kolab_storage_cache.php b/plugins/libkolab/lib/kolab_storage_cache.php --- a/plugins/libkolab/lib/kolab_storage_cache.php +++ b/plugins/libkolab/lib/kolab_storage_cache.php @@ -101,8 +101,8 @@ */ public function select_by_id($folder_id) { - $sql_arr = $this->db->fetch_assoc($this->db->query("SELECT * FROM `{$this->folders_table}` WHERE `folder_id` = ?", $folder_id)); - if ($sql_arr) { + $query = $this->db->query("SELECT * FROM `{$this->folders_table}` WHERE `folder_id` = ?", $folder_id); + if ($sql_arr = $this->db->fetch_assoc($query)) { $this->metadata = $sql_arr; $this->folder_id = $sql_arr['folder_id']; $this->folder = new StdClass; diff --git a/plugins/libkolab/lib/kolab_storage_dataset.php b/plugins/libkolab/lib/kolab_storage_dataset.php --- a/plugins/libkolab/lib/kolab_storage_dataset.php +++ b/plugins/libkolab/lib/kolab_storage_dataset.php @@ -79,18 +79,16 @@ public function offsetSet($offset, $value) { - $uid = $value['_msguid']; + $uid = !empty($value['_msguid']) ? $value['_msguid'] : $value['uid']; if (is_null($offset)) { $offset = count($this->index); - $this->index[] = $uid; - } - else { - $this->index[$offset] = $uid; } + $this->index[$offset] = $uid; + // keep full payload data in memory if possible - if ($this->memlimit && $this->buffer && isset($value['_mailbox'])) { + if ($this->memlimit && $this->buffer) { $this->data[$offset] = $value; // check memory usage and stop buffering @@ -115,8 +113,9 @@ if (isset($this->data[$offset])) { return $this->data[$offset]; } - else if ($msguid = $this->index[$offset]) { - return $this->cache->get($msguid); + + if ($uid = $this->index[$offset]) { + return $this->cache->get($uid); } return null; diff --git a/plugins/libkolab/lib/kolab_storage_dav.php b/plugins/libkolab/lib/kolab_storage_dav.php new file mode 100644 --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav.php @@ -0,0 +1,492 @@ + + * + * Copyright (C) 2022, Apheleia IT 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 . + */ + +class kolab_storage_dav +{ + const ERROR_DAV_CONN = 1; + const ERROR_CACHE_DB = 2; + const ERROR_NO_PERMISSION = 3; + const ERROR_INVALID_FOLDER = 4; + + protected $dav; + protected $url; + + + /** + * Object constructor + */ + public function __construct($url) + { + $this->url = $url; + $this->setup(); + } + + /** + * Setup the environment + */ + public function setup() + { + $rcmail = rcube::get_instance(); + + $this->config = $rcmail->config; + $this->dav = new kolab_dav_client($this->url); + } + + /** + * Get a list of storage folders for the given data type + * + * @param string Data type to list folders for (contact,distribution-list,event,task,note) + * + * @return array List of kolab_storage_dav_folder objects + */ + public function get_folders($type) + { + $davTypes = [ + 'event' => 'VEVENT', + 'task' => 'VTODO', + 'contact' => 'VCARD', + ]; + + // TODO: This should be cached + $folders = $this->dav->discover($davTypes[$type]); + + if (is_array($folders)) { + foreach ($folders as $idx => $folder) { + // Exclude some special folders + if (in_array('schedule-inbox', $folder['resource_type']) || in_array('schedule-outbox', $folder['resource_type'])) { + unset($folders[$idx]); + continue; + } + + $folders[$idx] = new kolab_storage_dav_folder($this->dav, $folder, $type); + } + } + + return $folders ?: []; + } + + /** + * Getter for the storage folder for the given type + * + * @param string Data type to list folders for (contact,distribution-list,event,task,note) + * + * @return object kolab_storage_dav_folder The folder object + */ + public function get_default_folder($type) + { + // TODO: Not used + } + + /** + * Getter for a specific storage folder + * + * @param string $id Folder to access + * @param string $type Expected folder type + * + * @return ?object kolab_storage_folder The folder object + */ + public function get_folder($id, $type = null) + { + foreach ($this->get_folders($type) as $folder) { + if ($folder->id == $id) { + return $folder; + } + } + } + + /** + * Getter for a single Kolab object, identified by its UID. + * This will search all folders storing objects of the given type. + * + * @param string Object UID + * @param string Object type (contact,event,task,journal,file,note,configuration) + * + * @return array The Kolab object represented as hash array or false if not found + */ + public function get_object($uid, $type) + { + // TODO + return false; + } + + /** + * Execute cross-folder searches with the given query. + * + * @param array Pseudo-SQL query as list of filter parameter triplets + * @param string Folder type (contact,event,task,journal,file,note,configuration) + * @param int Expected number of records or limit (for performance reasons) + * + * @return array List of Kolab data objects (each represented as hash array) + */ + public function select($query, $type, $limit = null) + { + $result = []; + + foreach ($this->get_folders($type) as $folder) { + if ($limit) { + $folder->set_order_and_limit(null, $limit); + } + + foreach ($folder->select($query) as $object) { + $result[] = $object; + } + } + + return $result; + } + + /** + * Compose an URL to query the free/busy status for the given user + * + * @param string Email address of the user to get free/busy data for + * @param object DateTime Start of the query range (optional) + * @param object DateTime End of the query range (optional) + * + * @return string Fully qualified URL to query free/busy data + */ + public static function get_freebusy_url($email, $start = null, $end = null) + { + return kolab_storage::get_freebusy_url($email, $start, $end); + } + + /** + * Deletes a folder + * + * @param string $name Folder name + * + * @return bool True on success, false on failure + */ + public function folder_delete($name) + { + // TODO + } + + /** + * Creates a folder + * + * @param string $name Folder name (UTF7-IMAP) + * @param string $type Folder type + * @param bool $subscribed Sets folder subscription + * @param bool $active Sets folder state (client-side subscription) + * + * @return bool True on success, false on failure + */ + public function folder_create($name, $type = null, $subscribed = false, $active = false) + { + // TODO + } + + /** + * Renames DAV folder + * + * @param string $oldname Old folder name (UTF7-IMAP) + * @param string $newname New folder name (UTF7-IMAP) + * + * @return bool True on success, false on failure + */ + public function folder_rename($oldname, $newname) + { + // TODO + } + + /** + * Rename or Create a new folder. + * + * Does additional checks for permissions and folder name restrictions + * + * @param array &$prop Hash array with folder properties and metadata + * - name: Folder name + * - oldname: Old folder name when changed + * - parent: Parent folder to create the new one in + * - type: Folder type to create + * - subscribed: Subscribed flag (IMAP subscription) + * - active: Activation flag (client-side subscription) + * + * @return string|false New folder name or False on failure + */ + public function folder_update(&$prop) + { + // TODO + } + + /** + * Getter for human-readable name of a folder + * + * @param string $folder Folder name (UTF7-IMAP) + * @param string $folder_ns Will be set to namespace name of the folder + * + * @return string Name of the folder-object + */ + public static function object_name($folder, &$folder_ns = null) + { + // TODO: Shared folders + $folder_ns = 'personal'; + return $folder; + } + + /** + * Creates a SELECT field with folders list + * + * @param string $type Folder type + * @param array $attrs SELECT field attributes (e.g. name) + * @param string $current The name of current folder (to skip it) + * + * @return html_select SELECT object + */ + public function folder_selector($type, $attrs, $current = '') + { + // TODO + } + + /** + * Returns a list of folder names + * + * @param string Optional root folder + * @param string Optional name pattern + * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) + * @param bool Enable to return subscribed folders only (null to use configured subscription mode) + * @param array Will be filled with folder-types data + * + * @return array List of folders + */ + public function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = array()) + { + // TODO + } + + /** + * Search for shared or otherwise not listed groupware folders the user has access + * + * @param string Folder type of folders to search for + * @param string Search string + * @param array Namespace(s) to exclude results from + * + * @return array List of matching kolab_storage_folder objects + */ + public function search_folders($type, $query, $exclude_ns = []) + { + // TODO + return []; + } + + /** + * Sort the given list of folders by namespace/name + * + * @param array List of kolab_storage_dav_folder objects + * + * @return array Sorted list of folders + */ + public static function sort_folders($folders) + { + // TODO + return $folders; + } + + /** + * Returns folder types indexed by folder name + * + * @param string $prefix Folder prefix (Default '*' for all folders) + * + * @return array|bool List of folders, False on failure + */ + public function folders_typedata($prefix = '*') + { + // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation + return []; + } + + /** + * Returns type of a DAV folder + * + * @param string $folder Folder name (UTF7-IMAP) + * + * @return string Folder type + */ + public function folder_type($folder) + { + // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation + return 'event'; + } + + /** + * Sets folder content-type. + * + * @param string $folder Folder name + * @param string $type Content type + * + * @return bool True on success, False otherwise + */ + public function set_folder_type($folder, $type = 'mail') + { + // NOP: Used by kolab_folders, kolab_activesync, kolab_delegation + return false; + } + + /** + * Check subscription status of this folder + * + * @param string $folder Folder name + * @param bool $temp Include temporary/session subscriptions + * + * @return bool True if subscribed, false if not + */ + public function folder_is_subscribed($folder, $temp = false) + { + // NOP + return true; + } + + /** + * Change subscription status of this folder + * + * @param string $folder Folder name + * @param bool $temp Only subscribe temporarily for the current session + * + * @return True on success, false on error + */ + public function folder_subscribe($folder, $temp = false) + { + // NOP + return true; + } + + /** + * Change subscription status of this folder + * + * @param string $folder Folder name + * @param bool $temp Only remove temporary subscription + * + * @return True on success, false on error + */ + public function folder_unsubscribe($folder, $temp = false) + { + // NOP + return false; + } + + /** + * Check activation status of this folder + * + * @param string $folder Folder name + * + * @return bool True if active, false if not + */ + public function folder_is_active($folder) + { + // TODO + return true; + } + + /** + * Change activation status of this folder + * + * @param string $folder Folder name + * + * @return True on success, false on error + */ + public function folder_activate($folder) + { + return true; + } + + /** + * Change activation status of this folder + * + * @param string $folder Folder name + * + * @return True on success, false on error + */ + public function folder_deactivate($folder) + { + return false; + } + + /** + * Creates default folder of specified type + * To be run when none of subscribed folders (of specified type) is found + * + * @param string $type Folder type + * @param string $props Folder properties (color, etc) + * + * @return string Folder name + */ + public function create_default_folder($type, $props = []) + { + // TODO: For kolab_addressbook?? + return ''; + } + + /** + * Returns a list of IMAP folders shared by the given user + * + * @param array User entry from LDAP + * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) + * @param int 1 - subscribed folders only, 0 - all folders, 2 - all non-active + * @param array Will be filled with folder-types data + * + * @return array List of folders + */ + public function list_user_folders($user, $type, $subscribed = 0, &$folderdata = []) + { + // TODO + return []; + } + + /** + * Get a list of (virtual) top-level folders from the other users namespace + * + * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) + * @param bool Enable to return subscribed folders only (null to use configured subscription mode) + * + * @return array List of kolab_storage_folder_user objects + */ + public function get_user_folders($type, $subscribed) + { + // TODO + return []; + } + + /** + * Handler for user_delete plugin hooks + * + * Remove all cache data from the local database related to the given user. + */ + public static function delete_user_folders($args) + { + $db = rcmail::get_instance()->get_dbh(); + $table = $db->table_name('kolab_folders', true); + $prefix = 'dav://' . urlencode($args['username']) . '@' . $args['host'] . '/%'; + + $db->query("DELETE FROM $table WHERE `resource` LIKE ?", $prefix); + } + + /** + * Get folder METADATA for all supported keys + * Do this in one go for better caching performance + */ + public function folder_metadata($folder) + { + // TODO ? + return []; + } +} diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache.php b/plugins/libkolab/lib/kolab_storage_dav_cache.php new file mode 100644 --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php @@ -0,0 +1,632 @@ + + * @author Aleksander Machniak + * + * Copyright (C) 2012-2013, Kolab Systems AG + * Copyright (C) 2022, Apheleia IT 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 . + */ + +class kolab_storage_dav_cache extends kolab_storage_cache +{ + /** + * Factory constructor + */ + public static function factory(kolab_storage_folder $storage_folder) + { + $subclass = 'kolab_storage_dav_cache_' . $storage_folder->type; + if (class_exists($subclass)) { + return new $subclass($storage_folder); + } + + rcube::raise_error( + ['code' => 900, 'message' => "No {$subclass} class found for folder '{$storage_folder->name}'"], + true + ); + + return new kolab_storage_dav_cache($storage_folder); + } + + /** + * Connect cache with a storage folder + * + * @param kolab_storage_folder The storage folder instance to connect with + */ + public function set_folder(kolab_storage_folder $storage_folder) + { + $this->folder = $storage_folder; + + if (!$this->folder->valid) { + $this->ready = false; + return; + } + + // compose fully qualified ressource uri for this instance + $this->resource_uri = $this->folder->get_resource_uri(); + $this->cache_table = $this->db->table_name('kolab_cache_dav_' . $this->folder->type); + $this->ready = true; + } + + /** + * Synchronize local cache data with remote + */ + public function synchronize() + { + // only sync once per request cycle + if ($this->synched) { + return; + } + + $this->sync_start = time(); + + // read cached folder metadata + $this->_read_folder_data(); + + $ctag = $this->folder->get_ctag(); + + // check cache status ($this->metadata is set in _read_folder_data()) + if ( + empty($this->metadata['ctag']) + || empty($this->metadata['changed']) + || $this->metadata['ctag'] !== $ctag + ) { + // lock synchronization for this folder and wait if already locked + $this->_sync_lock(); + + $result = $this->synchronize_worker(); + + // update ctag value (will be written to database in _sync_unlock()) + if ($result) { + $this->metadata['ctag'] = $ctag; + $this->metadata['changed'] = date(self::DB_DATE_FORMAT, time()); + } + + // remove lock + $this->_sync_unlock(); + } + + $this->synched = time(); + } + + /** + * Perform cache synchronization + */ + protected function synchronize_worker() + { + // get effective time limit we have for synchronization (~70% of the execution time) + $time_limit = $this->_max_sync_lock_time() * 0.7; + + if (time() - $this->sync_start > $time_limit) { + return false; + } + + // TODO: Implement synchronization with use of WebDAV-Sync (RFC 6578) + + // Get the objects from the DAV server + $dav_index = $this->folder->dav->getIndex($this->folder->href, $this->folder->get_dav_type()); + + if (!is_array($dav_index)) { + rcube::raise_error([ + 'code' => 900, + 'message' => "Failed to sync the kolab cache for {$this->folder->href}" + ], true); + return false; + } + + // WARNING: For now we assume object's href is /.ics, + // which would mean there are no duplicates (objects with the same uid). + // With DAV protocol we can't get UID without fetching the whole object. + // Also the folder_id + uid is a unique index in the database. + // In the future we maybe should store the href in database. + + // Determine objects to fetch or delete + $new_index = []; + $update_index = []; + $old_index = $this->folder_index(); // uid -> etag + $chunk_size = 20; // max numer of objects per DAV request + + foreach ($dav_index as $object) { + $uid = $object['uid']; + if (isset($old_index[$uid])) { + $old_etag = $old_index[$uid]; + $old_index[$uid] = null; + + if ($old_etag === $object['etag']) { + // the object didn't change + continue; + } + + $update_index[$uid] = $object['href']; + } + else { + $new_index[$uid] = $object['href']; + } + } + + // Fetch new objects and store in DB + if (!empty($new_index)) { + foreach (array_chunk($new_index, $chunk_size, true) as $chunk) { + $objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk); + + if (!is_array($objects)) { + rcube::raise_error([ + 'code' => 900, + 'message' => "Failed to sync the kolab cache for {$this->folder->href}" + ], true); + return false; + } + + foreach ($objects as $object) { + if ($object = $this->folder->from_dav($object)) { + $this->_extended_insert(false, $object); + } + } + + $this->_extended_insert(true, null); + + // check time limit and abort sync if running too long + if (++$i % 25 == 0 && time() - $this->sync_start > $time_limit) { + return false; + } + } + } + + // Fetch updated objects and store in DB + if (!empty($update_index)) { + foreach (array_chunk($update_index, $chunk_size, true) as $chunk) { + $objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk); + + if (!is_array($objects)) { + rcube::raise_error([ + 'code' => 900, + 'message' => "Failed to sync the kolab cache for {$this->folder->href}" + ], true); + return false; + } + + foreach ($objects as $object) { + if ($object = $this->folder->from_dav($object)) { + $this->save($object, $object['uid']); + } + } + + // check time limit and abort sync if running too long + if (++$i % 25 == 0 && time() - $this->sync_start > $time_limit) { + return false; + } + } + } + + // Remove deleted objects + $old_index = array_filter($old_index); + if (!empty($old_index)) { + $quoted_uids = join(',', array_map(array($this->db, 'quote'), $old_index)); + $this->db->query( + "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` IN ($quoted_uids)", + $this->folder_id + ); + } + + return true; + } + + /** + * Return current folder index (uid -> etag) + */ + protected function folder_index() + { + // read cache index + $sql_result = $this->db->query( + "SELECT `uid`, `etag` FROM `{$this->cache_table}` WHERE `folder_id` = ?", + $this->folder_id + ); + + $index = []; + + while ($sql_arr = $this->db->fetch_assoc($sql_result)) { + $index[$sql_arr['uid']] = $sql_arr['etag']; + } + + return $index; + } + + /** + * Read a single entry from cache or from server directly + * + * @param string Object UID + * @param string Object type to read + * @param string Unused (kept for compat. with the parent class) + */ + public function get($uid, $type = null, $unused = null) + { + if ($this->ready) { + $this->_read_folder_data(); + + $sql_result = $this->db->query( + "SELECT * FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` = ?", + $this->folder_id, + $uid + ); + + if ($sql_arr = $this->db->fetch_assoc($sql_result)) { + $object = $this->_unserialize($sql_arr); + } + } + + // fetch from DAV if not present in cache + if (empty($object)) { + if ($object = $this->folder->read_object($uid, $type ?: '*')) { + $this->save($object); + } + } + + return $object ?: null; + } + + /** + * Insert/Update a cache entry + * + * @param string Object UID + * @param array|false Hash array with object properties to save or false to delete the cache entry + * @param string Unused (kept for compat. with the parent class) + */ + public function set($uid, $object, $unused = null) + { + // remove old entry + if ($this->ready) { + $this->_read_folder_data(); + + $this->db->query( + "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` = ?", + $this->folder_id, + $uid + ); + } + + if ($object) { + $this->save($object); + } + } + + /** + * Insert (or update) a cache entry + * + * @param mixed Hash array with object properties to save or false to delete the cache entry + * @param string Optional old message UID (for update) + * @param string Unused (kept for compat. with the parent class) + */ + public function save($object, $olduid = null, $unused = null) + { + // write to cache + if ($this->ready) { + $this->_read_folder_data(); + + $sql_data = $this->_serialize($object); + $sql_data['folder_id'] = $this->folder_id; + $sql_data['uid'] = rcube_charset::clean($object['uid']); + $sql_data['etag'] = rcube_charset::clean($object['etag']); + + $args = []; + $cols = ['folder_id', 'uid', 'etag', 'changed', 'data', 'tags', 'words']; + $cols = array_merge($cols, $this->extra_cols); + + foreach ($cols as $idx => $col) { + $cols[$idx] = $this->db->quote_identifier($col); + $args[] = $sql_data[$col]; + } + + if ($olduid) { + foreach ($cols as $idx => $col) { + $cols[$idx] = "$col = ?"; + } + + $query = "UPDATE `{$this->cache_table}` SET " . implode(', ', $cols) + . " WHERE `folder_id` = ? AND `uid` = ?"; + $args[] = $this->folder_id; + $args[] = $olduid; + } + else { + $query = "INSERT INTO `{$this->cache_table}` (`created`, " . implode(', ', $cols) + . ") VALUES (" . $this->db->now() . str_repeat(', ?', count($cols)) . ")"; + } + + $result = $this->db->query($query, $args); + + if (!$this->db->affected_rows($result)) { + rcube::raise_error([ + 'code' => 900, + 'message' => "Failed to write to kolab cache" + ], true); + } + } + } + + /** + * Move an existing cache entry to a new resource + * + * @param string Entry's UID + * @param kolab_storage_folder Target storage folder instance + * @param string Unused (kept for compat. with the parent class) + * @param string Unused (kept for compat. with the parent class) + */ + public function move($uid, $target, $unused1 = null, $unused2 = null) + { + // TODO + } + + /** + * Update resource URI for existing folder + * + * @param string Target DAV folder to move it to + */ + public function rename($new_folder) + { + // TODO + } + + /** + * Select Kolab objects filtered by the given query + * + * @param array Pseudo-SQL query as list of filter parameter triplets + * triplet: ['', '', ''] + * @param bool Set true to only return UIDs instead of complete objects + * @param bool Use fast mode to fetch only minimal set of information + * (no xml fetching and parsing, etc.) + * + * @return array|null|kolab_storage_dataset List of Kolab data objects (each represented as hash array) or UIDs + */ + public function select($query = [], $uids = false, $fast = false) + { + $result = $uids ? [] : new kolab_storage_dataset($this); + + $this->_read_folder_data(); + + // fetch full object data on one query if a small result set is expected + $fetchall = !$uids && ($this->limit ? $this->limit[0] : ($count = $this->count($query))) < self::MAX_RECORDS; + + // skip SELECT if we know it will return nothing + if ($count === 0) { + return $result; + } + + $sql_query = "SELECT " . ($fetchall ? '*' : "`uid`") + . " FROM `{$this->cache_table}` WHERE `folder_id` = ?" + . $this->_sql_where($query) + . (!empty($this->order_by) ? " ORDER BY " . $this->order_by : ''); + + $sql_result = $this->limit ? + $this->db->limitquery($sql_query, $this->limit[1], $this->limit[0], $this->folder_id) : + $this->db->query($sql_query, $this->folder_id); + + if ($this->db->is_error($sql_result)) { + if ($uids) { + return null; + } + + $result->set_error(true); + return $result; + } + + while ($sql_arr = $this->db->fetch_assoc($sql_result)) { + if ($fast) { + $sql_arr['fast-mode'] = true; + } + if ($uids) { + $result[] = $sql_arr['uid']; + } + else if ($fetchall && ($object = $this->_unserialize($sql_arr))) { + $result[] = $object; + } + else if (!$fetchall) { + $result[] = $sql_arr; + } + } + + return $result; + } + + /** + * Get number of objects mathing the given query + * + * @param array $query Pseudo-SQL query as list of filter parameter triplets + * + * @return int The number of objects of the given type + */ + public function count($query = []) + { + // read from local cache DB (assume it to be synchronized) + $this->_read_folder_data(); + + $sql_result = $this->db->query( + "SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` ". + "WHERE `folder_id` = ?" . $this->_sql_where($query), + $this->folder_id + ); + + if ($this->db->is_error($sql_result)) { + return null; + } + + $sql_arr = $this->db->fetch_assoc($sql_result); + $count = intval($sql_arr['numrows']); + + return $count; + } + + /** + * Getter for a single Kolab object identified by its UID + * + * @param string $uid Object UID + * + * @return array|null The Kolab object represented as hash array + */ + public function get_by_uid($uid) + { + $old_limit = $this->limit; + + // set limit to skip count query + $this->limit = [1, 0]; + + $list = $this->select([['uid', '=', $uid]]); + + // set the limit back to defined value + $this->limit = $old_limit; + + if (!empty($list) && !empty($list[0])) { + return $list[0]; + } + } + + /** + * Check DAV connection error state + */ + protected function check_error() + { + // TODO ? + } + + /** + * Write records into cache using extended inserts to reduce the number of queries to be executed + * + * @param bool Set to false to commit buffered insert, true to force an insert + * @param array Kolab object to cache + */ + protected function _extended_insert($force, $object) + { + static $buffer = ''; + + $line = ''; + $cols = ['folder_id', 'uid', 'etag', 'created', 'changed', 'data', 'tags', 'words']; + if ($this->extra_cols) { + $cols = array_merge($cols, $this->extra_cols); + } + + if ($object) { + $sql_data = $this->_serialize($object); + + // Skip multi-folder insert for all databases but MySQL + // In Oracle we can't put long data inline, others we don't support yet + if (strpos($this->db->db_provider, 'mysql') !== 0) { + $extra_args = []; + $params = [ + $this->folder_id, + rcube_charset::clean($object['uid']), + rcube_charset::clean($object['etag']), + $sql_data['changed'], + $sql_data['data'], + $sql_data['tags'], + $sql_data['words'] + ]; + + foreach ($this->extra_cols as $col) { + $params[] = $sql_data[$col]; + $extra_args[] = '?'; + } + + $cols = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols)); + $extra_args = count($extra_args) ? ', ' . implode(', ', $extra_args) : ''; + + $result = $this->db->query( + "INSERT INTO `{$this->cache_table}` ($cols)" + . " VALUES (?, ?, " . $this->db->now() . ", ?, ?, ?, ?$extra_args)", + $params + ); + + if (!$this->db->affected_rows($result)) { + rcube::raise_error(array( + 'code' => 900, 'message' => "Failed to write to kolab cache" + ), true); + } + + return; + } + + $values = array( + $this->db->quote($this->folder_id), + $this->db->quote(rcube_charset::clean($object['uid'])), + $this->db->quote(rcube_charset::clean($object['etag'])), + $this->db->now(), + $this->db->quote($sql_data['changed']), + $this->db->quote($sql_data['data']), + $this->db->quote($sql_data['tags']), + $this->db->quote($sql_data['words']), + ); + foreach ($this->extra_cols as $col) { + $values[] = $this->db->quote($sql_data[$col]); + } + $line = '(' . join(',', $values) . ')'; + } + + if ($buffer && ($force || (strlen($buffer) + strlen($line) > $this->max_sql_packet()))) { + $columns = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols)); + $update = implode(', ', array_map(function($i) { return "`{$i}` = VALUES(`{$i}`)"; }, array_slice($cols, 2))); + + $result = $this->db->query( + "INSERT INTO `{$this->cache_table}` ($columns) VALUES $buffer" + . " ON DUPLICATE KEY UPDATE $update" + ); + + if (!$this->db->affected_rows($result)) { + rcube::raise_error(array( + 'code' => 900, 'message' => "Failed to write to kolab cache" + ), true); + } + + $buffer = ''; + } + + $buffer .= ($buffer ? ',' : '') . $line; + } + + /** + * Helper method to turn stored cache data into a valid storage object + */ + protected function _unserialize($sql_arr) + { + if ($sql_arr['fast-mode'] && !empty($sql_arr['data']) && ($object = json_decode($sql_arr['data'], true))) { + foreach ($this->data_props as $prop) { + if (isset($object[$prop]) && is_array($object[$prop]) && $object[$prop]['cl'] == 'DateTime') { + $object[$prop] = new DateTime($object[$prop]['dt'], new DateTimeZone($object[$prop]['tz'])); + } + else if (!isset($object[$prop]) && isset($sql_arr[$prop])) { + $object[$prop] = $sql_arr[$prop]; + } + } + + if ($sql_arr['created'] && empty($object['created'])) { + $object['created'] = new DateTime($sql_arr['created']); + } + + if ($sql_arr['changed'] && empty($object['changed'])) { + $object['changed'] = new DateTime($sql_arr['changed']); + } + + $object['_type'] = $sql_arr['type'] ?: $this->folder->type; + $object['uid'] = $sql_arr['uid']; + $object['etag'] = $sql_arr['etag']; + } + // Fetch a complete object from the server + else { + // TODO: Fetching objects one-by-one from DAV server is slow + $object = $this->folder->read_object($sql_arr['uid'], '*'); + } + + return $object; + } +} diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php b/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php new file mode 100644 --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php @@ -0,0 +1,113 @@ + + * + * Copyright (C) 2013-2022, Apheleia IT 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 . + */ + +class kolab_storage_dav_cache_contact extends kolab_storage_dav_cache +{ + protected $extra_cols_max = 255; + protected $extra_cols = ['type', 'name', 'firstname', 'surname', 'email']; + protected $data_props = ['type', 'name', 'firstname', 'middlename', 'prefix', 'suffix', 'surname', 'email', 'organization', 'member']; + protected $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email']; + + /** + * Helper method to convert the given Kolab object into a dataset to be written to cache + * + * @override + */ + protected function _serialize($object) + { + $sql_data = parent::_serialize($object); + $sql_data['type'] = $object['_type'] ?: 'contact'; + + // columns for sorting + $sql_data['name'] = rcube_charset::clean($object['name'] . $object['prefix']); + $sql_data['firstname'] = rcube_charset::clean($object['firstname'] . $object['middlename'] . $object['surname']); + $sql_data['surname'] = rcube_charset::clean($object['surname'] . $object['firstname'] . $object['middlename']); + $sql_data['email'] = ''; + + foreach ($object as $colname => $value) { + list($col, $field) = explode(':', $colname); + if ($col == 'email' && !empty($value)) { + $sql_data['email'] = is_array($value) ? $value[0] : $value; + break; + } + } + + // use organization if name is empty + if (empty($sql_data['name']) && !empty($object['organization'])) { + $sql_data['name'] = rcube_charset::clean($object['organization']); + } + + // make sure some data is not longer that database limit (#5291) + foreach ($this->extra_cols as $col) { + if (strlen($sql_data[$col]) > $this->extra_cols_max) { + $sql_data[$col] = rcube_charset::clean(substr($sql_data[$col], 0, $this->extra_cols_max)); + } + } + + $sql_data['tags'] = ' ' . join(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search + $sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' '; + + return $sql_data; + } + + /** + * Callback to get words to index for fulltext search + * + * @return array List of words to save in cache + */ + public function get_words($object) + { + $data = ''; + + foreach ($object as $colname => $value) { + list($col, $field) = explode(':', $colname); + + $val = ''; + if (in_array($col, $this->fulltext_cols)) { + $val = is_array($value) ? join(' ', $value) : $value; + } + + if (strlen($val)) { + $data .= $val . ' '; + } + } + + return array_unique(rcube_utils::normalize_string($data, true)); + } + + /** + * Callback to get object specific tags to cache + * + * @return array List of tags to save in cache + */ + public function get_tags($object) + { + $tags = []; + + if (!empty($object['birthday'])) { + $tags[] = 'x-has-birthday'; + } + + return $tags; + } +} diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_event.php b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php new file mode 100644 --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php @@ -0,0 +1,146 @@ + + * + * 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 . + */ + +class kolab_storage_dav_cache_event extends kolab_storage_dav_cache +{ + protected $extra_cols = ['dtstart','dtend']; + protected $data_props = ['categories', 'status', 'attendees']; + protected $fulltext_cols = ['title', 'description', 'location', 'attendees:name', 'attendees:email', 'categories']; + + /** + * Helper method to convert the given Kolab object into a dataset to be written to cache + * + * @override + */ + protected function _serialize($object) + { + $sql_data = parent::_serialize($object); + + $sql_data['dtstart'] = $this->_convert_datetime($object['start']); + $sql_data['dtend'] = $this->_convert_datetime($object['end']); + + // extend date range for recurring events + if (!empty($object['recurrence']) && !empty($object['_formatobj'])) { + $recurrence = new kolab_date_recurrence($object['_formatobj']); + $dtend = $recurrence->end() ?: new DateTime('now +100 years'); + $sql_data['dtend'] = $this->_convert_datetime($dtend); + } + + // extend start/end dates to spawn all exceptions + if (is_array($object['exceptions'])) { + foreach ($object['exceptions'] as $exception) { + if (is_a($exception['start'], 'DateTime')) { + $exstart = $this->_convert_datetime($exception['start']); + if ($exstart < $sql_data['dtstart']) { + $sql_data['dtstart'] = $exstart; + } + } + if (is_a($exception['end'], 'DateTime')) { + $exend = $this->_convert_datetime($exception['end']); + if ($exend > $sql_data['dtend']) { + $sql_data['dtend'] = $exend; + } + } + } + } + + $sql_data['tags'] = ' ' . join(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search + $sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' '; + + return $sql_data; + } + + /** + * Callback to get words to index for fulltext search + * + * @return array List of words to save in cache + */ + public function get_words($object = []) + { + $data = ''; + + foreach ($this->fulltext_cols as $colname) { + list($col, $field) = explode(':', $colname); + + if ($field) { + $a = []; + foreach ((array) $object[$col] as $attr) { + $a[] = $attr[$field]; + } + $val = join(' ', $a); + } + else { + $val = is_array($object[$col]) ? join(' ', $object[$col]) : $object[$col]; + } + + if (strlen($val)) + $data .= $val . ' '; + } + + $words = rcube_utils::normalize_string($data, true); + + // collect words from recurrence exceptions + if (is_array($object['exceptions'])) { + foreach ($object['exceptions'] as $exception) { + $words = array_merge($words, $this->get_words($exception)); + } + } + + return array_unique($words); + } + + /** + * Callback to get object specific tags to cache + * + * @return array List of tags to save in cache + */ + public function get_tags($object) + { + $tags = []; + + if (!empty($object['valarms'])) { + $tags[] = 'x-has-alarms'; + } + + // create tags reflecting participant status + if (is_array($object['attendees'])) { + foreach ($object['attendees'] as $attendee) { + if (!empty($attendee['email']) && !empty($attendee['status'])) + $tags[] = 'x-partstat:' . $attendee['email'] . ':' . strtolower($attendee['status']); + } + } + + // collect tags from recurrence exceptions + if (is_array($object['exceptions'])) { + foreach ($object['exceptions'] as $exception) { + $tags = array_merge($tags, $this->get_tags($exception)); + } + } + + if (!empty($object['status'])) { + $tags[] = 'x-status:' . strtolower($object['status']); + } + + return array_unique($tags); + } +} diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php new file mode 100644 --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -0,0 +1,587 @@ + + * + * Copyright (C) 2014-2022, Apheleia IT 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 . + */ +class kolab_storage_dav_folder extends kolab_storage_folder +{ + public $dav; + public $href; + public $attributes; + + /** + * Object constructor + */ + public function __construct($dav, $attributes, $type_annotation = '') + { + $this->attributes = $attributes; + + $this->href = $this->attributes['href']; + $this->id = md5($this->href); + $this->dav = $dav; + $this->valid = true; + + list($this->type, $suffix) = explode('.', $type_annotation); + $this->default = $suffix == 'default'; + $this->subtype = $this->default ? '' : $suffix; + + // Init cache + $this->cache = kolab_storage_dav_cache::factory($this); + } + + /** + * Returns the owner of the folder. + * + * @param bool Return a fully qualified owner name (i.e. including domain for shared folders) + * + * @return string The owner of this folder. + */ + public function get_owner($fully_qualified = false) + { + // return cached value + if (isset($this->owner)) { + return $this->owner; + } + + $rcube = rcube::get_instance(); + $this->owner = $rcube->get_user_name(); + $this->valid = true; + + // TODO: Support shared folders + + return $this->owner; + } + + /** + * Get a folder Etag identifier + */ + public function get_ctag() + { + return $this->attributes['ctag']; + } + + /** + * Getter for the name of the namespace to which the folder belongs + * + * @return string Name of the namespace (personal, other, shared) + */ + public function get_namespace() + { + // TODO: Support shared folders + return 'personal'; + } + + /** + * Get the display name value of this folder + * + * @return string Folder name + */ + public function get_name() + { + return kolab_storage_dav::object_name($this->attributes['name']); + } + + /** + * Getter for the top-end folder name (not the entire path) + * + * @return string Name of this folder + */ + public function get_foldername() + { + return $this->attributes['name']; + } + + public function get_folder_info() + { + return []; // todo ? + } + + /** + * Getter for parent folder path + * + * @return string Full path to parent folder + */ + public function get_parent() + { + // TODO + return ''; + } + + /** + * Compose a unique resource URI for this folder + */ + public function get_resource_uri() + { + if (!empty($this->resource_uri)) { + return $this->resource_uri; + } + + // compose fully qualified ressource uri for this instance + $host = preg_replace('|^https?://|', 'dav://' . urlencode($this->get_owner(true)) . '@', $this->dav->url); + $path = $this->href[0] == '/' ? $this->href : "/{$this->href}"; + + $host_path = parse_url($host, PHP_URL_PATH); + if ($host_path && strpos($path, $host_path) === 0) { + $path = substr($path, strlen($host_path)); + } + + $this->resource_uri = unslashify($host) . $path; + + return $this->resource_uri; + } + + /** + * Getter for the Cyrus mailbox identifier corresponding to this folder + * (e.g. user/john.doe/Calendar/Personal@example.org) + * + * @return string Mailbox ID + */ + public function get_mailbox_id() + { + // TODO: This is used with Bonnie related features + return ''; + } + + /** + * Get the color value stored in metadata + * + * @param string Default color value to return if not set + * + * @return mixed Color value from the folder metadata or $default if not set + */ + public function get_color($default = null) + { + return !empty($this->attributes['color']) ? $this->attributes['color'] : $default; + } + + /** + * Get ACL information for this folder + * + * @return string Permissions as string + */ + public function get_myrights() + { + // TODO + return ''; + } + + /** + * Helper method to extract folder UID + * + * @return string Folder's UID + */ + public function get_uid() + { + // TODO ??? + return ''; + } + + /** + * Check activation status of this folder + * + * @return bool True if enabled, false if not + */ + public function is_active() + { + // TODO + return true; + } + + /** + * Change activation status of this folder + * + * @param bool The desired subscription status: true = active, false = not active + * + * @return bool True on success, false on error + */ + public function activate($active) + { + // TODO + return true; + } + + /** + * Check subscription status of this folder + * + * @return bool True if subscribed, false if not + */ + public function is_subscribed() + { + // TODO + return true; + } + + /** + * Change subscription status of this folder + * + * @param bool The desired subscription status: true = subscribed, false = not subscribed + * + * @return True on success, false on error + */ + public function subscribe($subscribed) + { + // TODO + return true; + } + + /** + * Delete the specified object from this folder. + * + * @param array|string $object The Kolab object to delete or object UID + * @param bool $expunge Should the folder be expunged? + * + * @return bool True if successful, false on error + */ + public function delete($object, $expunge = true) + { + if (!$this->valid) { + return false; + } + + $uid = is_array($object) ? $object['uid'] : $object; + + $success = $this->dav->delete($this->object_location($uid), $content); + + if ($success) { + $this->cache->set($uid, false); + } + + return $success; + } + + /** + * + */ + public function delete_all() + { + if (!$this->valid) { + return false; + } + + // TODO: This method is used by kolab_addressbook plugin only + + $this->cache->purge(); + + return false; + } + + /** + * Restore a previously deleted object + * + * @param string $uid Object UID + * + * @return mixed Message UID on success, false on error + */ + public function undelete($uid) + { + if (!$this->valid) { + return false; + } + + // TODO + + return false; + } + + /** + * Move a Kolab object message to another IMAP folder + * + * @param string Object UID + * @param string IMAP folder to move object to + * + * @return bool True on success, false on failure + */ + public function move($uid, $target_folder) + { + if (!$this->valid) { + return false; + } + + // TODO + + return false; + } + + /** + * Save an object in this folder. + * + * @param array $object The array that holds the data of the object. + * @param string $type The type of the kolab object. + * @param string $uid The UID of the old object if it existed before + * + * @return mixed False on error or object UID on success + */ + public function save(&$object, $type = null, $uid = null) + { + if (!$this->valid || empty($object)) { + return false; + } + + if (!$type) { + $type = $this->type; + } +/* + // copy attachments from old message + $copyfrom = $object['_copyfrom'] ?: $object['_msguid']; + if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) { + foreach ((array)$old['_attachments'] as $key => $att) { + if (!isset($object['_attachments'][$key])) { + $object['_attachments'][$key] = $old['_attachments'][$key]; + } + // unset deleted attachment entries + if ($object['_attachments'][$key] == false) { + unset($object['_attachments'][$key]); + } + // load photo.attachment from old Kolab2 format to be directly embedded in xcard block + else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) { + if (!isset($object['photo'])) + $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']); + unset($object['_attachments'][$key]); + } + } + } + + // process attachments + if (is_array($object['_attachments'])) { + $numatt = count($object['_attachments']); + foreach ($object['_attachments'] as $key => $attachment) { + // FIXME: kolab_storage and Roundcube attachment hooks use different fields! + if (empty($attachment['content']) && !empty($attachment['data'])) { + $attachment['content'] = $attachment['data']; + unset($attachment['data'], $object['_attachments'][$key]['data']); + } + + // make sure size is set, so object saved in cache contains this info + if (!isset($attachment['size'])) { + if (!empty($attachment['content'])) { + if (is_resource($attachment['content'])) { + // this need to be a seekable resource, otherwise + // fstat() failes and we're unable to determine size + // here nor in rcube_imap_generic before IMAP APPEND + $stat = fstat($attachment['content']); + $attachment['size'] = $stat ? $stat['size'] : 0; + } + else { + $attachment['size'] = strlen($attachment['content']); + } + } + else if (!empty($attachment['path'])) { + $attachment['size'] = filesize($attachment['path']); + } + $object['_attachments'][$key] = $attachment; + } + + // generate unique keys (used as content-id) for attachments + if (is_numeric($key) && $key < $numatt) { + // derrive content-id from attachment file name + $ext = preg_match('/(\.[a-z0-9]{1,6})$/i', $attachment['name'], $m) ? $m[1] : null; + $basename = preg_replace('/[^a-z0-9_.-]/i', '', basename($attachment['name'], $ext)); // to 7bit ascii + if (!$basename) $basename = 'noname'; + $cid = $basename . '.' . microtime(true) . $key . $ext; + + $object['_attachments'][$cid] = $attachment; + unset($object['_attachments'][$key]); + } + } + } +*/ + $rcmail = rcube::get_instance(); + $result = false; + + // generate and save object message + if ($content = $this->to_dav($object)) { + $method = $uid ? 'update' : 'create'; + $dav_type = $this->get_dav_type(); + $result = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type); + + // Note: $result can be NULL if the request was successful, but ETag wasn't returned + if ($result !== false) { + // insert/update object in the cache + $object['etag'] = $result; + $this->cache->save($object, $uid); + $result = true; + } + } + + return $result; + } + + /** + * Fetch the object the DAV server and convert to internal format + * + * @param string The object UID to fetch + * @param string The object type expected (use wildcard '*' to accept all types) + * @param string Unused (kept for compat. with the parent class) + * + * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found + */ + public function read_object($uid, $type = null, $folder = null) + { + if (!$this->valid) { + return false; + } + + $href = $this->object_location($uid); + $objects = $this->dav->getData($this->href, $this->get_dav_type(), [$href]); + + if (!is_array($objects) || count($objects) != 1) { + rcube::raise_error([ + 'code' => 900, + 'message' => "Failed to fetch {$href}" + ], true); + return false; + } + + return $this->from_dav($objects[0]); + } + + /** + * Convert DAV object into PHP array + * + * @param array Object data in kolab_dav_client::fetchData() format + * + * @return array Object properties + */ + public function from_dav($object) + { + if ($this->type == 'event') { + $ical = libcalendaring::get_ical(); + $events = $ical->import($object['data']); + + if (!count($events) || empty($events[0]['uid'])) { + return false; + } + + $result = $events[0]; + } + else if ($this->type == 'contact') { + if (stripos($object['data'], 'BEGIN:VCARD') !== 0) { + return false; + } + + $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false); + + if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) { + $result = $vcard->get_assoc(); + } + else { + return false; + } + } + + $result['etag'] = $object['etag']; + $result['href'] = $object['href']; + $result['uid'] = $object['uid'] ?: $result['uid']; + + return $result; + } + + /** + * Convert Kolab object into DAV format (iCalendar) + */ + public function to_dav($object) + { + $result = ''; + + if ($this->type == 'event') { + $ical = libcalendaring::get_ical(); + if (!empty($object['exceptions'])) { + $object['recurrence']['EXCEPTIONS'] = $object['exceptions']; + } + + $result = $ical->export([$object]); + } + else if ($this->type == 'contact') { + // copy values into vcard object + $vcard = new rcube_vcard('', RCUBE_CHARSET, false, ['uid' => 'UID']); + + $vcard->set('groups', null); + + foreach ($object as $key => $values) { + list($field, $section) = rcube_utils::explode(':', $key); + + // avoid casting DateTime objects to array + if (is_object($values) && is_a($values, 'DateTime')) { + $values = [$values]; + } + + foreach ((array) $values as $value) { + if (isset($value)) { + $vcard->set($field, $value, $section); + } + } + } + + $result = $vcard->export(false); + } + + if ($result) { + // The content must be UTF-8, otherwise if we try to fetch the object + // from server XML parsing would fail. + $result = rcube_charset::clean($result); + } + + return $result; + } + + protected function object_location($uid) + { + return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext(); + } + + /** + * Get a folder DAV content type + */ + public function get_dav_type() + { + $types = [ + 'event' => 'VEVENT', + 'task' => 'VTODO', + 'contact' => 'VCARD', + ]; + + return $types[$this->type]; + } + + + /** + * Get a DAV file extension for specified Kolab type + */ + public function get_dav_ext() + { + $types = [ + 'event' => 'ics', + 'task' => 'ics', + 'contact' => 'vcf', + ]; + + return $types[$this->type]; + } + + /** + * Return folder name as string representation of this object + * + * @return string Full IMAP folder name + */ + public function __toString() + { + return $this->attributes['name']; + } +} diff --git a/plugins/libkolab/lib/kolab_storage_folder.php b/plugins/libkolab/lib/kolab_storage_folder.php --- a/plugins/libkolab/lib/kolab_storage_folder.php +++ b/plugins/libkolab/lib/kolab_storage_folder.php @@ -45,6 +45,7 @@ * * @param string The folder name/path * @param string Expected folder type + * @param string Optional folder type if known */ function __construct($name, $type = null, $type_annotation = null) { @@ -596,7 +597,7 @@ */ public function save(&$object, $type = null, $uid = null) { - if (!$this->valid && empty($object)) { + if (!$this->valid || empty($object)) { return false; } diff --git a/plugins/libkolab/libkolab.php b/plugins/libkolab/libkolab.php --- a/plugins/libkolab/libkolab.php +++ b/plugins/libkolab/libkolab.php @@ -235,8 +235,10 @@ $key = md5(serialize($http_config)); if (!($request = self::$http_requests[$key])) { - // load HTTP_Request2 - require_once 'HTTP/Request2.php'; + // load HTTP_Request2 (support both composer-installed and system-installed package) + if (!class_exists('HTTP_Request2')) { + require_once 'HTTP/Request2.php'; + } try { $request = new HTTP_Request2();