diff --git a/plugins/calendar/drivers/caldav/caldav_calendar.php b/plugins/calendar/drivers/caldav/caldav_calendar.php new file mode 100644 index 00000000..b4e11b2e --- /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] = $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 = $this->_to_driver_event($record); + } + + if ($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 + colab_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); + $event['_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 index 00000000..9e38cc87 --- /dev/null +++ b/plugins/calendar/drivers/caldav/caldav_driver.php @@ -0,0 +1,527 @@ + + * + * 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, + ]; + + 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 necesary + 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 index 00000000..3d34c992 --- /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 index 005cac77..42ef7cf6 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1,2644 +1,2646 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2015, 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_driver extends calendar_driver { const INVITATIONS_CALENDAR_PENDING = '--invitation--pending'; const INVITATIONS_CALENDAR_DECLINED = '--invitation--declined'; // features this backend supports public $alarms = true; public $attendees = true; public $freebusy = true; public $attachments = true; public $undelete = true; 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 */ public function __construct($cal) { $cal->require_plugin('libkolab'); // load helper classes *after* libkolab has been loaded (#3248) require_once(__DIR__ . '/kolab_calendar.php'); 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']); $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); if (kolab_storage::$version == '2.0') { $this->alarm_types = ['DISPLAY']; $this->alarm_absolute = false; } // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); // calendar uses fully encoded identifiers kolab_storage::$encode_ids = true; } /** * Read available calendars from server */ - private function _read_calendars() + protected function _read_calendars() { // already read sources if (isset($this->calendars)) { return $this->calendars; } // 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 = []; 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 kolab_calendar */ - private function _to_calendar($folder) + protected function _to_calendar($folder) { if ($folder instanceof kolab_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 kolab_calendar($folder->name, $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(); // attempt to create a default calendar for this user if (!$this->has_writeable) { if ($this->create_calendar(['name' => 'Calendar', 'color' => 'cc0000'])) { unset($this->calendars); $this->_read_calendars(); } } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $folders = $this->filter_calendars($filter); $calendars = []; // 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); foreach ($folders as $id => $cal) { $imap_path = explode($delim, $cal->name); // 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; 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::folder_hierarchy() above // make sure we deal with kolab_calendar instances $cal = $this->_to_calendar($cal); $this->calendars[$cal->id] = $cal; $is_user = ($cal instanceof kolab_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, ]; 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 kolab_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 list of calendars according to specified filters * * @param int Bitmask defining restrictions. See FILTER_* constants for possible values. * * @return array List of calendars */ protected function filter_calendars($filter) { $this->_read_calendars(); $calendars = []; $plugin = $this->rc->plugins->exec_hook('calendar_list_filter', [ 'list' => $this->calendars, 'calendars' => $calendars, 'filter' => $filter, ]); if ($plugin['abort']) { return $plugin['calendars']; } $personal = $filter & self::FILTER_PERSONAL; $shared = $filter & self::FILTER_SHARED; foreach ($this->calendars as $cal) { if (!$cal->ready) { continue; } if (($filter & self::FILTER_WRITEABLE) && !$cal->editable) { continue; } if (($filter & self::FILTER_INSERTABLE) && !$cal->editable) { continue; } if (($filter & self::FILTER_ACTIVE) && !$cal->is_active()) { continue; } if (($filter & self::FILTER_PRIVATE) && $cal->subtype != 'private') { continue; } if (($filter & self::FILTER_CONFIDENTIAL) && $cal->subtype != 'confidential') { continue; } if ($personal || $shared) { $ns = $cal->get_namespace(); if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { continue; } } $calendars[$cal->id] = $cal; } return $calendars; } /** * Get the kolab_calendar instance for the given calendar ID * * @param string Calendar identifier (encoded imap folder name) * * @return kolab_calendar Object nor null if calendar doesn't exist */ public function get_calendar($id) { $this->_read_calendars(); // create calendar object if necesary if (empty($this->calendars[$id])) { if (in_array($id, [self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED])) { return new kolab_invitation_calendar($id, $this->cal); } // for unsubscribed calendar folders if ($id !== self::BIRTHDAY_CALENDAR_ID) { $calendar = kolab_calendar::factory($id, $this->cal); if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; } } } return !empty($this->calendars[$id]) ? $this->calendars[$id] : null; } /** * Create a new calendar assigned to the current user * * @param array Hash array with calendar properties * name: Calendar name * color: The color of the calendar * * @return mixed ID of the calendar on success, False on error */ public function create_calendar($prop) { $prop['type'] = 'event'; $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', []); if (isset($prop['color'])) { $prefs['kolab_calendars'][$id]['color'] = $prop['color']; } if (isset($prop['showalarms'])) { $prefs['kolab_calendars'][$id]['showalarms'] = !empty($prop['showalarms']); } if (!empty($prefs['kolab_calendars'][$id])) { $this->rc->user->save_prefs($prefs); } return $id; } /** * Update properties of an existing calendar * * @see calendar_driver::edit_calendar() */ public function edit_calendar($prop) { if (!empty($prop['id']) && ($cal = $this->get_calendar($prop['id']))) { $id = $cal->update($prop); } else { $id = $prop['id']; } // fallback to local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); unset($prefs['kolab_calendars'][$prop['id']]['color'], $prefs['kolab_calendars'][$prop['id']]['showalarms']); if (isset($prop['color'])) { $prefs['kolab_calendars'][$id]['color'] = $prop['color']; } if (isset($prop['showalarms']) && $id == self::BIRTHDAY_CALENDAR_ID) { $prefs['calendar_birthdays_alarm_type'] = $prop['showalarms'] ? $this->alarm_types[0] : ''; } else if (isset($prop['showalarms'])) { $prefs['kolab_calendars'][$id]['showalarms'] = !empty($prop['showalarms']); } if (!empty($prefs['kolab_calendars'][$id])) { $this->rc->user->save_prefs($prefs); } return true; } /** * Set active/subscribed state of a calendar * * @see calendar_driver::subscribe_calendar() */ public function subscribe_calendar($prop) { if (!empty($prop['id']) && ($cal = $this->get_calendar($prop['id'])) && !empty($cal->storage)) { $ret = false; if (isset($prop['permanent'])) { $ret |= $cal->storage->subscribe(intval($prop['permanent'])); } if (isset($prop['active'])) { $ret |= $cal->storage->activate(intval($prop['active'])); } // 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); } } } } return $ret; } else { // save state in local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); $prefs['kolab_calendars'][$prop['id']]['active'] = !empty($prop['active']); $this->rc->user->save_prefs($prefs); return true; } return false; } /** * Delete the given calendar with all its contents * * @see calendar_driver::delete_calendar() */ public function delete_calendar($prop) { if (!empty($prop['id']) && ($cal = $this->get_calendar($prop['id']))) { $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']]); $this->rc->user->save_prefs($prefs); return true; } else { - $this->last_error = kolab_storage::$last_error; + $this->last_error = $this->storage->last_error; } } return false; } /** * 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) { - if (!kolab_storage::setup()) { + if (!$this->storage->setup()) { return []; } $this->calendars = []; $this->search_more_results = false; // 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; } } // 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 (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; } } 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(); } /** * Fetch a single event * * @see calendar_driver::get_event() * @return array Hash array with event properties, false if not found */ public function get_event($event, $scope = 0, $full = false) { if (is_array($event)) { $id = !empty($event['id']) ? $event['id'] : $event['uid']; $cal = $event['calendar']; // we're looking for a recurring instance: expand the ID to our internal convention for recurring instances if (empty($event['id']) && !empty($event['_instance'])) { $id .= '-' . $event['_instance']; } } else { $id = $event; } if (!empty($cal)) { if ($storage = $this->get_calendar($cal)) { $result = $storage->get_event($id); return self::to_rcube_event($result); } // get event from the address books birthday calendar if ($cal == self::BIRTHDAY_CALENDAR_ID) { return $this->get_birthday_event($id); } } // iterate over all calendar folders and search for the event ID else { foreach ($this->filter_calendars($scope) as $calendar) { if ($result = $calendar->get_event($id)) { return self::to_rcube_event($result); } } } return false; } /** * Add a single event to the database * * @see calendar_driver::new_event() */ public function new_event($event) { if (!$this->validate($event)) { return false; } $event = self::from_rcube_event($event); if (!$event['calendar']) { $this->_read_calendars(); $cal_ids = array_keys($this->calendars); $event['calendar'] = reset($cal_ids); } if ($storage = $this->get_calendar($event['calendar'])) { // if this is a recurrence instance, append as exception to an already existing object for this UID if (!empty($event['recurrence_date']) && ($master = $storage->get_event($event['uid']))) { self::add_exception($master, $event); $success = $storage->update_event($master); } else { $success = $storage->insert_event($event); } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', ['action' => 'calendar/push-freebusy', 'source' => $storage->id]); $this->freebusy_trigger = false; // disable after first execution (#2355) } return $success; } return false; } /** * Update an event entry with the given data * * @see calendar_driver::new_event() * @return bool True on success, False on error */ public function edit_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) { return false; } return $this->update_event(self::from_rcube_event($event, $storage->get_event($event['id']))); } /** * Extended event editing with possible changes to the argument * * @param array Hash array with event properties * @param string New participant status * @param array List of hash arrays with updated attendees * * @return bool True on success, False on error */ public function edit_rsvp(&$event, $status, $attendees) { $update_event = $event; // apply changes to master (and all exceptions) if ($event['_savemode'] == 'all' && !empty($event['recurrence_id'])) { if ($storage = $this->get_calendar($event['calendar'])) { $update_event = $storage->get_event($event['recurrence_id']); $update_event['_savemode'] = $event['_savemode']; $update_event['id'] = $update_event['uid']; unset($update_event['recurrence_id']); calendar::merge_attendee_data($update_event, $attendees); } } if ($ret = $this->update_attendees($update_event, $attendees)) { // replace with master event (for iTip reply) $event = self::to_rcube_event($update_event); // re-assign to the according (virtual) calendar if ($this->rc->config->get('kolab_invitation_calendars')) { if (strtoupper($status) == 'DECLINED') { $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; } else if (strtoupper($status) == 'NEEDS-ACTION') { $event['calendar'] = self::INVITATIONS_CALENDAR_PENDING; } else if (!empty($event['_folder_id'])) { $event['calendar'] = $event['_folder_id']; } } } return $ret; } /** * Update the participant status for the given attendees * * @see calendar_driver::update_attendees() */ public function update_attendees(&$event, $attendees) { // for this-and-future updates, merge the updated attendees onto all exceptions in range if ( ($event['_savemode'] == 'future' && !empty($event['recurrence_id'])) || (!empty($event['recurrence']) && empty($event['recurrence_id'])) ) { if (!($storage = $this->get_calendar($event['calendar']))) { return false; } // load master event $master = !empty($event['recurrence_id']) ? $storage->get_event($event['recurrence_id']) : $event; // apply attendee update to each existing exception if (!empty($master['recurrence']) && !empty($master['recurrence']['EXCEPTIONS'])) { $saved = false; foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // merge the new event properties onto future exceptions if ($exception['_instance'] >= strval($event['_instance'])) { calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $attendees); } // update a specific instance if ($exception['_instance'] == $event['_instance'] && $exception['thisandfuture']) { $saved = true; } } // add the given event as new exception if (!$saved && $event['id'] != $master['id']) { $event['thisandfuture'] = true; $master['recurrence']['EXCEPTIONS'][] = $event; } // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; return $this->update_event($master); } } // just update the given event (instance) return $this->update_event($event); } /** * Move a single event * * @see calendar_driver::move_event() * @return boolean True on success, False on error */ public function move_event($event) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } return false; } /** * Resize a single event * * @see calendar_driver::resize_event() * @return boolean True on success, False on error */ public function resize_event($event) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } return false; } /** * Remove a single event * * @param array Hash array with event properties: * id: Event identifier * @param bool Remove record(s) irreversible (mark as deleted otherwise) * * @return bool True on success, False on error */ public function remove_event($event, $force = true) { $ret = true; $success = false; $savemode = isset($event['_savemode']) ? $event['_savemode'] : null; if (!$force) { unset($event['attendees']); $this->rc->session->remove('calendar_event_undo'); $this->rc->session->remove('calendar_restore_event_data'); $sess_data = $event; } if (($storage = $this->get_calendar($event['calendar'])) && ($event = $storage->get_event($event['id']))) { $event['_savemode'] = $savemode; $decline = $event['_decline']; $savemode = 'all'; $master = $event; // read master if deleting a recurring event if (!empty($event['recurrence']) || !empty($event['recurrence_id']) || !empty($event['isexception'])) { $master = $storage->get_event($event['uid']); if (!empty($event['_savemode'])) { $savemode = $event['_savemode']; } else if (!empty($event['_instance']) || !empty($event['isexception'])) { $savemode = 'current'; } // force 'current' mode for single occurrences stored as exception if (empty($event['recurrence']) && empty($event['recurrence_id']) && !empty($event['isexception'])) { $savemode = 'current'; } } // removing an exception instance if ((!empty($event['recurrence_id']) || !empty($event['isexception'])) && !empty($master['exceptions'])) { foreach ($master['exceptions'] as $i => $exception) { if ($exception['_instance'] == $event['_instance']) { unset($master['exceptions'][$i]); // set event date back to the actual occurrence if (!empty($exception['recurrence_date'])) { $event['start'] = $exception['recurrence_date']; } } } if (!empty($master['recurrence'])) { $master['recurrence']['EXCEPTIONS'] = &$master['exceptions']; } } switch ($savemode) { case 'current': $_SESSION['calendar_restore_event_data'] = $master; // remove the matching RDATE entry if (!empty($master['recurrence']['RDATE'])) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { unset($master['recurrence']['RDATE'][$j]); break; } } } // add exception to master event $master['recurrence']['EXDATE'][] = $event['start']; $success = $storage->update_event($master); break; case 'future': $master['_instance'] = libcalendaring::recurrence_instance_identifier($master); if ($master['_instance'] != $event['_instance']) { $_SESSION['calendar_restore_event_data'] = $master; // set until-date on master event $master['recurrence']['UNTIL'] = clone $event['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); // if all future instances are deleted, remove recurrence rule entirely (bug #1677) if ($master['recurrence']['UNTIL']->format('Ymd') == $master['start']->format('Ymd')) { $master['recurrence'] = []; } // remove matching RDATE entries else if (!empty($master['recurrence']['RDATE'])) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { $master['recurrence']['RDATE'] = array_slice($master['recurrence']['RDATE'], 0, $j); break; } } } $success = $storage->update_event($master); $ret = $master['uid']; break; } default: // 'all' is default // removing the master event with loose exceptions (not recurring though) if (!empty($event['recurrence_date']) && empty($master['recurrence']) && !empty($master['exceptions'])) { // make the first exception the new master $newmaster = array_shift($master['exceptions']); $newmaster['exceptions'] = $master['exceptions']; $newmaster['_attachments'] = $master['_attachments']; $newmaster['_mailbox'] = $master['_mailbox']; $newmaster['_msguid'] = $master['_msguid']; $success = $storage->update_event($newmaster); } else if ($decline && $this->rc->config->get('kolab_invitation_calendars')) { // don't delete but set PARTSTAT=DECLINED if ($this->cal->lib->set_partstat($master, 'DECLINED')) { $success = $storage->update_event($master); } } if (!$success) { $success = $storage->delete_event($master, $force); } break; } } if ($success && !$force) { if (!empty($master['_folder_id'])) { $sess_data['_folder_id'] = $master['_folder_id']; } $_SESSION['calendar_event_undo'] = ['ts' => time(), 'data' => $sess_data]; } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', [ 'action' => 'calendar/push-freebusy', // _folder_id may be set by invitations calendar 'source' => !empty($master['_folder_id']) ? $master['_folder_id'] : $storage->id, ]); } return $success ? $ret : false; } /** * Restore a single deleted event * * @param array Hash array with event properties: * id: Event identifier * calendar: Event calendar * * @return bool True on success, False on error */ public function restore_event($event) { if ($storage = $this->get_calendar($event['calendar'])) { if (!empty($_SESSION['calendar_restore_event_data'])) { $success = $storage->update_event($event = $_SESSION['calendar_restore_event_data']); } else { $success = $storage->restore_event($event); } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', [ 'action' => 'calendar/push-freebusy', // _folder_id may be set by invitations calendar 'source' => !empty($event['_folder_id']) ? $event['_folder_id'] : $storage->id, ]); } return $success; } return false; } /** * 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; } // move event to another folder/calendar if (!empty($event['_fromcalendar']) && $event['_fromcalendar'] != $event['calendar']) { if (!($fromcalendar = $this->get_calendar($event['_fromcalendar']))) { return false; } $old = $fromcalendar->get_event($event['id']); if ($event['_savemode'] != 'new') { if (!$fromcalendar->storage->move($old['uid'], $storage->storage)) { return false; } $fromcalendar = $storage; } } else { $fromcalendar = $storage; } $success = false; $savemode = 'all'; $attachments = []; $old = $master = $storage->get_event($event['id']); if (!$old || empty($old['start'])) { rcube::raise_error([ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to load event object to update: id=" . $event['id'] ], true, false ); return false; } // modify a recurring event, check submitted savemode to do the right things if (!empty($old['recurrence']) || !empty($old['recurrence_id']) || !empty($old['isexception'])) { $master = $storage->get_event($old['uid']); if (!empty($event['_savemode'])) { $savemode = $event['_savemode']; } else { $savemode = (!empty($old['recurrence_id']) || !empty($old['isexception'])) ? 'current' : 'all'; } // this-and-future on the first instance equals to 'all' if ($savemode == 'future' && !empty($master['start']) && $old['_instance'] == libcalendaring::recurrence_instance_identifier($master) ) { $savemode = 'all'; } // force 'current' mode for single occurrences stored as exception else if (empty($old['recurrence']) && empty($old['recurrence_id']) && !empty($old['isexception'])) { $savemode = 'current'; } // Stick to the master timezone for all occurrences (Bifrost#T104637) $master_tz = $master['start']->getTimezone(); $event_tz = $event['start']->getTimezone(); if ($master_tz->getName() != $event_tz->getName()) { $event['start']->setTimezone($master_tz); $event['end']->setTimezone($master_tz); } } // check if update affects scheduling and update attendee status accordingly $reschedule = $this->check_scheduling($event, $old, true); // keep saved exceptions (not submitted by the client) if (!empty($old['recurrence']['EXDATE']) && !isset($event['recurrence']['EXDATE'])) { $event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE']; } if (isset($event['recurrence']['EXCEPTIONS'])) { // exceptions already provided (e.g. from iCal import) $with_exceptions = true; } else if (!empty($old['recurrence']['EXCEPTIONS'])) { $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS']; } else if (!empty($old['exceptions'])) { $event['exceptions'] = $old['exceptions']; } // remove some internal properties which should not be saved unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_owner'], $event['_notify'], $event['_method'], $event['_sender'], $event['_sender_utf'], $event['_size'] ); switch ($savemode) { case 'new': // save submitted data as new (non-recurring) event $event['recurrence'] = []; $event['_copyfrom'] = $master['_msguid']; $event['_mailbox'] = $master['_mailbox']; $event['uid'] = $this->cal->generate_uid(); unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); // copy attachment metadata to new event $event = self::from_rcube_event($event, $master); self::clear_attandee_noreply($event); if ($success = $storage->insert_event($event)) { $success = $event['uid']; } break; case 'future': // create a new recurring event $event['_copyfrom'] = $master['_msguid']; $event['_mailbox'] = $master['_mailbox']; $event['uid'] = $this->cal->generate_uid(); unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); // copy attachment metadata to new event $event = self::from_rcube_event($event, $master); // remove recurrence exceptions on re-scheduling if ($reschedule) { unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']); } else if (isset($event['recurrence']['EXCEPTIONS']) && is_array($event['recurrence']['EXCEPTIONS'])) { // only keep relevant exceptions $event['recurrence']['EXCEPTIONS'] = array_filter( $event['recurrence']['EXCEPTIONS'], function($exception) use ($event) { return $exception['start'] > $event['start']; } ); if (isset($event['recurrence']['EXDATE']) && is_array($event['recurrence']['EXDATE'])) { $event['recurrence']['EXDATE'] = array_filter( $event['recurrence']['EXDATE'], function($exdate) use ($event) { return $exdate > $event['start']; } ); } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } // compute remaining occurrences if ($event['recurrence']['COUNT']) { if (empty($old['_count'])) { $old['_count'] = $this->get_recurrence_count($master, $old['start']); } $event['recurrence']['COUNT'] -= intval($old['_count']); } // remove fixed weekday when date changed if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) { if (!empty($event['recurrence']['BYDAY']) && strlen($event['recurrence']['BYDAY']) == 2) { unset($event['recurrence']['BYDAY']); } if (!empty($old['recurrence']['BYMONTH']) && $old['recurrence']['BYMONTH'] == $old['start']->format('n')) { unset($event['recurrence']['BYMONTH']); } } // set until-date on master event $master['recurrence']['UNTIL'] = clone $old['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); // remove all exceptions after $event['start'] if (isset($master['recurrence']['EXCEPTIONS']) && is_array($master['recurrence']['EXCEPTIONS'])) { $master['recurrence']['EXCEPTIONS'] = array_filter( $master['recurrence']['EXCEPTIONS'], function($exception) use ($event) { return $exception['start'] < $event['start']; } ); // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; } if (isset($master['recurrence']['EXDATE']) && is_array($master['recurrence']['EXDATE'])) { $master['recurrence']['EXDATE'] = array_filter( $master['recurrence']['EXDATE'], function($exdate) use ($event) { return $exdate < $event['start']; } ); } // save new event if ($success = $storage->insert_event($event)) { $success = $event['uid']; // update master event (no rescheduling!) self::clear_attandee_noreply($master); $storage->update_event($master); } break; case 'current': // recurring instances shall not store recurrence rules and attachments $event['recurrence'] = []; $event['thisandfuture'] = $savemode == 'future'; unset($event['attachments'], $event['id']); // increment sequence of this instance if scheduling is affected if ($reschedule) { $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; } else if (!isset($event['sequence'])) { $event['sequence'] = !empty($old['sequence']) ? $old['sequence'] : $master['sequence']; } // save properties to a recurrence exception instance if (!empty($old['_instance']) && isset($master['recurrence']['EXCEPTIONS'])) { if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) { $success = $storage->update_event($master, $old['id']); break; } } $add_exception = true; // adjust matching RDATE entry if dates changed if ( !empty($master['recurrence']['RDATE']) && ($old_date = $old['start']->format('Ymd')) != $event['start']->format('Ymd') ) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $old_date) { $master['recurrence']['RDATE'][$j] = $event['start']; sort($master['recurrence']['RDATE']); $add_exception = false; break; } } } // save as new exception to master event if ($add_exception) { self::add_exception($master, $event, $old); } $success = $storage->update_event($master); break; default: // 'all' is the default $event['id'] = $master['uid']; $event['uid'] = $master['uid']; // use start date from master but try to be smart on time or duration changes $old_start_date = $old['start']->format('Y-m-d'); $old_start_time = !empty($old['allday']) ? '' : $old['start']->format('H:i'); $old_duration = self::event_duration($old['start'], $old['end'], !empty($old['allday'])); $new_start_date = $event['start']->format('Y-m-d'); $new_start_time = !empty($event['allday']) ? '' : $event['start']->format('H:i'); $new_duration = self::event_duration($event['start'], $event['end'], !empty($event['allday'])); $diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration; $date_shift = $old['start']->diff($event['start']); // shifted or resized if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) { $event['start'] = $master['start']->add($date_shift); $event['end'] = clone $event['start']; $event['end']->add(new DateInterval($new_duration)); // remove fixed weekday, will be re-set to the new weekday in kolab_calendar::update_event() if ($old_start_date != $new_start_date && !empty($event['recurrence'])) { if (!empty($event['recurrence']['BYDAY']) && strlen($event['recurrence']['BYDAY']) == 2) unset($event['recurrence']['BYDAY']); if (!empty($old['recurrence']['BYMONTH']) && $old['recurrence']['BYMONTH'] == $old['start']->format('n')) unset($event['recurrence']['BYMONTH']); } } // dates did not change, use the ones from master else if ($new_start_date . $new_start_time == $old_start_date . $old_start_time) { $event['start'] = $master['start']; $event['end'] = $master['end']; } // when saving an instance in 'all' mode, copy recurrence exceptions over if (!empty($old['recurrence_id'])) { $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS']; $event['recurrence']['EXDATE'] = $master['recurrence']['EXDATE']; } else if (!empty($master['_instance'])) { $event['_instance'] = $master['_instance']; $event['recurrence_date'] = $master['recurrence_date']; } // TODO: forward changes to exceptions (which do not yet have differing values stored) if (!empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { // determine added and removed attendees $old_attendees = $current_attendees = $added_attendees = []; if (!empty($old['attendees'])) { foreach ((array) $old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } } if (!empty($event['attendees'])) { foreach ((array) $event['attendees'] as $attendee) { $current_attendees[] = $attendee['email']; if (!in_array($attendee['email'], $old_attendees)) { $added_attendees[] = $attendee; } } } $removed_attendees = array_diff($old_attendees, $current_attendees); foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { calendar::merge_attendee_data($event['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); } // adjust recurrence-id when start changed and therefore the entire recurrence chain changes if ($old_start_date != $new_start_date || $old_start_time != $new_start_time) { $recurrence_id_format = libcalendaring::recurrence_id_format($event); foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { if (isset($exception['recurrence_date']) && is_a($exception['recurrence_date'], 'DateTime')) { $recurrence_id = $exception['recurrence_date']; } else { $recurrence_id = rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); } if ($recurrence_id instanceof DateTime) { $recurrence_id->add($date_shift); $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id; $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format); } } } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } // unset _dateonly flags in (cached) date objects unset($event['start']->_dateonly, $event['end']->_dateonly); $success = $storage->update_event($event) ? $event['id'] : false; // return master UID break; } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', [ 'action' => 'calendar/push-freebusy', 'source' => $storage->id ]); } return $success; } /** * Calculate event duration, returns string in DateInterval format */ protected static function event_duration($start, $end, $allday = false) { if ($allday) { $diff = $start->diff($end); return 'P' . $diff->days . 'D'; } return 'PT' . ($end->format('U') - $start->format('U')) . 'S'; } /** * Determine whether the current change affects scheduling and reset attendee status accordingly */ public function check_scheduling(&$event, $old, $update = true) { // skip this check when importing iCal/iTip events if (isset($event['sequence']) || !empty($event['_method'])) { return false; } // iterate through the list of properties considered 'significant' for scheduling $kolab_event = !empty($old['_formatobj']) ? $old['_formatobj'] : new kolab_format_event(); $reschedule = $kolab_event->check_rescheduling($event, $old); // reset all attendee status to needs-action (#4360) if ($update && $reschedule && !empty($event['attendees'])) { $is_organizer = false; $emails = $this->cal->get_user_emails(); $attendees = $event['attendees']; foreach ($attendees as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER' && !empty($attendee['email']) && in_array(strtolower($attendee['email']), $emails) ) { $is_organizer = true; } else if ($attendee['role'] != 'ORGANIZER' && $attendee['role'] != 'NON-PARTICIPANT' && $attendee['status'] != 'DELEGATED' ) { $attendees[$i]['status'] = 'NEEDS-ACTION'; $attendees[$i]['rsvp'] = true; } } // update attendees only if I'm the organizer if ($is_organizer || (!empty($event['organizer']) && in_array(strtolower($event['organizer']['email']), $emails))) { $event['attendees'] = $attendees; } } return $reschedule; } /** * Apply the given changes to already existing exceptions */ protected function update_recurrence_exceptions(&$master, $event, $old, $savemode) { $saved = false; $existing = null; // determine added and removed attendees $added_attendees = $removed_attendees = []; if ($savemode == 'future') { $old_attendees = $current_attendees = []; if (!empty($old['attendees'])) { foreach ((array) $old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } } if (!empty($event['attendees'])) { foreach ((array) $event['attendees'] as $attendee) { $current_attendees[] = $attendee['email']; if (!in_array($attendee['email'], $old_attendees)) { $added_attendees[] = $attendee; } } } $removed_attendees = array_diff($old_attendees, $current_attendees); } foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // update a specific instance if ($exception['_instance'] == $old['_instance']) { $existing = $i; // check savemode against existing exception mode. // if matches, we can update this existing exception $thisandfuture = !empty($exception['thisandfuture']); if ($thisandfuture === ($savemode == 'future')) { $event['_instance'] = $old['_instance']; $event['thisandfuture'] = $old['thisandfuture']; $event['recurrence_date'] = $old['recurrence_date']; $master['recurrence']['EXCEPTIONS'][$i] = $event; $saved = true; } } // merge the new event properties onto future exceptions if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { unset($event['thisandfuture']); self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, ['attendees']); if (!empty($added_attendees) || !empty($removed_attendees)) { calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); } } } /* // we could not update the existing exception due to savemode mismatch... if (!$saved && isset($existing) && !empty($master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture'])) { // ... try to move the existing this-and-future exception to the next occurrence foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) { // our old this-and-future exception is obsolete if (!empty($candidate['thisandfuture'])) { unset($master['recurrence']['EXCEPTIONS'][$existing]); $saved = true; break; } // this occurrence doesn't yet have an exception else if (empty($candidate['isexception'])) { $event['_instance'] = $candidate['_instance']; $event['recurrence_date'] = $candidate['recurrence_date']; $master['recurrence']['EXCEPTIONS'][$i] = $event; $saved = true; break; } } } */ // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; // returning false here will add a new exception return $saved; } /** * Add or update the given event as an exception to $master */ public static function add_exception(&$master, $event, $old = null) { if ($old) { $event['_instance'] = $old['_instance']; if (empty($event['recurrence_date'])) { $event['recurrence_date'] = !empty($old['recurrence_date']) ? $old['recurrence_date'] : $old['start']; } } else if (empty($event['recurrence_date'])) { $event['recurrence_date'] = $event['start']; } if (empty($event['_instance']) && is_a($event['recurrence_date'], 'DateTime')) { $event['_instance'] = libcalendaring::recurrence_instance_identifier($event, !empty($master['allday'])); } if (!is_array($master['exceptions']) && isset($master['recurrence']['EXCEPTIONS'])) { $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; } $existing = false; foreach ((array) $master['exceptions'] as $i => $exception) { if ($exception['_instance'] == $event['_instance']) { $master['exceptions'][$i] = $event; $existing = true; } } if (!$existing) { $master['exceptions'][] = $event; } return true; } /** * Remove the noreply flags from attendees */ public static function clear_attandee_noreply(&$event) { if (!empty($event['attendees'])) { foreach ((array) $event['attendees'] as $i => $attendee) { unset($event['attendees'][$i]['noreply']); } } } /** * Merge certain properties from the overlay event to the base event object * * @param array The event object to be altered * @param array The overlay event object to be merged over $event * @param array List of properties not allowed to be overwritten */ public static function merge_exception_data(&$event, $overlay, $blacklist = null) { $forbidden = ['id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments']; if (is_array($blacklist)) { $forbidden = array_merge($forbidden, $blacklist); } foreach ($overlay as $prop => $value) { if ($prop == 'start' || $prop == 'end') { // handled by merge_exception_dates() below } else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { $event[$prop] = $value; } else if ($prop[0] != '_' && !in_array($prop, $forbidden)) { $event[$prop] = $value; } } self::merge_exception_dates($event, $overlay); } /** * Merge start/end date from the overlay event to the base event object * * @param array The event object to be altered * @param array The overlay event object to be merged over $event */ public static function merge_exception_dates(&$event, $overlay) { // compute date offset from the exception if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) { $date_offset = $overlay['recurrence_date']->diff($overlay['start']); } foreach (['start', 'end'] as $prop) { $value = $overlay[$prop]; if (isset($event[$prop]) && $event[$prop] instanceof DateTime) { // set date value if overlay is an exception of the current instance if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); } // apply date offset else if (!empty($date_offset)) { $event[$prop]->add($date_offset); } // adjust time of the recurring event instance $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); } } } /** * 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, 'kolab_driver::to_rcube_event'); return $events; } /** * Get number of events in the given calendar * * @param mixed List of calendar IDs to count events (either as array or comma-separated string) * @param int Date range start (unix timestamp) * @param int Date range end (unix timestamp) * * @return array Hash array with counts grouped by calendar ID */ public function count_events($calendars, $start, $end = null) { $counts = []; if ($calendars && is_string($calendars)) { $calendars = explode(',', $calendars); } else if (!$calendars) { $this->_read_calendars(); $calendars = array_keys($this->calendars); } foreach ($calendars as $cid) { if ($storage = $this->get_calendar($cid)) { $counts[$cid] = $storage->count_events($start, $end); } } return $counts; } /** * Get a list of pending alarms to be displayed to the user * * @see calendar_driver::pending_alarms() */ public function pending_alarms($time, $calendars = null) { $interval = 300; $time -= $time % 60; $slot = $time; $slot -= $slot % $interval; $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); $last -= $last % $interval; // only check for alerts once in 5 minutes if ($last == $slot) { return []; } if ($calendars && is_string($calendars)) { $calendars = explode(',', $calendars); } $time = $slot + $interval; $alarms = []; $candidates = []; $query = [['tags', '=', 'x-has-alarms']]; $this->_read_calendars(); foreach ($this->calendars as $cid => $calendar) { // skip calendars with alarms disabled if (!$calendar->alarms || ($calendars && !in_array($cid, $calendars))) { continue; } foreach ($calendar->list_events($time, $time + 86400 * 365, null, 1, $query) as $e) { // add to list if alarm is set $alarm = libcalendaring::get_next_alarm($e); if ($alarm && !empty($alarm['time']) && $alarm['time'] >= $last && in_array($alarm['action'], $this->alarm_types) ) { $id = $alarm['id']; // use alarm-id as primary identifier $candidates[$id] = [ 'id' => $id, 'title' => $e['title'], 'location' => $e['location'], 'start' => $e['start'], 'end' => $e['end'], 'notifyat' => $alarm['time'], 'action' => $alarm['action'], ]; } } } // get alarm information stored in local database if (!empty($candidates)) { $dbdata = []; $alarm_ids = array_map([$this->rc->db, 'quote'], array_keys($candidates)); $result = $this->rc->db->query("SELECT *" . " FROM " . $this->rc->db->table_name('kolab_alarms', true) . " WHERE `alarm_id` IN (" . join(',', $alarm_ids) . ")" . " AND `user_id` = ?", $this->rc->user->ID ); while ($result && ($e = $this->rc->db->fetch_assoc($result))) { $dbdata[$e['alarm_id']] = $e; } foreach ($candidates as $id => $alarm) { // skip dismissed alarms if ($dbdata[$id]['dismissed']) { continue; } // snooze function may have shifted alarm time $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $alarm['notifyat']; if ($notifyat <= $time) { $alarms[] = $alarm; } } } return $alarms; } /** * Feedback after showing/sending an alarm notification * * @see calendar_driver::dismiss_alarm() */ public function dismiss_alarm($alarm_id, $snooze = 0) { $alarms_table = $this->rc->db->table_name('kolab_alarms', true); // delete old alarm entry $this->rc->db->query("DELETE FROM $alarms_table" . " WHERE `alarm_id` = ? AND `user_id` = ?", $alarm_id, $this->rc->user->ID ); // set new notifyat time or unset if not snoozed $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; $query = $this->rc->db->query("INSERT INTO $alarms_table" . " (`alarm_id`, `user_id`, `dismissed`, `notifyat`)" . " VALUES (?, ?, ?, ?)", $alarm_id, $this->rc->user->ID, $snooze > 0 ? 0 : 1, $notifyat ); return $this->rc->db->affected_rows($query); } /** * List attachments from the given event */ public function list_attachments($event) { if (!($storage = $this->get_calendar($event['calendar']))) { return false; } $event = $storage->get_event($event['id']); return $event['attachments']; } /** * Get attachment properties */ public function get_attachment($id, $event) { if (!($storage = $this->get_calendar($event['calendar']))) { return false; } // get old revision of event if (!empty($event['rev'])) { $event = $this->get_event_revison($event, $event['rev'], true); } else { $event = $storage->get_event($event['id']); } if ($event) { $attachments = isset($event['_attachments']) ? $event['_attachments'] : $event['attachments']; foreach ((array) $attachments as $att) { if ($att['id'] == $id) { return $att; } } } } /** * Get attachment body * @see calendar_driver::get_attachment_body() */ public function get_attachment_body($id, $event) { if (!($cal = $this->get_calendar($event['calendar']))) { return false; } // get old revision of event if (!empty($event['rev'])) { if (empty($this->bonnie_api)) { return false; } $cid = substr($id, 4); // call Bonnie API and get the raw mime message list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); if ($msg_raw = $this->bonnie_api->rawdata('event', $uid, $event['rev'], $mailbox, $msguid)) { // parse the message and find the part with the matching content-id $message = rcube_mime::parse_message($msg_raw); foreach ((array) $message->parts as $part) { if (!empty($part->headers['content-id']) && trim($part->headers['content-id'], '<>') == $cid) { return $part->body; } } } return false; } return $cal->get_attachment_body($id, $event); } /** * Build a struct representing the given message reference * * @see calendar_driver::get_message_reference() */ public function get_message_reference($uri_or_headers, $folder = null) { if (is_object($uri_or_headers)) { $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); } if (is_string($uri_or_headers)) { return kolab_storage_config::get_message_reference($uri_or_headers, 'event'); } return false; } /** * List availabale categories * The default implementation reads them from config/user prefs */ public function list_categories() { // FIXME: complete list with categories saved in config objects (KEP:12) return $this->rc->config->get('calendar_categories', $this->default_categories); } /** * 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 if (empty($event['_formatobj'])) { $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); } /** * */ - 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'])) { $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; } /** * Fetch free/busy information from a person within the given range */ public function get_freebusy_list($email, $start, $end) { if (empty($email)/* || $end < time()*/) { return false; } // map vcalendar fbtypes to internal values $fbtypemap = [ 'FREE' => calendar::FREEBUSY_FREE, 'BUSY-TENTATIVE' => calendar::FREEBUSY_TENTATIVE, 'X-OUT-OF-OFFICE' => calendar::FREEBUSY_OOF, 'OOF' => calendar::FREEBUSY_OOF ]; // ask kolab server first try { $request_config = [ 'store_body' => true, '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 if ($response->getStatus() == 401) { $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password'])); $response = $request->send(); } if ($response->getStatus() == 200) { $fbdata = $response->getBody(); } unset($request, $response); } catch (Exception $e) { PEAR::raiseError("Error fetching free/busy information: " . $e->getMessage()); } // get free-busy url from contacts if (empty($fbdata)) { $fburl = null; foreach ((array) $this->rc->config->get('autocomplete_addressbooks', 'sql') as $book) { $abook = $this->rc->get_address_book($book); if ($result = $abook->search(['email'], $email, true, true, true/*, 'freebusyurl'*/)) { while ($contact = $result->iterate()) { if (!empty($contact['freebusyurl'])) { $fbdata = @file_get_contents($contact['freebusyurl']); break; } } } if (!empty($fbdata)) { break; } } } // parse free-busy information using Horde classes if (!empty($fbdata)) { $ical = $this->cal->get_ical(); $ical->import($fbdata); if ($fb = $ical->freebusy) { $result = []; foreach ($fb['periods'] as $tuple) { list($from, $to, $type) = $tuple; $result[] = [ $from->format('U'), $to->format('U'), isset($fbtypemap[$type]) ? $fbtypemap[$type] : calendar::FREEBUSY_BUSY ]; } // we take 'dummy' free-busy lists as "unknown" if (empty($result) && !empty($fb['comment']) && stripos($fb['comment'], 'dummy')) { return false; } // set period from $start till the begin of the free-busy information as 'unknown' if (!empty($fb['start']) && ($fbstart = $fb['start']->format('U')) && $start < $fbstart) { array_unshift($result, [$start, $fbstart, calendar::FREEBUSY_UNKNOWN]); } // pad period till $end with status 'unknown' if (!empty($fb['end']) && ($fbend = $fb['end']->format('U')) && $fbend < $end) { $result[] = [$fbend, $end, calendar::FREEBUSY_UNKNOWN]; } return $result; } } return false; } /** * Handler to push folder triggers when sent from client. * Used to push free-busy changes asynchronously after updating an event */ public function push_freebusy() { // make shure triggering completes set_time_limit(0); ignore_user_abort(true); $cal = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); if (!($cal = $this->get_calendar($cal))) { return false; } // trigger updates on folder $trigger = $cal->storage->trigger(); if (is_object($trigger) && is_a($trigger, 'PEAR_Error')) { rcube::raise_error([ 'code' => 900, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed triggering folder. Error was " . $trigger->getMessage() ], true, false ); } exit; } /** * Convert from driver format to external caledar app data */ public static function to_rcube_event(&$record) { if (!is_array($record)) { return $record; } $record['id'] = $record['uid']; if (!empty($record['_instance'])) { $record['id'] .= '-' . $record['_instance']; if (empty($record['recurrence_id']) && !empty($record['recurrence'])) { $record['recurrence_id'] = $record['uid']; } } // all-day events go from 12:00 - 13:00 if (is_a($record['start'], 'DateTime') && $record['end'] <= $record['start'] && !empty($record['allday'])) { $record['end'] = clone $record['start']; $record['end']->add(new DateInterval('PT1H')); } // translate internal '_attachments' to external 'attachments' list if (!empty($record['_attachments'])) { foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (empty($attachment['name'])) { $attachment['name'] = $key; } unset($attachment['path'], $attachment['content']); $attachments[] = $attachment; } } $record['attachments'] = $attachments; } if (!empty($record['attendees'])) { foreach ((array) $record['attendees'] as $i => $attendee) { if (isset($attendee['delegated-from']) && is_array($attendee['delegated-from'])) { $record['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); } if (isset($attendee['delegated-to']) && is_array($attendee['delegated-to'])) { $record['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); } } } // Roundcube only supports one category assignment if (!empty($record['categories']) && is_array($record['categories'])) { $record['categories'] = $record['categories'][0]; } // the cancelled flag transltes into status=CANCELLED if (!empty($record['cancelled'])) { $record['status'] = 'CANCELLED'; } // The web client only supports DISPLAY type of alarms if (!empty($record['alarms'])) { $record['alarms'] = preg_replace('/:[A-Z]+$/', ':DISPLAY', $record['alarms']); } // remove empty recurrence array if (empty($record['recurrence'])) { unset($record['recurrence']); } // clean up exception data else if (!empty($record['recurrence']['EXCEPTIONS'])) { array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments'] ); }); } unset($record['_mailbox'], $record['_msguid'], $record['_type'], $record['_size'], $record['_formatobj'], $record['_attachments'], $record['exceptions'], $record['x-custom'] ); return $record; } /** * */ public static function from_rcube_event($event, $old = []) { kolab_format::merge_attachments($event, $old); return $event; } /** * Set CSS class according to the event's attendde partstat */ public static function add_partstat_class($event, $partstats, $user = null) { // set classes according to PARTSTAT if (!empty($event['attendees'])) { $user_emails = libcalendaring::get_instance()->get_user_emails($user); $partstat = 'UNKNOWN'; foreach ($event['attendees'] as $attendee) { if (in_array($attendee['email'], $user_emails)) { $partstat = $attendee['status']; break; } } if (in_array($partstat, $partstats)) { $event['className'] = trim($event['className'] . ' fc-invitation-' . strtolower($partstat)); } } return $event; } /** * Provide a list of revisions for the given event * * @param array $event Hash array with event properties * * @return array List of changes, each as a hash array * @see calendar_driver::get_event_changelog() */ public function get_event_changelog($event) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); $result = $this->bonnie_api->changelog('event', $uid, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { return $result['changes']; } return false; } /** * Get a list of property changes beteen two revisions of an event * * @param array $event Hash array with event properties * @param mixed $rev1 Old Revision * @param mixed $rev2 New Revision * * @return array List of property changes, each as a hash array * @see calendar_driver::get_event_diff() */ public function get_event_diff($event, $rev1, $rev2) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); // get diff for the requested recurrence instance $instance_id = $event['id'] != $uid ? substr($event['id'], strlen($uid) + 1) : null; // call Bonnie API $result = $this->bonnie_api->diff('event', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $keymap = [ 'dtstart' => 'start', 'dtend' => 'end', 'dstamp' => 'changed', 'summary' => 'title', 'alarm' => 'alarms', 'attendee' => 'attendees', 'attach' => 'attachments', 'rrule' => 'recurrence', 'transparency' => 'free_busy', 'lastmodified-date' => 'changed', ]; $prop_keymaps = [ 'attachments' => ['fmttype' => 'mimetype', 'label' => 'name'], 'attendees' => ['partstat' => 'status'], ]; $special_changes = []; // map kolab event properties to keys the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap, $prop_keymaps, $special_changes) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } // translate free_busy values if ($change['property'] == 'free_busy') { $change['old'] = !empty($old['old']) ? 'free' : 'busy'; $change['new'] = !empty($old['new']) ? 'free' : 'busy'; } // map alarms trigger value if ($change['property'] == 'alarms') { if (!empty($change['old']['trigger'])) { $change['old']['trigger'] = $change['old']['trigger']['value']; } if (!empty($change['new']['trigger'])) { $change['new']['trigger'] = $change['new']['trigger']['value']; } } // make all property keys uppercase if ($change['property'] == 'recurrence') { $special_changes['recurrence'] = $i; foreach (['old', 'new'] as $m) { if (!empty($change[$m])) { $props = []; foreach ($change[$m] as $k => $v) { $props[strtoupper($k)] = $v; } $change[$m] = $props; } } } // map property keys names if (!empty($prop_keymaps[$change['property']])) { foreach ($prop_keymaps[$change['property']] as $k => $dest) { if (!empty($change['old']) && array_key_exists($k, $change['old'])) { $change['old'][$dest] = $change['old'][$k]; unset($change['old'][$k]); } if (!empty($change['new']) && array_key_exists($k, $change['new'])) { $change['new'][$dest] = $change['new'][$k]; unset($change['new'][$k]); } } } if ($change['property'] == 'exdate') { $special_changes['exdate'] = $i; } else if ($change['property'] == 'rdate') { $special_changes['rdate'] = $i; } }); // merge some recurrence changes foreach (['exdate', 'rdate'] as $prop) { if (array_key_exists($prop, $special_changes)) { $exdate = $result['changes'][$special_changes[$prop]]; if (array_key_exists('recurrence', $special_changes)) { $recurrence = &$result['changes'][$special_changes['recurrence']]; } else { $i = count($result['changes']); $result['changes'][$i] = ['property' => 'recurrence', 'old' => [], 'new' => []]; $recurrence = &$result['changes'][$i]['recurrence']; } $key = strtoupper($prop); $recurrence['old'][$key] = $exdate['old']; $recurrence['new'][$key] = $exdate['new']; unset($result['changes'][$special_changes[$prop]]); } } return $result; } return false; } /** * Return full data of a specific revision of an event * * @param array Hash array with event properties * @param mixed $rev Revision number * * @return array Event object as hash array * @see calendar_driver::get_event_revison() */ public function get_event_revison($event, $rev, $internal = false) { if (empty($this->bonnie_api)) { return false; } $eventid = $event['id']; $calid = $event['calendar']; list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); // call Bonnie API $result = $this->bonnie_api->get('event', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('event'); $format->load($result['xml']); $event = $format->to_array(); $format->get_attachments($event, true); // get the right instance from a recurring event if ($eventid != $event['uid']) { $instance_id = substr($eventid, strlen($event['uid']) + 1); // check for recurrence exception first if ($instance = $format->get_instance($instance_id)) { $event = $instance; } else { // not a exception, compute recurrence... $event['_formatobj'] = $format; $recurrence_date = rcube_utils::anytodatetime($instance_id, $event['start']->getTimezone()); foreach ($this->get_recurring_events($event, $event['start'], $recurrence_date) as $instance) { if ($instance['id'] == $eventid) { $event = $instance; break; } } } } if ($format->is_valid()) { $event['calendar'] = $calid; $event['rev'] = $result['rev']; return $internal ? $event : self::to_rcube_event($event); } } return false; } /** * Command the backend to restore a certain revision of an event. * This shall replace the current event with an older version. * * @param mixed $event UID string or hash array with event properties: * id: Event identifier * calendar: Calendar identifier * @param mixed $rev Revision number * * @return bool True on success, False on failure */ public function restore_event_revision($event, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); $calendar = $this->get_calendar($event['calendar']); $success = false; if ($calendar && $calendar->storage && $calendar->editable) { if ($raw_msg = $this->bonnie_api->rawdata('event', $uid, $rev, $mailbox)) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($calendar->storage->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $calendar->storage->name); $calendar->storage->cache->set($msguid, false); } } } return $success; } /** * Helper method to resolved the given event identifier into uid and folder * * @return array (uid,folder,msguid) tuple */ - private function _resolve_event_identity($event) + protected function _resolve_event_identity($event) { $mailbox = $msguid = null; if (is_array($event)) { $uid = !empty($event['uid']) ? $event['uid'] : $event['id']; if (($cal = $this->get_calendar($event['calendar'])) && !($cal instanceof kolab_invitation_calendar)) { $mailbox = $cal->get_mailbox_id(); // get event object from storage in order to get the real object uid an msguid if ($ev = $cal->get_event($event['id'])) { $msguid = $ev['_msguid']; $uid = $ev['uid']; } } } else { $uid = $event; // get event object from storage in order to get the real object uid an msguid if ($ev = $this->get_event($event)) { $mailbox = $ev['_mailbox']; $msguid = $ev['_msguid']; $uid = $ev['uid']; } } return array($uid, $mailbox, $msguid); } /** * 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]; $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = []; if (strlen($folder)) { $path_imap = explode($delim, $folder); array_pop($path_imap); // pop off name part $path_imap = implode($delim, $path_imap); $options = $storage->folder_info($folder); } else { $path_imap = ''; } // General tab $form['props'] = [ 'name' => $this->rc->gettext('properties'), 'fields' => [], ]; $protected = !empty($options) && (!empty($options['norename']) || !empty($options['protected'])); // 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); } // calendar name (default field) $form['props']['fields']['location'] = $formfields['name']; if ($protected) { // prevent user from moving folder $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', 'label' => $this->cal->gettext('parentcalendar'), 'value' => $select->show(strlen($folder) ? $path_imap : ''), ]; } // 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); } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $db = $this->rc->get_dbh(); foreach (['kolab_alarms', 'itipinvitations'] as $table) { $db->query("DELETE FROM " . $this->rc->db->table_name($table, true) . " WHERE `user_id` = ?", $args['user']->ID); } } } diff --git a/plugins/libkolab/SQL/mysql.initial.sql b/plugins/libkolab/SQL/mysql.initial.sql index 33b8a57d..f56486f4 100644 --- a/plugins/libkolab/SQL/mysql.initial.sql +++ b/plugins/libkolab/SQL/mysql.initial.sql @@ -1,182 +1,199 @@ /** * libkolab database schema * * @author Thomas Bruederli * @licence GNU AGPL */ SET FOREIGN_KEY_CHECKS=0; DROP TABLE IF EXISTS `kolab_folders`; CREATE TABLE `kolab_folders` ( `folder_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `resource` VARCHAR(255) BINARY NOT NULL, `type` VARCHAR(32) NOT NULL, `synclock` INT(10) NOT NULL DEFAULT '0', `ctag` VARCHAR(40) DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `objectcount` BIGINT DEFAULT NULL, PRIMARY KEY(`folder_id`), INDEX `resource_type` (`resource`, `type`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache`; DROP TABLE IF EXISTS `kolab_cache_contact`; CREATE TABLE `kolab_cache_contact` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT 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_contact_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `contact_type` (`folder_id`,`type`), INDEX `contact_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_event`; CREATE TABLE `kolab_cache_event` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT 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_event_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `event_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_task`; CREATE TABLE `kolab_cache_task` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT 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_task_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `task_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_journal`; CREATE TABLE `kolab_cache_journal` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT 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_journal_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `journal_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_note`; CREATE TABLE `kolab_cache_note` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, CONSTRAINT `fk_kolab_cache_note_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `note_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_file`; CREATE TABLE `kolab_cache_file` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `filename` varchar(255) DEFAULT NULL, CONSTRAINT `fk_kolab_cache_file_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `folder_filename` (`folder_id`, `filename`), INDEX `file_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_configuration`; CREATE TABLE `kolab_cache_configuration` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT 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, CONSTRAINT `fk_kolab_cache_configuration_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `configuration_type` (`folder_id`,`type`), INDEX `configuration_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_freebusy`; CREATE TABLE `kolab_cache_freebusy` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT 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_freebusy_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), 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_event`; + +CREATE TABLE `kolab_cache_dav_event` ( + `folder_id` BIGINT UNSIGNED NOT NULL, + `uid` VARCHAR(512) NOT 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/lib/kolab_dav_client.php b/plugins/libkolab/lib/kolab_dav_client.php new file mode 100644 index 00000000..dba20edc --- /dev/null +++ b/plugins/libkolab/lib/kolab_dav_client.php @@ -0,0 +1,450 @@ + + * + * 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 $rc; + protected $responseHeaders = []; + + /** + * Object constructor + */ + public function __construct($url) + { + $this->url = $url; + $this->rc = rcube::get_instance(); + } + + /** + * 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->rc->user->get_username(), $this->rc->decrypt($_SESSION['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') + { +/* + $path = parse_url($this->url, PHP_URL_PATH); + + $body = '' + . '' + . '' + . '' + . '' + . ''; + + $response = $this->request('/calendars', 'PROPFIND', $body); + + $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)); + } + + $body = '' + . '' + . '' + . '' + . '' + . ''; + + $response = $this->request($principal_href, 'PROPFIND', $body); +*/ + $roots = [ + 'VEVENT' => 'calendars', + 'VTODO' => 'calendars', + 'VCARD' => 'addressbooks', + ]; + + $principal_href = '/' . $roots[$component] . '/' . $this->rc->user->get_username(); + + $body = '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + + $response = $this->request($principal_href, 'PROPFIND', $body); + + if (empty($response)) { + return false; + } + + $folders = []; + + foreach ($response->getElementsByTagName('response') as $element) { + $folder = $this->getFolderPropertiesFromResponse($element); + if ($folder['type'] === $component) { + $folders[] = $folder; + } + } + + return $folders; + } + + /** + * Create DAV object in a folder + */ + public function create($location, $content) + { + $response = $this->request($location, 'PUT', $content, ['Content-Type' => 'text/calendar; charset=utf-8']); + + if ($response !== false) { + $etag = $this->responseHeaders['etag']; + + if (preg_match('|^".*"$|', $etag)) { + $etag = substr($etag, 1, -1); + } + + return $etag; + } + + return false; + } + + /** + * Delete 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') + { + $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; + } + + /** + * Fetch DAV objects data from a folder + */ + public function getData($location, $hrefs = []) + { + if (empty($hrefs)) { + return []; + } + + $body = ''; + foreach ($hrefs as $href) { + $body .= '' . $href . ''; + } + + $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, 'loadXML($body)) { + throw new Exception("Failed to parse XML"); + } + + $doc->formatOutput = true; + + $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; + } + } + + return [ + 'href' => $href, + 'name' => $name, + 'ctag' => $ctag, + 'color' => $color, + 'type' => $component, + ]; + } + + /** + * 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; + } + + 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 + 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_cache.php b/plugins/libkolab/lib/kolab_storage_cache.php index 18b5e5f2..e9591484 100644 --- a/plugins/libkolab/lib/kolab_storage_cache.php +++ b/plugins/libkolab/lib/kolab_storage_cache.php @@ -1,1458 +1,1458 @@ * * Copyright (C) 2012-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_cache { const DB_DATE_FORMAT = 'Y-m-d H:i:s'; const MAX_RECORDS = 500; protected $db; protected $imap; protected $folder; protected $uid2msg; protected $objects; protected $metadata = array(); protected $folder_id; protected $resource_uri; protected $enabled = true; protected $synched = false; protected $synclock = false; protected $ready = false; protected $cache_table; protected $folders_table; protected $max_sql_packet; protected $max_sync_lock_time = 600; protected $extra_cols = array(); protected $data_props = array(); protected $order_by = null; protected $limit = null; protected $error = 0; protected $server_timezone; /** * Factory constructor */ public static function factory(kolab_storage_folder $storage_folder) { $subclass = 'kolab_storage_cache_' . $storage_folder->type; if (class_exists($subclass)) { return new $subclass($storage_folder); } else { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "No kolab_storage_cache class found for folder '$storage_folder->name' of type '$storage_folder->type'" ), true); return new kolab_storage_cache($storage_folder); } } /** * Default constructor */ public function __construct(kolab_storage_folder $storage_folder = null) { $rcmail = rcube::get_instance(); $this->db = $rcmail->get_dbh(); $this->imap = $rcmail->get_storage(); $this->enabled = $rcmail->config->get('kolab_cache', false); $this->folders_table = $this->db->table_name('kolab_folders'); $this->server_timezone = new DateTimeZone(date_default_timezone_get()); if ($this->enabled) { // always read folder cache and lock state from DB master $this->db->set_table_dsn('kolab_folders', 'w'); // remove sync-lock on script termination $rcmail->add_shutdown_function(array($this, '_sync_unlock')); } if ($storage_folder) $this->set_folder($storage_folder); } /** * Direct access to cache by folder_id * (only for internal use) */ 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; $this->folder->type = $sql_arr['type']; $this->resource_uri = $sql_arr['resource']; $this->cache_table = $this->db->table_name('kolab_cache_' . $sql_arr['type']); $this->ready = true; } } /** * 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 (empty($this->folder->name) || !$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_' . $this->folder->type); $this->ready = $this->enabled && !empty($this->folder->type); $this->folder_id = null; } /** * Returns true if this cache supports query by type */ public function has_type_col() { return in_array('type', $this->extra_cols); } /** * Getter for the numeric ID used in cache tables */ public function get_folder_id() { $this->_read_folder_data(); return $this->folder_id; } /** * Returns code of last error * * @return int Error code */ public function get_error() { return $this->error; } /** * Synchronize local cache data with remote */ public function synchronize() { // only sync once per request cycle if ($this->synched) return; if (!$this->ready) { // kolab cache is disabled, synchronize IMAP mailbox cache only $this->imap_mode(true); $this->imap->folder_sync($this->folder->name); $this->imap_mode(false); } else { $this->sync_start = time(); // read cached folder metadata $this->_read_folder_data(); // Read folder data from IMAP $ctag = $this->folder->get_ctag(); // Validate current ctag list($uidvalidity, $highestmodseq, $uidnext) = explode('-', $ctag); if (empty($uidvalidity) || empty($highestmodseq)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (Invalid ctag)" ), true); } // check cache status ($this->metadata is set in _read_folder_data()) else if ( empty($this->metadata['ctag']) || empty($this->metadata['changed']) || $this->metadata['ctag'] !== $ctag ) { // lock synchronization for this folder or wait if locked $this->_sync_lock(); // Run a full-sync (initial sync or continue the aborted sync) if (empty($this->metadata['changed']) || empty($this->metadata['ctag'])) { $result = $this->synchronize_full(); } // Synchronize only the changes since last sync else { $result = $this->synchronize_update($ctag); } // 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->check_error(); $this->synched = time(); } /** * Perform full cache synchronization */ protected function synchronize_full() { // 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; } // disable messages cache if configured to do so $this->imap_mode(true); // synchronize IMAP mailbox cache, does nothing if messages cache is disabled $this->imap->folder_sync($this->folder->name); // compare IMAP index with object cache index $imap_index = $this->imap->index($this->folder->name, null, null, true, true); $this->imap_mode(false); if ($imap_index->is_error()) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (SEARCH failed)" ), true); return false; } // determine objects to fetch or to invalidate $imap_index = $imap_index->get(); $del_index = array(); $old_index = $this->current_index($del_index); // Fetch objects and store in DB $result = $this->synchronize_fetch($imap_index, $old_index, $del_index); if ($result) { // Remove redundant entries from IMAP and cache $rem_index = array_intersect($del_index, $imap_index); $del_index = array_merge(array_unique($del_index), array_diff($old_index, $imap_index)); $this->synchronize_delete($rem_index, $del_index); } return $result; } /** * Perform partial cache synchronization, based on QRESYNC */ protected function synchronize_update() { if (!$this->imap->get_capability('QRESYNC')) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (no QRESYNC capability)" ), true); return $this->synchronize_full(); } // Handle the previous ctag list($uidvalidity, $highestmodseq, $uidnext) = explode('-', $this->metadata['ctag']); if (empty($uidvalidity) || empty($highestmodseq)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (Invalid old ctag)" ), true); return false; } // Enable QRESYNC $res = $this->imap->conn->enable('QRESYNC'); if ($res === false) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (failed to enable QRESYNC/CONDSTORE)" ), true); return false; } $mbox_data = $this->imap->folder_data($this->folder->name); if (empty($mbox_data)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (failed to get folder state)" ), true); return false; } // Check UIDVALIDITY if ($uidvalidity != $mbox_data['UIDVALIDITY']) { return $this->synchronize_full(); } // QRESYNC not supported on specified mailbox if (!empty($mbox_data['NOMODSEQ']) || empty($mbox_data['HIGHESTMODSEQ'])) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (QRESYNC not supported on the folder)" ), true); return $this->synchronize_full(); } // Get modified flags and vanished messages // UID FETCH 1:* (FLAGS) (CHANGEDSINCE 0123456789 VANISHED) $result = $this->imap->conn->fetch( $this->folder->name, '1:*', true, array('FLAGS'), $highestmodseq, true ); $removed = array(); $modified = array(); $existing = $this->current_index($removed); if (!empty($result)) { foreach ($result as $msg) { $uid = $msg->uid; // Message marked as deleted if (!empty($msg->flags['DELETED'])) { $removed[] = $uid; continue; } // Flags changed or new $modified[] = $uid; } } $new = array_diff($modified, $existing, $removed); $result = true; if (!empty($new)) { $result = $this->synchronize_fetch($new, $existing, $removed); if (!$result) { return false; } } // VANISHED found? $mbox_data = $this->imap->folder_data($this->folder->name); // Removed vanished messages from the database $vanished = (array) rcube_imap_generic::uncompressMessageSet($mbox_data['VANISHED']); // Remove redundant entries from IMAP and DB $vanished = array_merge($removed, array_intersect($vanished, $existing)); $this->synchronize_delete($removed, $vanished); return $result; } /** * Fetch objects from IMAP and save into the database */ protected function synchronize_fetch($new_index, &$old_index, &$del_index) { // 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; } $i = 0; $aborted = false; // fetch new objects from imap foreach (array_diff($new_index, $old_index) as $msguid) { // Note: We'll store only objects matching the folder type // anything else will be silently ignored if ($object = $this->folder->read_object($msguid)) { // Deduplication: remove older objects with the same UID // Here we do not resolve conflicts, we just make sure // the most recent version of the object will be used if ($old_msguid = $old_index[$object['uid']]) { if ($old_msguid < $msguid) { $del_index[] = $old_msguid; } else { $del_index[] = $msguid; continue; } } $old_index[$object['uid']] = $msguid; $this->_extended_insert($msguid, $object); // check time limit and abort sync if running too long if (++$i % 50 == 0 && time() - $this->sync_start > $time_limit) { $aborted = true; break; } } } $this->_extended_insert(0, null); return $aborted === false; } /** * Remove specified objects from the database and IMAP */ protected function synchronize_delete($imap_delete, $db_delete) { if (!empty($imap_delete)) { $this->imap_mode(true); $this->imap->delete_message($imap_delete, $this->folder->name); $this->imap_mode(false); } if (!empty($db_delete)) { $quoted_ids = join(',', array_map(array($this->db, 'quote'), $db_delete)); $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `msguid` IN ($quoted_ids)", $this->folder_id ); } } /** * Return current use->msguid index */ protected function current_index(&$duplicates = array()) { // read cache index $sql_result = $this->db->query( "SELECT `msguid`, `uid` FROM `{$this->cache_table}` WHERE `folder_id` = ?" . " ORDER BY `msguid` DESC", $this->folder_id ); $index = $del_index = array(); while ($sql_arr = $this->db->fetch_assoc($sql_result)) { // Mark all duplicates for removal (note sorting order above) // Duplicates here should not happen, but they do sometimes if (isset($index[$sql_arr['uid']])) { $duplicates[] = $sql_arr['msguid']; } else { $index[$sql_arr['uid']] = $sql_arr['msguid']; } } return $index; } /** * Read a single entry from cache or from IMAP directly * * @param string Related IMAP message UID * @param string Object type to read * @param string IMAP folder name the entry relates to * @param array Hash array with object properties or null if not found */ public function get($msguid, $type = null, $foldername = null) { // delegate to another cache instance if ($foldername && $foldername != $this->folder->name) { $success = false; if ($targetfolder = kolab_storage::get_folder($foldername)) { $success = $targetfolder->cache->get($msguid, $type); $this->error = $targetfolder->cache->get_error(); } return $success; } // load object if not in memory if (!isset($this->objects[$msguid])) { if ($this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT * FROM `{$this->cache_table}` ". "WHERE `folder_id` = ? AND `msguid` = ?", $this->folder_id, $msguid ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $this->objects = array($msguid => $this->_unserialize($sql_arr)); // store only this object in memory (#2827) } } // fetch from IMAP if not present in cache if (empty($this->objects[$msguid])) { if ($object = $this->folder->read_object($msguid, $type ?: '*', $foldername)) { $this->objects = array($msguid => $object); $this->set($msguid, $object); } } } $this->check_error(); return $this->objects[$msguid]; } /** * Getter for a single Kolab object identified by its UID * * @param string $uid Object UID * * @return array The Kolab object represented as hash array */ public function get_by_uid($uid) { $old_order_by = $this->order_by; $old_limit = $this->limit; // set order to make sure we get most recent object version // set limit to skip count query $this->order_by = '`msguid` DESC'; $this->limit = array(1, 0); $list = $this->select(array(array('uid', '=', $uid))); // set the order/limit back to defined value $this->order_by = $old_order_by; $this->limit = $old_limit; if (!empty($list) && !empty($list[0])) { return $list[0]; } } /** * Insert/Update a cache entry * * @param string Related IMAP message UID * @param mixed Hash array with object properties to save or false to delete the cache entry * @param string IMAP folder name the entry relates to */ public function set($msguid, $object, $foldername = null) { if (!$msguid) { return; } // delegate to another cache instance if ($foldername && $foldername != $this->folder->name) { if ($targetfolder = kolab_storage::get_folder($foldername)) { $targetfolder->cache->set($msguid, $object); $this->error = $targetfolder->cache->get_error(); } return; } // remove old entry if ($this->ready) { $this->_read_folder_data(); $this->db->query("DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `msguid` = ?", $this->folder_id, $msguid); } if ($object) { // insert new object data... $this->save($msguid, $object); } else { // ...or set in-memory cache to false $this->objects[$msguid] = $object; } $this->check_error(); } /** * Insert (or update) a cache entry * * @param int Related IMAP message UID * @param mixed Hash array with object properties to save or false to delete the cache entry * @param int Optional old message UID (for update) */ public function save($msguid, $object, $olduid = 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['msguid'] = $msguid; $sql_data['uid'] = $object['uid']; $args = array(); $cols = array('folder_id', 'msguid', 'uid', '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 `msguid` = ?"; $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(array( 'code' => 900, 'type' => 'php', 'message' => "Failed to write to kolab cache" ), true); } } // keep a copy in memory for fast access $this->objects = array($msguid => $object); $this->uid2msg = array($object['uid'] => $msguid); $this->check_error(); } /** * Move an existing cache entry to a new resource * * @param string Entry's IMAP message UID * @param string Entry's Object UID * @param kolab_storage_folder Target storage folder instance * @param string Target entry's IMAP message UID */ public function move($msguid, $uid, $target, $new_msguid = null) { if ($this->ready && $target) { // clear cached uid mapping and force new lookup unset($target->cache->uid2msg[$uid]); // resolve new message UID in target folder if (!$new_msguid) { $new_msguid = $target->cache->uid2msguid($uid); } if ($new_msguid) { $this->_read_folder_data(); $this->db->query( "UPDATE `{$this->cache_table}` SET `folder_id` = ?, `msguid` = ? ". "WHERE `folder_id` = ? AND `msguid` = ?", $target->cache->get_folder_id(), $new_msguid, $this->folder_id, $msguid ); $result = $this->db->affected_rows(); } } if (empty($result)) { // just clear cache entry $this->set($msguid, false); } unset($this->uid2msg[$uid]); $this->check_error(); } /** * Remove all objects from local cache */ public function purge() { if (!$this->ready) { return true; } $this->_read_folder_data(); $result = $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ?", $this->folder_id ); return $this->db->affected_rows($result); } /** * Update resource URI for existing cache entries * * @param string Target IMAP folder to move it to */ public function rename($new_folder) { if (!$this->ready) { return; } if ($target = kolab_storage::get_folder($new_folder)) { // resolve new message UID in target folder $this->db->query( "UPDATE `{$this->folders_table}` SET `resource` = ? ". "WHERE `resource` = ?", $target->get_resource_uri(), $this->resource_uri ); $this->check_error(); } else { $this->error = kolab_storage::ERROR_IMAP_CONN; } } /** * Select Kolab objects filtered by the given query * * @param array Pseudo-SQL query as list of filter parameter triplets * triplet: array('', '', '') * @param boolean Set true to only return UIDs instead of complete objects * @param boolean Use fast mode to fetch only minimal set of information * (no xml fetching and parsing, etc.) * * @return array List of Kolab data objects (each represented as hash array) or UIDs */ public function select($query = array(), $uids = false, $fast = false) { $result = $uids ? array() : new kolab_storage_dataset($this); // read from local cache DB (assume it to be synchronized) if ($this->ready) { $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 ? '*' : "`msguid` AS `_msguid`, `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) { $this->uid2msg[$sql_arr['uid']] = $sql_arr['_msguid']; $result[] = $sql_arr['uid']; } else if ($fetchall && ($object = $this->_unserialize($sql_arr))) { $result[] = $object; } else if (!$fetchall) { // only add msguid to dataset index $result[] = $sql_arr; } } } // use IMAP else { $filter = $this->_query2assoc($query); $this->imap_mode(true); if ($filter['type']) { $search = 'UNDELETED HEADER X-Kolab-Type ' . kolab_format::KTYPE_PREFIX . $filter['type']; $index = $this->imap->search_once($this->folder->name, $search); } else { $index = $this->imap->index($this->folder->name, null, null, true, true); } $this->imap_mode(false); if ($index->is_error()) { $this->check_error(); if ($uids) { return null; } $result->set_error(true); return $result; } $index = $index->get(); $result = $uids ? $index : $this->_fetch($index, $filter['type']); // TODO: post-filter result according to query } // We don't want to cache big results in-memory, however // if we select only one object here, there's a big chance we will need it later if (!$uids && count($result) == 1) { if ($msguid = $result[0]['_msguid']) { $this->uid2msg[$result[0]['uid']] = $msguid; $this->objects = array($msguid => $result[0]); } } $this->check_error(); return $result; } /** * Get number of objects mathing the given query * * @param array $query Pseudo-SQL query as list of filter parameter triplets * @return integer The number of objects of the given type */ public function count($query = array()) { // read from local cache DB (assume it to be synchronized) if ($this->ready) { $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']); } // use IMAP else { $filter = $this->_query2assoc($query); $this->imap_mode(true); if ($filter['type']) { $search = 'UNDELETED HEADER X-Kolab-Type ' . kolab_format::KTYPE_PREFIX . $filter['type']; $index = $this->imap->search_once($this->folder->name, $search); } else { $index = $this->imap->index($this->folder->name, null, null, true, true); } $this->imap_mode(false); if ($index->is_error()) { $this->check_error(); return null; } // TODO: post-filter result according to query $count = $index->count(); } $this->check_error(); return $count; } /** * Define ORDER BY clause for cache queries */ public function set_order_by($sortcols) { if (!empty($sortcols)) { $sortcols = array_map(function($v) { list($column, $order) = explode(' ', $v, 2); return "`$column`" . ($order ? " $order" : ''); }, (array) $sortcols); $this->order_by = join(', ', $sortcols); } else { $this->order_by = null; } } /** * Define LIMIT clause for cache queries */ public function set_limit($length, $offset = 0) { $this->limit = array($length, $offset); } /** * Helper method to compose a valid SQL query from pseudo filter triplets */ protected function _sql_where($query) { $sql_where = ''; foreach ((array) $query as $param) { if (is_array($param[0])) { $subq = array(); foreach ($param[0] as $q) { $subq[] = preg_replace('/^\s*AND\s+/i', '', $this->_sql_where(array($q))); } if (!empty($subq)) { $sql_where .= ' AND (' . implode($param[1] == 'OR' ? ' OR ' : ' AND ', $subq) . ')'; } continue; } else if ($param[1] == '=' && is_array($param[2])) { $qvalue = '(' . join(',', array_map(array($this->db, 'quote'), $param[2])) . ')'; $param[1] = 'IN'; } else if ($param[1] == '~' || $param[1] == 'LIKE' || $param[1] == '!~' || $param[1] == '!LIKE') { $not = ($param[1] == '!~' || $param[1] == '!LIKE') ? 'NOT ' : ''; $param[1] = $not . 'LIKE'; $qvalue = $this->db->quote('%'.preg_replace('/(^\^|\$$)/', ' ', $param[2]).'%'); } else if ($param[1] == '~*' || $param[1] == '!~*') { $not = $param[1][1] == '!' ? 'NOT ' : ''; $param[1] = $not . 'LIKE'; $qvalue = $this->db->quote(preg_replace('/(^\^|\$$)/', ' ', $param[2]).'%'); } else if ($param[0] == 'tags') { $param[1] = ($param[1] == '!=' ? 'NOT ' : '' ) . 'LIKE'; $qvalue = $this->db->quote('% '.$param[2].' %'); } else { $qvalue = $this->db->quote($param[2]); } $sql_where .= sprintf(' AND %s %s %s', $this->db->quote_identifier($param[0]), $param[1], $qvalue ); } return $sql_where; } /** * Helper method to convert the given pseudo-query triplets into * an associative filter array with 'equals' values only */ protected function _query2assoc($query) { // extract object type from query parameter $filter = array(); foreach ($query as $param) { if ($param[1] == '=') $filter[$param[0]] = $param[2]; } return $filter; } /** * Fetch messages from IMAP * * @param array List of message UIDs to fetch * @param string Requested object type or * for all * @param string IMAP folder to read from * @return array List of parsed Kolab objects */ protected function _fetch($index, $type = null, $folder = null) { $results = new kolab_storage_dataset($this); foreach ((array)$index as $msguid) { if ($object = $this->folder->read_object($msguid, $type, $folder)) { $results[] = $object; $this->set($msguid, $object); } } return $results; } /** * Helper method to convert the given Kolab object into a dataset to be written to cache */ protected function _serialize($object) { $data = array(); $sql_data = array('changed' => null, 'tags' => '', 'words' => ''); if ($object['changed']) { $sql_data['changed'] = date(self::DB_DATE_FORMAT, is_object($object['changed']) ? $object['changed']->format('U') : $object['changed']); } if ($object['_formatobj']) { $xml = (string) $object['_formatobj']->write(3.0); $data['_size'] = strlen($xml); $sql_data['tags'] = ' ' . join(' ', $object['_formatobj']->get_tags()) . ' '; // pad with spaces for strict/prefix search $sql_data['words'] = ' ' . join(' ', $object['_formatobj']->get_words()) . ' '; } // Store only minimal set of object properties foreach ($this->data_props as $prop) { if (isset($object[$prop])) { $data[$prop] = $object[$prop]; if ($data[$prop] instanceof DateTime) { $data[$prop] = array( 'cl' => 'DateTime', 'dt' => $data[$prop]->format('Y-m-d H:i:s'), 'tz' => $data[$prop]->getTimezone()->getName(), ); } } } $sql_data['data'] = json_encode(rcube_charset::clean($data)); return $sql_data; } /** * 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))) { $object['uid'] = $sql_arr['uid']; 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['_msguid'] = $sql_arr['msguid']; $object['_mailbox'] = $this->folder->name; } // Fetch object xml else { // FIXME: Because old cache solution allowed storing objects that // do not match folder type we may end up with invalid objects. // 2nd argument of read_object() here makes sure they are still // usable. However, not allowing them here might be also an intended // solution in future. $object = $this->folder->read_object($sql_arr['msguid'], '*'); } return $object; } /** * Write records into cache using extended inserts to reduce the number of queries to be executed * * @param int Message UID. Set 0 to commit buffered inserts * @param array Kolab object to cache */ protected function _extended_insert($msguid, $object) { static $buffer = ''; $line = ''; $cols = array('folder_id', 'msguid', 'uid', '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 = array(); $params = array($this->folder_id, $msguid, $object['uid'], $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($msguid), $this->db->quote($object['uid']), $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 && (!$msguid || (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; } /** * Returns max_allowed_packet from mysql config */ protected function max_sql_packet() { if (!$this->max_sql_packet) { // mysql limit or max 4 MB $value = $this->db->get_variable('max_allowed_packet', 1048500); $this->max_sql_packet = min($value, 4*1024*1024) - 2000; } return $this->max_sql_packet; } /** * Read this folder's ID and cache metadata */ protected function _read_folder_data() { // already done if (!empty($this->folder_id) || !$this->ready) return; $sql_arr = $this->db->fetch_assoc($this->db->query( "SELECT `folder_id`, `synclock`, `ctag`, `changed`" . " FROM `{$this->folders_table}` WHERE `resource` = ?", $this->resource_uri )); if ($sql_arr) { $this->metadata = $sql_arr; $this->folder_id = $sql_arr['folder_id']; } else { $this->db->query("INSERT INTO `{$this->folders_table}` (`resource`, `type`)" . " VALUES (?, ?)", $this->resource_uri, $this->folder->type); $this->folder_id = $this->db->insert_id('kolab_folders'); $this->metadata = array(); } } /** * Check lock record for this folder and wait if locked or set lock */ protected function _sync_lock() { if (!$this->ready) return; $this->_read_folder_data(); // abort if database is not set-up if ($this->db->is_error()) { $this->check_error(); $this->ready = false; return; } $read_query = "SELECT `synclock`, `ctag` FROM `{$this->folders_table}` WHERE `folder_id` = ?"; $write_query = "UPDATE `{$this->folders_table}` SET `synclock` = ? WHERE `folder_id` = ? AND `synclock` = ?"; $max_lock_time = $this->_max_sync_lock_time(); // wait if locked (expire locks after 10 minutes) ... // ... or if setting lock fails (another process meanwhile set it) while ( (intval($this->metadata['synclock']) + $max_lock_time > time()) || (($res = $this->db->query($write_query, time(), $this->folder_id, intval($this->metadata['synclock']))) && !($affected = $this->db->affected_rows($res))) ) { usleep(500000); $this->metadata = $this->db->fetch_assoc($this->db->query($read_query, $this->folder_id)); } $this->synclock = $affected > 0; } /** * Remove lock for this folder */ public function _sync_unlock() { if (!$this->ready || !$this->synclock) return; $this->db->query( "UPDATE `{$this->folders_table}` SET `synclock` = 0, `ctag` = ?, `changed` = ? WHERE `folder_id` = ?", $this->metadata['ctag'], $this->metadata['changed'], $this->folder_id ); $this->synclock = false; } protected function _max_sync_lock_time() { $limit = get_offset_sec(ini_get('max_execution_time')); if ($limit <= 0 || $limit > $this->max_sync_lock_time) { $limit = $this->max_sync_lock_time; } return $limit; } /** * Check IMAP connection error state */ protected function check_error() { if (($err_code = $this->imap->get_error_code()) < 0) { $this->error = kolab_storage::ERROR_IMAP_CONN; if (($res_code = $this->imap->get_response_code()) !== 0 && in_array($res_code, array(rcube_storage::NOPERM, rcube_storage::READONLY))) { $this->error = kolab_storage::ERROR_NO_PERMISSION; } } else if ($this->db->is_error()) { $this->error = kolab_storage::ERROR_CACHE_DB; } } /** * Resolve an object UID into an IMAP message UID * * @param string Kolab object UID * @param boolean Include deleted objects * @return int The resolved IMAP message UID */ public function uid2msguid($uid, $deleted = false) { // query local database if available if (!isset($this->uid2msg[$uid]) && $this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT `msguid` FROM `{$this->cache_table}` ". "WHERE `folder_id` = ? AND `uid` = ? ORDER BY `msguid` DESC", $this->folder_id, $uid ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $this->uid2msg[$uid] = $sql_arr['msguid']; } } if (!isset($this->uid2msg[$uid])) { // use IMAP SEARCH to get the right message $index = $this->imap->search_once($this->folder->name, ($deleted ? '' : 'UNDELETED ') . 'HEADER SUBJECT ' . rcube_imap_generic::escape($uid)); $results = $index->get(); $this->uid2msg[$uid] = end($results); } return $this->uid2msg[$uid]; } /** * Getter for protected member variables */ public function __get($name) { if ($name == 'folder_id') { $this->_read_folder_data(); } return $this->$name; } /** * Set Roundcube storage options and bypass messages/indexes cache. * * We use skip_deleted and threading settings specific to Kolab, * we have to change these global settings only temporarily. * Roundcube cache duplicates information already stored in kolab_cache, * that's why we can disable it for better performance. * * @param bool $force True to start Kolab mode, False to stop it. */ public function imap_mode($force = false) { // remember current IMAP settings if ($force) { $this->imap_options = array( 'skip_deleted' => $this->imap->get_option('skip_deleted'), 'threading' => $this->imap->get_threading(), ); } // re-set IMAP settings $this->imap->set_threading($force ? false : $this->imap_options['threading']); $this->imap->set_options(array( 'skip_deleted' => $force ? true : $this->imap_options['skip_deleted'], )); // if kolab cache is disabled do nothing if (!$this->enabled) { return; } static $messages_cache, $cache_bypass; if ($messages_cache === null) { $rcmail = rcube::get_instance(); $messages_cache = (bool) $rcmail->config->get('messages_cache'); $cache_bypass = (int) $rcmail->config->get('kolab_messages_cache_bypass'); } if ($messages_cache) { // handle recurrent (multilevel) bypass() calls if ($force) { $this->cache_bypassed += 1; if ($this->cache_bypassed > 1) { return; } } else { $this->cache_bypassed -= 1; if ($this->cache_bypassed > 0) { return; } } switch ($cache_bypass) { case 2: // Disable messages and index cache completely $this->imap->set_messages_caching(!$force); break; case 3: case 1: // We'll disable messages cache, but keep index cache (1) or vice-versa (3) // Default mode is both (MODE_INDEX | MODE_MESSAGE) $mode = $cache_bypass == 3 ? rcube_imap_cache::MODE_MESSAGE : rcube_imap_cache::MODE_INDEX; if (!$force) { $mode |= $cache_bypass == 3 ? rcube_imap_cache::MODE_INDEX : rcube_imap_cache::MODE_MESSAGE; } $this->imap->set_messages_caching(true, $mode); } } } /** * Converts DateTime or unix timestamp into sql date format * using server timezone. */ protected function _convert_datetime($datetime) { if (is_object($datetime)) { $dt = clone $datetime; $dt->setTimeZone($this->server_timezone); return $dt->format(self::DB_DATE_FORMAT); } else if ($datetime) { return date(self::DB_DATE_FORMAT, $datetime); } } } diff --git a/plugins/libkolab/lib/kolab_storage_dataset.php b/plugins/libkolab/lib/kolab_storage_dataset.php index 9f39b12e..771e321f 100644 --- a/plugins/libkolab/lib/kolab_storage_dataset.php +++ b/plugins/libkolab/lib/kolab_storage_dataset.php @@ -1,153 +1,152 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_dataset implements Iterator, ArrayAccess, Countable { private $cache; // kolab_storage_cache instance to use for fetching data private $memlimit = 0; private $buffer = false; private $index = array(); private $data = array(); private $iteratorkey = 0; private $error = null; /** * Default constructor * * @param object kolab_storage_cache instance to be used for fetching objects upon access */ public function __construct($cache) { $this->cache = $cache; // enable in-memory buffering up until 1/5 of the available memory if (function_exists('memory_get_usage')) { $this->memlimit = parse_bytes(ini_get('memory_limit')) / 5; $this->buffer = true; } } /** * Return error state */ public function is_error() { return !empty($this->error); } /** * Set error state */ public function set_error($err) { $this->error = $err; } /*** Implement PHP Countable interface ***/ public function count() { return count($this->index); } /*** Implement PHP ArrayAccess interface ***/ 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 if ($offset % 10 == 0) { $this->buffer = memory_get_usage() < $this->memlimit; } } } public function offsetExists($offset) { return isset($this->index[$offset]); } public function offsetUnset($offset) { unset($this->index[$offset]); } public function offsetGet($offset) { 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; } /*** Implement PHP Iterator interface ***/ public function current() { return $this->offsetGet($this->iteratorkey); } public function key() { return $this->iteratorkey; } public function next() { $this->iteratorkey++; return $this->valid(); } public function rewind() { $this->iteratorkey = 0; } public function valid() { return !empty($this->index[$this->iteratorkey]); } } diff --git a/plugins/libkolab/lib/kolab_storage_dav.php b/plugins/libkolab/lib/kolab_storage_dav.php new file mode 100644 index 00000000..f4480cf9 --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav.php @@ -0,0 +1,476 @@ + + * + * 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) + { + // TODO: This should be cached + $folders = $this->dav->discover(); + + if (is_array($folders)) { + foreach ($folders as $idx => $folder) { + $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 Folder to access (UTF7-IMAP) + * @param string Expected folder type + * + * @return object kolab_storage_folder The folder object + */ + public function get_folder($folder, $type = null) + { + // TODO + } + + /** + * 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 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 index 00000000..7126516f --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php @@ -0,0 +1,622 @@ + + * @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->current_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, $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, $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 current_index() + { + // read cache index + $sql_result = $this->db->query( + "SELECT `uid`, `data` FROM `{$this->cache_table}` WHERE `folder_id` = ?", + $this->folder_id + ); + + $index = []; + + // TODO: Store etag as a separate column + + while ($sql_arr = $this->db->fetch_assoc($sql_result)) { + if ($object = json_decode($sql_arr['data'], true)) { + $index[$sql_arr['uid']] = $object['etag']; + } + } + + return $index; + } + + /** + * Read a single entry from cache or from server directly + * + * @param string Object UID + * @param string Object type to read + */ + public function get($uid, $type = 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 + */ + public function set($uid, $object) + { + // 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) + */ + public function save($object, $olduid = 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'] = $object['uid']; + + $args = []; + $cols = ['folder_id', 'uid', '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 + */ + public function move($uid, $target) + { + // 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', '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, $object['uid'], $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($object['uid']), + $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))) { + $object['uid'] = $sql_arr['uid']; + + 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; + } + // 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_event.php b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php new file mode 100644 index 00000000..91a57952 --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav_cache_event.php @@ -0,0 +1,68 @@ + + * + * 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 = array('dtstart','dtend'); + protected $data_props = array('categories', 'status', 'attendees', 'etag'); + + /** + * 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; + } + } + } + } + + return $sql_data; + } +} diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php new file mode 100644 index 00000000..7bee428a --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -0,0 +1,529 @@ + + * + * 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']; + + // Here we assume the last element of the folder path is the folder ID + // if that's not the case, we should consider generating an ID + $href = explode('/', unslashify($this->href)); + $this->id = $href[count($href) - 1]; + $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']; + } + + /** + * 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}"; + + $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)) { + $result = $this->dav->create($this->object_location($object['uid']), $content); + + if ($result !== false) { + // insert/update object in the cache + $object['etag'] = $result; + $this->cache->save($object, $uid); + } + } + + 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) + * + * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found + */ + public function read_object($uid, $type = null) + { + if (!$this->valid) { + return false; + } + + $href = $this->object_location($uid); + $objects = $this->dav->getData($this->href, [$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]; + } + + $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(); + // TODO: Attachments? + $result = $ical->export([$object]); + } + + 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 index 0cc6fbad..194289de 100644 --- a/plugins/libkolab/lib/kolab_storage_folder.php +++ b/plugins/libkolab/lib/kolab_storage_folder.php @@ -1,1167 +1,1168 @@ * @author Aleksander Machniak * * Copyright (C) 2012-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_folder extends kolab_storage_folder_api { /** * The kolab_storage_cache instance for caching operations * @var object */ public $cache; /** * Indicate validity status * @var boolean */ public $valid = false; protected $error = 0; protected $resource_uri; /** * Default constructor * * @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) { parent::__construct($name); $this->set_folder($name, $type, $type_annotation); } /** * Set the IMAP folder this instance connects to * * @param string The folder name/path * @param string Expected folder type * @param string Optional folder type if known */ public function set_folder($name, $type = null, $type_annotation = null) { $this->name = $name; if (empty($type_annotation)) { $type_annotation = $this->get_type(); } $oldtype = $this->type; list($this->type, $suffix) = explode('.', $type_annotation); $this->default = $suffix == 'default'; $this->subtype = $this->default ? '' : $suffix; $this->id = kolab_storage::folder_id($name); $this->valid = !empty($this->type) && $this->type != 'mail' && (!$type || $this->type == $type); if (!$this->valid) { $this->error = $this->imap->get_error_code() < 0 ? kolab_storage::ERROR_IMAP_CONN : kolab_storage::ERROR_INVALID_FOLDER; } // reset cached object properties $this->owner = $this->namespace = $this->resource_uri = $this->info = $this->idata = null; // get a new cache instance if folder type changed if (!$this->cache || $this->type != $oldtype) $this->cache = kolab_storage_cache::factory($this); else $this->cache->set_folder($this); $this->imap->set_folder($this->name); } /** * Returns code of last error * * @return int Error code */ public function get_error() { return $this->error ?: $this->cache->get_error(); } /** * Check IMAP connection error state */ public function check_error() { if (($err_code = $this->imap->get_error_code()) < 0) { $this->error = kolab_storage::ERROR_IMAP_CONN; if (($res_code = $this->imap->get_response_code()) !== 0 && in_array($res_code, array(rcube_storage::NOPERM, rcube_storage::READONLY))) { $this->error = kolab_storage::ERROR_NO_PERMISSION; } } return $this->error; } /** * Compose a unique resource URI for this IMAP folder */ public function get_resource_uri() { if (!empty($this->resource_uri)) { return $this->resource_uri; } // strip namespace prefix from folder name $ns = $this->get_namespace(); $nsdata = $this->imap->get_namespace($ns); if (is_array($nsdata[0]) && strlen($nsdata[0][0]) && strpos($this->name, $nsdata[0][0]) === 0) { $subpath = substr($this->name, strlen($nsdata[0][0])); if ($ns == 'other') { list($user, $suffix) = explode($nsdata[0][1], $subpath, 2); $subpath = $suffix; } } else { $subpath = $this->name; } // compose fully qualified ressource uri for this instance $this->resource_uri = 'imap://' . urlencode($this->get_owner(true)) . '@' . $this->imap->options['host'] . '/' . $subpath; return $this->resource_uri; } /** * Helper method to extract folder UID metadata * * @return string Folder's UID */ public function get_uid() { // UID is defined in folder METADATA $metakeys = array(kolab_storage::UID_KEY_SHARED, kolab_storage::UID_KEY_CYRUS); $metadata = $this->get_metadata(); if ($metadata !== null) { foreach ($metakeys as $key) { if ($uid = $metadata[$key]) { return $uid; } } // generate a folder UID and set it to IMAP $uid = rtrim(chunk_split(md5($this->name . $this->get_owner() . uniqid('-', true)), 12, '-'), '-'); if ($this->set_uid($uid)) { return $uid; } } $this->check_error(); // create hash from folder name if we can't write the UID metadata return md5($this->name . $this->get_owner()); } /** * Helper method to set an UID value to the given IMAP folder instance * * @param string Folder's UID * @return boolean True on succes, False on failure */ public function set_uid($uid) { $success = $this->set_metadata(array(kolab_storage::UID_KEY_SHARED => $uid)); $this->check_error(); return $success; } /** * Compose a folder Etag identifier */ public function get_ctag() { $fdata = $this->get_imap_data(); $this->check_error(); return sprintf('%d-%d-%d', $fdata['UIDVALIDITY'], $fdata['HIGHESTMODSEQ'], $fdata['UIDNEXT']); } /** * Check activation status of this folder * * @return boolean True if enabled, false if not */ public function is_active() { return kolab_storage::folder_is_active($this->name); } /** * Change activation status of this folder * * @param boolean The desired subscription status: true = active, false = not active * * @return True on success, false on error */ public function activate($active) { return $active ? kolab_storage::folder_activate($this->name) : kolab_storage::folder_deactivate($this->name); } /** * Check subscription status of this folder * * @return boolean True if subscribed, false if not */ public function is_subscribed() { return kolab_storage::folder_is_subscribed($this->name); } /** * Change subscription status of this folder * * @param boolean The desired subscription status: true = subscribed, false = not subscribed * * @return True on success, false on error */ public function subscribe($subscribed) { return $subscribed ? kolab_storage::folder_subscribe($this->name) : kolab_storage::folder_unsubscribe($this->name); } /** * Get number of objects stored in this folder * * @param mixed Pseudo-SQL query as list of filter parameter triplets * or string with object type (e.g. contact, event, todo, journal, note, configuration) * * @return integer The number of objects of the given type * @see self::select() */ public function count($query = null) { if (!$this->valid) { return 0; } // synchronize cache first $this->cache->synchronize(); return $this->cache->count($this->_prepare_query($query)); } /** * List Kolab objects matching the given query * * @param mixed Pseudo-SQL query as list of filter parameter triplets * or string with object type (e.g. contact, event, todo, journal, note, configuration) * * @return array List of Kolab data objects (each represented as hash array) * @deprecated Use select() */ public function get_objects($query = array()) { return $this->select($query); } /** * Select Kolab objects matching the given query * * @param mixed Pseudo-SQL query as list of filter parameter triplets * or string with object type (e.g. contact, event, todo, journal, note, configuration) * @param boolean Use fast mode to fetch only minimal set of information * (no xml fetching and parsing, etc.) * * @return array List of Kolab data objects (each represented as hash array) */ public function select($query = array(), $fast = false) { if (!$this->valid) { return array(); } // synchronize caches $this->cache->synchronize(); // fetch objects from cache return $this->cache->select($this->_prepare_query($query), false, $fast); } /** * Getter for object UIDs only * * @param array Pseudo-SQL query as list of filter parameter triplets * @return array List of Kolab object UIDs */ public function get_uids($query = array()) { if (!$this->valid) { return array(); } // synchronize caches $this->cache->synchronize(); // fetch UIDs from cache return $this->cache->select($this->_prepare_query($query), true); } /** * Setter for ORDER BY and LIMIT parameters for cache queries * * @param array List of columns to order by * @param integer Limit result set to this length * @param integer Offset row */ public function set_order_and_limit($sortcols, $length = null, $offset = 0) { $this->cache->set_order_by($sortcols); if ($length !== null) { $this->cache->set_limit($length, $offset); } } /** * Helper method to sanitize query arguments */ private function _prepare_query($query) { // string equals type query // FIXME: should not be called this way! if (is_string($query)) { return $this->cache->has_type_col() && !empty($query) ? array(array('type','=',$query)) : array(); } foreach ((array)$query as $i => $param) { if ($param[0] == 'type' && !$this->cache->has_type_col()) { unset($query[$i]); } else if (($param[0] == 'dtstart' || $param[0] == 'dtend' || $param[0] == 'changed')) { if (is_object($param[2]) && is_a($param[2], 'DateTime')) $param[2] = $param[2]->format('U'); if (is_numeric($param[2])) $query[$i][2] = date('Y-m-d H:i:s', $param[2]); } } return $query; } /** * Getter for a single Kolab object identified by its UID * * @param string $uid Object UID * * @return array The Kolab object represented as hash array */ public function get_object($uid) { if (!$this->valid || !$uid) { return false; } // synchronize caches $this->cache->synchronize(); return $this->cache->get_by_uid($uid); } /** * Fetch a Kolab object attachment which is stored in a separate part * of the mail MIME message that represents the Kolab record. * * @param string Object's UID * @param string The attachment's mime number * @param string IMAP folder where message is stored; * If set, that also implies that the given UID is an IMAP UID * @param bool True to print the part content * @param resource File pointer to save the message part * @param boolean Disables charset conversion * * @return mixed The attachment content as binary string */ public function get_attachment($uid, $part, $mailbox = null, $print = false, $fp = null, $skip_charset_conv = false) { if ($this->valid && ($msguid = ($mailbox ? $uid : $this->cache->uid2msguid($uid)))) { $this->imap->set_folder($mailbox ? $mailbox : $this->name); if (substr($part, 0, 2) == 'i:') { // attachment data is stored in XML if ($object = $this->cache->get($msguid)) { // load data from XML (attachment content is not stored in cache) if ($object['_formatobj'] && isset($object['_size'])) { $object['_attachments'] = array(); $object['_formatobj']->get_attachments($object); } foreach ($object['_attachments'] as $attach) { if ($attach['id'] == $part) { if ($print) echo $attach['content']; else if ($fp) fwrite($fp, $attach['content']); else return $attach['content']; return true; } } } } else { // return message part from IMAP directly // TODO: We could improve performance if we cache part's encoding // without 3rd argument get_message_part() will request BODYSTRUCTURE from IMAP return $this->imap->get_message_part($msguid, $part, null, $print, $fp, $skip_charset_conv); } } return null; } /** * Fetch the mime message from the storage server and extract * the Kolab groupware object from it * * @param string The IMAP message UID to fetch * @param string The object type expected (use wildcard '*' to accept all types) * @param string The folder name where the message is stored * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($msguid, $type = null, $folder = null) { if (!$this->valid) { return false; } if (!$type) $type = $this->type; if (!$folder) $folder = $this->name; $this->imap->set_folder($folder); $this->cache->imap_mode(true); $message = new rcube_message($msguid); $this->cache->imap_mode(false); // Message doesn't exist? if (empty($message->headers)) { return false; } // extract the X-Kolab-Type header from the XML attachment part if missing if (empty($message->headers->others['x-kolab-type'])) { foreach ((array)$message->attachments as $part) { if (strpos($part->mimetype, kolab_format::KTYPE_PREFIX) === 0) { $message->headers->others['x-kolab-type'] = $part->mimetype; break; } } } // fix buggy messages stating the X-Kolab-Type header twice else if (is_array($message->headers->others['x-kolab-type'])) { $message->headers->others['x-kolab-type'] = reset($message->headers->others['x-kolab-type']); } // no object type header found: abort if (empty($message->headers->others['x-kolab-type'])) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "No X-Kolab-Type information found in message $msguid ($this->name).", ), true); return false; } $object_type = kolab_format::mime2object_type($message->headers->others['x-kolab-type']); $content_type = kolab_format::KTYPE_PREFIX . $object_type; // check object type header and abort on mismatch if ($type != '*' && strpos($object_type, $type) !== 0 && !($object_type == 'distribution-list' && $type == 'contact')) { return false; } $attachments = array(); // get XML part foreach ((array)$message->attachments as $part) { if (!$xml && ($part->mimetype == $content_type || preg_match('!application/([a-z.]+\+)?xml!i', $part->mimetype))) { $xml = $message->get_part_body($part->mime_id, true); } else if ($part->filename || $part->content_id) { $key = $part->content_id ? trim($part->content_id, '<>') : $part->filename; $size = null; // Use Content-Disposition 'size' as for the Kolab Format spec. if (isset($part->d_parameters['size'])) { $size = $part->d_parameters['size']; } // we can trust part size only if it's not encoded else if ($part->encoding == 'binary' || $part->encoding == '7bit' || $part->encoding == '8bit') { $size = $part->size; } $attachments[$key] = array( 'id' => $part->mime_id, 'name' => $part->filename, 'mimetype' => $part->mimetype, 'size' => $size, ); } } if (!$xml) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not find Kolab data part in message $msguid ($this->name).", ), true); return false; } // check kolab format version $format_version = $message->headers->others['x-kolab-mime-version']; if (empty($format_version)) { list($xmltype, $subtype) = explode('.', $object_type); $xmlhead = substr($xml, 0, 512); // detect old Kolab 2.0 format if (strpos($xmlhead, '<' . $xmltype) !== false && strpos($xmlhead, 'xmlns=') === false) $format_version = '2.0'; else $format_version = '3.0'; // assume 3.0 } // get Kolab format handler for the given type $format = kolab_format::factory($object_type, $format_version); if (is_a($format, 'PEAR_Error')) return false; // load Kolab object from XML part $format->load($xml); if ($format->is_valid()) { $object = $format->to_array(array('_attachments' => $attachments)); $object['_type'] = $object_type; $object['_msguid'] = $msguid; $object['_mailbox'] = $this->name; $object['_formatobj'] = $format; $object['_size'] = strlen($xml); return $object; } else { // try to extract object UID from XML block if (preg_match('!(.+)!Uims', $xml, $m)) $msgadd = " UID = " . trim(strip_tags($m[1])); rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not parse Kolab object data in message $msguid ($this->name)." . $msgadd, ), true); self::save_user_xml("$msguid.xml", $xml); } 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 IMAP message UID on success */ public function save(&$object, $type = null, $uid = null) { - if (!$this->valid && empty($object)) { + 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]); } } } // save contact photo to attachment for Kolab2 format if (kolab_storage::$version == '2.0' && $object['photo']) { $attkey = 'kolab-picture.png'; // this file name is hard-coded in libkolab/kolabformatV2/contact.cpp $object['_attachments'][$attkey] = array( 'mimetype'=> rcube_mime::image_content_type($object['photo']), 'content' => preg_match('![^a-z0-9/=+-]!i', $object['photo']) ? $object['photo'] : base64_decode($object['photo']), ); } // 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]); } } } // save recurrence exceptions as individual objects due to lack of support in Kolab v2 format if (kolab_storage::$version == '2.0' && $object['recurrence']['EXCEPTIONS']) { $this->save_recurrence_exceptions($object, $type); } // check IMAP BINARY extension support for 'file' objects // allow configuration to workaround bug in Cyrus < 2.4.17 $rcmail = rcube::get_instance(); $binary = $type == 'file' && !$rcmail->config->get('kolab_binary_disable') && $this->imap->get_capability('BINARY'); // generate and save object message if ($raw_msg = $this->build_message($object, $type, $binary, $body_file)) { // resolve old msguid before saving if ($uid && empty($object['_msguid']) && ($msguid = $this->cache->uid2msguid($uid))) { $object['_msguid'] = $msguid; $object['_mailbox'] = $this->name; } $result = $this->imap->save_message($this->name, $raw_msg, null, false, null, null, $binary); // update cache with new UID if ($result) { $old_uid = $object['_msguid']; $object['_msguid'] = $result; $object['_mailbox'] = $this->name; if ($old_uid) { // delete old message $this->cache->imap_mode(true); $this->imap->delete_message($old_uid, $object['_mailbox']); $this->cache->imap_mode(false); } // insert/update message in cache $this->cache->save($result, $object, $old_uid); } // remove temp file if ($body_file) { @unlink($body_file); } } return $result; } /** * Save recurrence exceptions as individual objects. * The Kolab v2 format doesn't allow us to save fully embedded exception objects. * * @param array Hash array with event properties * @param string Object type */ private function save_recurrence_exceptions(&$object, $type = null) { if ($object['recurrence']['EXCEPTIONS']) { $exdates = array(); foreach ((array)$object['recurrence']['EXDATE'] as $exdate) { $key = is_a($exdate, 'DateTime') ? $exdate->format('Y-m-d') : strval($exdate); $exdates[$key] = 1; } // save every exception as individual object foreach((array)$object['recurrence']['EXCEPTIONS'] as $exception) { $exception['uid'] = self::recurrence_exception_uid($object['uid'], $exception['start']->format('Ymd')); $exception['sequence'] = $object['sequence'] + 1; if ($exception['thisandfuture']) { $exception['recurrence'] = $object['recurrence']; // adjust the recurrence duration of the exception if ($object['recurrence']['COUNT']) { $recurrence = new kolab_date_recurrence($object['_formatobj']); if ($end = $recurrence->end()) { unset($exception['recurrence']['COUNT']); $exception['recurrence']['UNTIL'] = $end; } } // set UNTIL date if we have a thisandfuture exception $untildate = clone $exception['start']; $untildate->sub(new DateInterval('P1D')); $object['recurrence']['UNTIL'] = $untildate; unset($object['recurrence']['COUNT']); } else { if (!$exdates[$exception['start']->format('Y-m-d')]) $object['recurrence']['EXDATE'][] = clone $exception['start']; unset($exception['recurrence']); } unset($exception['recurrence']['EXCEPTIONS'], $exception['_formatobj'], $exception['_msguid']); $this->save($exception, $type, $exception['uid']); } unset($object['recurrence']['EXCEPTIONS']); } } /** * Generate an object UID with the given recurrence-ID in a way that it is * unique (the original UID is not a substring) but still recoverable. */ private static function recurrence_exception_uid($uid, $recurrence_id) { $offset = -2; return substr($uid, 0, $offset) . '-' . $recurrence_id . '-' . substr($uid, $offset); } /** * Delete the specified object from this folder. * * @param mixed $object The Kolab object to delete or object UID * @param boolean $expunge Should the folder be expunged? * * @return boolean True if successful, false on error */ public function delete($object, $expunge = true) { if (!$this->valid) { return false; } $msguid = is_array($object) ? $object['_msguid'] : $this->cache->uid2msguid($object); $success = false; $this->cache->imap_mode(true); if ($msguid && $expunge) { $success = $this->imap->delete_message($msguid, $this->name); } else if ($msguid) { $success = $this->imap->set_flag($msguid, 'DELETED', $this->name); } $this->cache->imap_mode(false); if ($success) { $this->cache->set($msguid, false); } return $success; } /** * */ public function delete_all() { if (!$this->valid) { return false; } $this->cache->purge(); $this->cache->imap_mode(true); $result = $this->imap->clear_folder($this->name); $this->cache->imap_mode(false); return $result; } /** * Restore a previously deleted object * * @param string Object UID * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } if ($msguid = $this->cache->uid2msguid($uid, true)) { $this->cache->imap_mode(true); $result = $this->imap->set_flag($msguid, 'UNDELETED', $this->name); $this->cache->imap_mode(false); if ($result) { return $msguid; } } return false; } /** * Move a Kolab object message to another IMAP folder * * @param string Object UID * @param string IMAP folder to move object to * @return boolean True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } if (is_string($target_folder)) $target_folder = kolab_storage::get_folder($target_folder); if ($msguid = $this->cache->uid2msguid($uid)) { $this->cache->imap_mode(true); $result = $this->imap->move_message($msguid, $target_folder->name, $this->name); $this->cache->imap_mode(false); if ($result) { $new_uid = ($copyuid = $this->imap->conn->data['COPYUID']) ? $copyuid[1] : null; $this->cache->move($msguid, $uid, $target_folder, $new_uid); return true; } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to move message $msguid to $target_folder: " . $this->imap->get_error_str(), ), true); } } return false; } /** * Creates source of the configuration object message * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param bool $binary Enables use of binary encoding of attachment(s) * @param string $body_file Reference to filename of message body * * @return mixed Message as string or array with two elements * (one for message file path, second for message headers) */ private function build_message(&$object, $type, $binary, &$body_file) { // load old object to preserve data we don't understand/process if (is_object($object['_formatobj'])) $format = $object['_formatobj']; else if ($object['_msguid'] && ($old = $this->cache->get($object['_msguid'], $type, $object['_mailbox']))) $format = $old['_formatobj']; // create new kolab_format instance if (!$format) $format = kolab_format::factory($type, kolab_storage::$version); if (PEAR::isError($format)) return false; $format->set($object); $xml = $format->write(kolab_storage::$version); $object['uid'] = $format->uid; // read UID from format $object['_formatobj'] = $format; if (empty($xml) || !$format->is_valid() || empty($object['uid'])) { return false; } $mime = new Mail_mime("\r\n"); $rcmail = rcube::get_instance(); $headers = array(); $files = array(); $part_id = 1; $encoding = $binary ? 'binary' : 'base64'; if ($user_email = $rcmail->get_user_email()) { $headers['From'] = $user_email; $headers['To'] = $user_email; } $headers['Date'] = date('r'); $headers['X-Kolab-Type'] = kolab_format::KTYPE_PREFIX . $type; $headers['X-Kolab-Mime-Version'] = kolab_storage::$version; $headers['Subject'] = $object['uid']; // $headers['Message-ID'] = $rcmail->gen_message_id(); $headers['User-Agent'] = $rcmail->config->get('useragent'); // Check if we have enough memory to handle the message in it // It's faster than using files, so we'll do this if we only can if (!empty($object['_attachments']) && ($mem_limit = parse_bytes(ini_get('memory_limit'))) > 0) { $memory = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024; // safe value: 16MB foreach ($object['_attachments'] as $attachment) { $memory += $attachment['size']; } // 1.33 is for base64, we need at least 4x more memory than the message size if ($memory * ($binary ? 1 : 1.33) * 4 > $mem_limit) { $marker = '%%%~~~' . md5(microtime(true) . $memory) . '~~~%%%'; $is_file = true; $temp_dir = unslashify($rcmail->config->get('temp_dir')); $mime->setParam('delay_file_io', true); } } $mime->headers($headers); $mime->setTXTBody("This is a Kolab Groupware object. " . "To view this object you will need an email client that understands the Kolab Groupware format. " . "For a list of such email clients please visit http://www.kolab.org/\n\n"); $ctype = kolab_storage::$version == '2.0' ? $format->CTYPEv2 : $format->CTYPE; // Convert new lines to \r\n, to wrokaround "NO Message contains bare newlines" // when APPENDing from temp file $xml = preg_replace('/\r?\n/', "\r\n", $xml); $mime->addAttachment($xml, // file $ctype, // content-type 'kolab.xml', // filename false, // is_file '8bit', // encoding 'attachment', // disposition RCUBE_CHARSET // charset ); $part_id++; // save object attachments as separate parts foreach ((array)$object['_attachments'] as $key => $att) { if (empty($att['content']) && !empty($att['id'])) { // @TODO: use IMAP CATENATE to skip attachment fetch+push operation $msguid = $object['_copyfrom'] ?: ($object['_msguid'] ?: $object['uid']); if ($is_file) { $att['path'] = tempnam($temp_dir, 'rcmAttmnt'); if (($fp = fopen($att['path'], 'w')) && $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, $fp, true)) { fclose($fp); } else { return false; } } else { $att['content'] = $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, null, true); } } $headers = array('Content-ID' => Mail_mimePart::encodeHeader('Content-ID', '<' . $key . '>', RCUBE_CHARSET, 'quoted-printable')); $name = !empty($att['name']) ? $att['name'] : $key; // To store binary files we can use faster method // without writting full message content to a temporary file but // directly to IMAP, see rcube_imap_generic::append(). // I.e. use file handles where possible if (!empty($att['path'])) { if ($is_file && $binary) { $files[] = fopen($att['path'], 'r'); $mime->addAttachment($marker, $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } else { $mime->addAttachment($att['path'], $att['mimetype'], $name, true, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } } else { if (is_resource($att['content']) && $is_file && $binary) { $files[] = $att['content']; $mime->addAttachment($marker, $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } else { if (is_resource($att['content'])) { @rewind($att['content']); $att['content'] = stream_get_contents($att['content']); } $mime->addAttachment($att['content'], $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } } $object['_attachments'][$key]['id'] = ++$part_id; } if (!$is_file || !empty($files)) { $message = $mime->getMessage(); } // parse message and build message array with // attachment file pointers in place of file markers if (!empty($files)) { $message = explode($marker, $message); $tmp = array(); foreach ($message as $msg_part) { $tmp[] = $msg_part; if ($file = array_shift($files)) { $tmp[] = $file; } } $message = $tmp; } // write complete message body into temp file else if ($is_file) { // use common temp dir $body_file = tempnam($temp_dir, 'rcmMsg'); if (PEAR::isError($mime_result = $mime->saveMessageBody($body_file))) { rcube::raise_error(array('code' => 650, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not create message: ".$mime_result->getMessage()), true, false); return false; } $message = array(trim($mime->txtHeaders()) . "\r\n\r\n", fopen($body_file, 'r')); } return $message; } /** * Triggers any required updates after changes within the * folder. This is currently only required for handling free/busy * information with Kolab. * * @return boolean|PEAR_Error True if successfull. */ public function trigger() { $owner = $this->get_owner(); $result = false; switch($this->type) { case 'event': if ($this->get_namespace() == 'personal') { $result = $this->trigger_url( sprintf('%s/trigger/%s/%s.pfb', kolab_storage::get_freebusy_server(), urlencode($owner), urlencode($this->imap->mod_folder($this->name)) ), $this->imap->options['user'], $this->imap->options['password'] ); } break; default: return true; } if ($result && is_object($result) && is_a($result, 'PEAR_Error')) { return PEAR::raiseError(sprintf("Failed triggering folder %s. Error was: %s", $this->name, $result->getMessage())); } return $result; } /** * Triggers a URL. * * @param string $url The URL to be triggered. * @param string $auth_user Username to authenticate with * @param string $auth_passwd Password for basic auth * @return boolean|PEAR_Error True if successfull. */ private function trigger_url($url, $auth_user = null, $auth_passwd = null) { try { $request = libkolab::http_request($url); // set authentication credentials if ($auth_user && $auth_passwd) $request->setAuth($auth_user, $auth_passwd); $result = $request->send(); // rcube::write_log('trigger', $result->getBody()); } catch (Exception $e) { return PEAR::raiseError($e->getMessage()); } return true; } /** * Log content to a file in per_user_loggin dir if configured */ private static function save_user_xml($filename, $content) { $rcmail = rcube::get_instance(); if ($rcmail->config->get('kolab_format_error_log')) { $log_dir = $rcmail->config->get('log_dir', RCUBE_INSTALL_PATH . 'logs'); $user_name = $rcmail->get_user_name(); $log_dir = $log_dir . '/' . $user_name; if (!empty($user_name) && is_writable($log_dir)) { file_put_contents("$log_dir/$filename", $content); } } } }