diff --git a/plugins/calendar/drivers/kolab/SQL/oracle.initial.sql b/plugins/calendar/drivers/kolab/SQL/oracle.initial.sql new file mode 100644 index 00000000..d6d882bd --- /dev/null +++ b/plugins/calendar/drivers/kolab/SQL/oracle.initial.sql @@ -0,0 +1,31 @@ +/** + * Roundcube Calendar Kolab backend + * + * @author Aleksander Machniak + * @licence GNU AGPL + **/ + +CREATE TABLE "kolab_alarms" ( + "alarm_id" varchar(255) NOT NULL PRIMARY KEY, + "user_id" integer NOT NULL + REFERENCES "users" ("user_id") ON DELETE CASCADE, + "notifyat" timestamp DEFAULT NULL, + "dismissed" smallint DEFAULT 0 NOT NULL +); + +CREATE INDEX "kolab_alarms_user_id_idx" ON "kolab_alarms" ("user_id"); + + +CREATE TABLE "itipinvitations" ( + "token" varchar(64) NOT NULL PRIMARY KEY, + "event_uid" varchar(255) NOT NULL, + "user_id" integer NOT NULL + REFERENCES "users" ("user_id") ON DELETE CASCADE, + "event" long NOT NULL, + "expires" timestamp DEFAULT NULL, + "cancelled" smallint DEFAULT 0 NOT NULL +); + +CREATE INDEX "itipinvitations_user_id_idx" ON "itipinvitations" ("user_id", "event_uid"); + +INSERT INTO "system" ("name", "value") VALUES ('calendar-kolab-version', '2014041700'); diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 6575a233..938bcee3 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1,1798 +1,1796 @@ * @author Aleksander Machniak * * Copyright (C) 2012-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_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 = array('DISPLAY','AUDIO'); public $categoriesimmutable = true; private $rc; private $cal; private $calendars; private $has_writeable = false; private $freebusy_trigger = false; private $bonnie_api = false; /** * Default constructor */ public function __construct($cal) { $cal->require_plugin('libkolab'); // load helper classes *after* libkolab has been loaded (#3248) require_once(dirname(__FILE__) . '/kolab_calendar.php'); require_once(dirname(__FILE__) . '/kolab_user_calendar.php'); require_once(dirname(__FILE__) . '/kolab_invitation_calendar.php'); $this->cal = $cal; $this->rc = $cal->rc; $this->_read_calendars(); $this->cal->register_action('push-freebusy', array($this, 'push_freebusy')); $this->cal->register_action('calendar-acl', array($this, 'calendar_acl')); $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); if (kolab_storage::$version == '2.0') { $this->alarm_types = array('DISPLAY'); $this->alarm_absolute = false; } // get configuration for the Bonnie API if ($bonnie_config = $this->cal->rc->config->get('kolab_bonnie_api', false)) $this->bonnie_api = new kolab_bonnie_api($bonnie_config); // calendar uses fully encoded identifiers kolab_storage::$encode_ids = true; } /** * Read available calendars from server */ private 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)); $this->calendars = array(); foreach ($folders as $folder) { if ($folder instanceof kolab_storage_folder_user) { $calendar = new kolab_user_calendar($folder->name, $this->cal); $calendar->subscriptions = count($folder->children) > 0; } else { $calendar = new kolab_calendar($folder->name, $this->cal); } if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; if (!$calendar->readonly) $this->has_writeable = true; } } return $this->calendars; } /** * Get a list of available calendars from this source * * @param bool $active Return only active calendars * @param bool $personal Return only personal calendars * @param object $tree Reference to hierarchical folder tree object * * @return array List of calendars */ public function list_calendars($active = false, $personal = false, &$tree = null) { // attempt to create a default calendar for this user if (!$this->has_writeable) { if ($this->create_calendar(array('name' => 'Calendar', 'color' => 'cc0000'))) { unset($this->calendars); $this->_read_calendars(); } } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $folders = $this->filter_calendars(false, $active, $personal); $calendars = array(); // include virtual folders for a full folder tree if (!is_null($tree)) $folders = kolab_storage::folder_hierarchy($folders, $tree); foreach ($folders as $id => $cal) { $fullname = $cal->get_name(); $listname = $cal->get_foldername(); $imap_path = explode($delim, $cal->name); // find parent do { array_pop($imap_path); $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !$this->calendars[$parent_id]); // restore "real" parent ID if ($parent_id && !$this->calendars[$parent_id]) { $parent_id = kolab_storage::folder_id($cal->get_parent()); } // turn a kolab_storage_folder object into a kolab_calendar if ($cal instanceof kolab_storage_folder) { $cal = new kolab_calendar($cal->name, $this->cal); $this->calendars[$cal->id] = $cal; } // special handling for user or virtual folders if ($cal instanceof kolab_storage_folder_user) { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => kolab_storage::object_name($fullname), 'listname' => $listname, 'editname' => $cal->get_foldername(), 'color' => $cal->get_color(), 'active' => $cal->is_active(), 'title' => $cal->get_owner(), 'owner' => $cal->get_owner(), 'history' => false, 'virtual' => false, 'readonly' => true, 'group' => 'other', 'class' => 'user', 'removable' => true, ); } else if ($cal->virtual) { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => $fullname, 'listname' => $listname, 'editname' => $cal->get_foldername(), 'virtual' => true, 'readonly' => true, 'group' => $cal->get_namespace(), 'class' => 'folder', ); } else { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => $fullname, 'listname' => $listname, 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'readonly' => $cal->readonly, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'group' => $cal->get_namespace(), 'default' => $cal->default, 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'children' => true, // TODO: determine if that folder indeed has child folders 'parent' => $parent_id, 'caldavurl' => $cal->get_caldav_url(), 'removable' => !$cal->default, ); } if ($cal->subscriptions) { $calendars[$cal->id]['subscribed'] = $cal->is_subscribed(); } } // list virtual calendars showing invitations if ($this->rc->config->get('kolab_invitation_calendars')) { foreach (array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED) as $id) { $cal = new kolab_invitation_calendar($id, $this->cal); $this->calendars[$cal->id] = $cal; if (!$active || $cal->is_active()) { $calendars[$id] = array( 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_name(), 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'readonly' => $cal->readonly, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'group' => 'x-invitations', 'default' => false, 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'children' => false, ); if (is_object($tree)) { $tree->children[] = $cal; } } } } // append the virtual birthdays calendar if ($this->rc->config->get('calendar_contact_birthdays', false)) { $id = self::BIRTHDAY_CALENDAR_ID; $prefs = $this->rc->config->get('kolab_calendars', array()); // read local prefs if (!$active || $prefs[$id]['active']) { $calendars[$id] = array( 'id' => $id, 'name' => $this->cal->gettext('birthdays'), 'listname' => $this->cal->gettext('birthdays'), 'color' => $prefs[$id]['color'] ?: '87CEFA', 'active' => (bool)$prefs[$id]['active'], 'showalarms' => (bool)$this->rc->config->get('calendar_birthdays_alarm_type'), 'group' => 'x-birthdays', 'readonly' => true, 'default' => false, 'children' => false, 'history' => false, ); } } return $calendars; } /** * Get list of calendars according to specified filters * * @param bool $writeable Return only writeable calendars * @param bool $active Return only active calendars * @param bool $personal Return only personal calendars * * @return array List of calendars */ protected function filter_calendars($writeable = false, $active = false, $personal = false) { $calendars = array(); $plugin = $this->rc->plugins->exec_hook('calendar_list_filter', array( 'list' => $this->calendars, 'calendars' => $calendars, 'writeable' => $writeable, 'active' => $active, 'personal' => $personal, )); if ($plugin['abort']) { return $plugin['calendars']; } foreach ($this->calendars as $cal) { if (!$cal->ready) { continue; } if ($writeable && $cal->readonly) { continue; } if ($active && !$cal->is_active()) { continue; } if ($personal && $cal->get_namespace() != 'personal') { 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 object kolab_calendar Object nor null if calendar doesn't exist */ public function get_calendar($id) { // create calendar object if necesary if (!$this->calendars[$id] && in_array($id, array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { $this->calendars[$id] = new kolab_invitation_calendar($id, $this->cal); } else if (!$this->calendars[$id] && $id !== self::BIRTHDAY_CALENDAR_ID) { $calendar = kolab_calendar::factory($id, $this->cal); if ($calendar->ready) $this->calendars[$calendar->id] = $calendar; } return $this->calendars[$id]; } /** * 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); if ($folder === false) { $this->last_error = $this->cal->gettext(kolab_storage::$last_error); return false; } // create ID $id = kolab_storage::folder_id($folder); // save color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); if (isset($prop['color'])) $prefs['kolab_calendars'][$id]['color'] = $prop['color']; if (isset($prop['showalarms'])) $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($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 ($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', array()); 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'] = $prop['showalarms'] ? true : false; 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 ($prop['id'] && ($cal = $this->get_calendar($prop['id'])) && is_object($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 ($prop['recursive']) { foreach ((array)kolab_storage::list_folders($cal->storage->name, '*', 'event') as $subfolder) { if (isset($prop['permanent'])) ($prop['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder)); if (isset($prop['active'])) ($prop['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder)); } } return $ret; } else { // save state in local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); $prefs['kolab_calendars'][$prop['id']]['active'] = (bool)$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 ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { $folder = $cal->get_realname(); // TODO: unsubscribe if no admin rights if (kolab_storage::folder_delete($folder)) { // remove color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); unset($prefs['kolab_calendars'][$prop['id']]); $this->rc->user->save_prefs($prefs); return true; } else $this->last_error = kolab_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()) return array(); $this->calendars = array(); $this->search_more_results = false; // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('event', $query, array('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') { $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number foreach (kolab_storage::search_users($query, 0, array(), $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) { $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, $writeable = false, $active = false, $personal = false) { if (is_array($event)) { $id = $event['id'] ? $event['id'] : $event['uid']; $cal = $event['calendar']; } else { $id = $event; } if ($cal) { if ($storage = $this->get_calendar($cal)) { return $storage->get_event($id); } // get event from the address books birthday calendar else 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($writeable, $active, $personal) as $calendar) { if ($result = $calendar->get_event($id)) { return $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; $cid = $event['calendar'] ? $event['calendar'] : reset(array_keys($this->calendars)); if ($storage = $this->get_calendar($cid)) { // handle attachments to add if (!empty($event['attachments'])) { foreach ($event['attachments'] as $idx => $attachment) { // we'll read file contacts into memory, Horde/Kolab classes does the same // So we cannot save memory, rcube_imap class can do this better $event['attachments'][$idx]['content'] = $attachment['data'] ? $attachment['data'] : file_get_contents($attachment['path']); } } $success = $storage->insert_event($event); if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', array('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 boolean True on success, False on error */ public function edit_event($event) { return $this->update_event($event); } /** * Extended event editing with possible changes to the argument * * @param array Hash array with event properties * @param string New participant status * @return boolean True on success, False on error */ public function edit_rsvp(&$event, $status) { if (($ret = $this->update_event($event)) && $this->rc->config->get('kolab_invitation_calendars')) { // re-assign to the according (virtual) calendar if (strtoupper($status) == 'DECLINED') $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; else if (strtoupper($status) == 'NEEDS-ACTION') $event['calendar'] = self::INVITATIONS_CALENDAR_PENDING; else if ($event['_folder_id']) $event['calendar'] = $event['_folder_id']; } return $ret; } /** * 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']); 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']); return $this->update_event($event + $ev); } return false; } /** * Remove a single event * * @param array Hash array with event properties: * id: Event identifier * @param boolean Remove record(s) irreversible (mark as deleted otherwise) * * @return boolean True on success, False on error */ public function remove_event($event, $force = true) { $success = false; $savemode = $event['_savemode']; $decline = $event['decline']; if (($storage = $this->get_calendar($event['calendar'])) && ($event = $storage->get_event($event['id']))) { $event['_savemode'] = $savemode; $savemode = 'all'; $master = $event; $this->rc->session->remove('calendar_restore_event_data'); // read master if deleting a recurring event if ($event['recurrence'] || $event['recurrence_id']) { $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event; $savemode = $event['_savemode']; } // removing an exception instance if ($event['recurrence_id']) { $i = $event['_instance'] - 1; if (!empty($master['recurrence']['EXCEPTIONS'][$i])) { unset($master['recurrence']['EXCEPTIONS'][$i]); } } switch ($savemode) { case 'current': $_SESSION['calendar_restore_event_data'] = $master; // removing the first instance => just move to next occurence if ($master['id'] == $event['id']) { $recurring = reset($storage->_get_recurring_events($event, $event['start'], null, $event['id'].'-1')); // no future instances found: delete the master event (bug #1677) if (!$recurring['start']) { $success = $storage->delete_event($master, $force); break; } $master['start'] = $recurring['start']; $master['end'] = $recurring['end']; if ($master['recurrence']['COUNT']) $master['recurrence']['COUNT']--; } // remove the matching RDATE entry else if ($master['recurrence']['RDATE']) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { unset($master['recurrence']['RDATE'][$j]); break; } } } else { // add exception to master event $master['recurrence']['EXDATE'][] = $event['start']; } $success = $storage->update_event($master); break; case 'future': if ($master['id'] != $event['id']) { $_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'] = array(); } // remove matching RDATE entries else if ($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); break; } default: // 'all' is default 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 && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success; } /** * Restore a single deleted event * * @param array Hash array with event properties: * id: Event identifier * @return boolean 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($_SESSION['calendar_restore_event_data']); else $success = $storage->restore_event($event); if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success; } return false; } /** * Wrapper to update an event object depending on the given savemode */ private function update_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; // move event to another folder/calendar if ($event['_fromcalendar'] && $event['_fromcalendar'] != $event['calendar']) { if (!($fromcalendar = $this->get_calendar($event['_fromcalendar']))) return false; if ($event['_savemode'] != 'new') { if (!$fromcalendar->storage->move($event['id'], $storage->get_realname())) return false; $fromcalendar = $storage; } } else $fromcalendar = $storage; $success = false; $savemode = 'all'; $attachments = array(); $old = $master = $fromcalendar->get_event($event['id']); if (!$old || !$old['start']) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to load event object to update: id=" . $event['id']), true, false); return false; } // delete existing attachment(s) if (!empty($event['deleted_attachments'])) { foreach ($event['deleted_attachments'] as $attachment) { if (!empty($old['attachments'])) { foreach ($old['attachments'] as $idx => $att) { if ($att['id'] == $attachment) { $old['attachments'][$idx]['_deleted'] = true; } } } } unset($event['deleted_attachments']); } // handle attachments to add if (!empty($event['attachments'])) { foreach ($event['attachments'] as $attachment) { // skip entries without content (could be existing ones) if (!$attachment['data'] && !$attachment['path']) continue; $attachments[] = array( 'name' => $attachment['name'], 'mimetype' => $attachment['mimetype'], 'content' => $attachment['data'], 'path' => $attachment['path'], ); } } $event['attachments'] = array_merge((array)$old['attachments'], $attachments); // modify a recurring event, check submitted savemode to do the right things if ($old['recurrence'] || $old['recurrence_id']) { $master = $old['recurrence_id'] ? $fromcalendar->get_event($old['recurrence_id']) : $old; $savemode = $event['_savemode']; } // keep saved exceptions (not submitted by the client) if ($old['recurrence']['EXDATE']) $event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE']; if ($old['recurrence']['EXCEPTIONS']) $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS']; switch ($savemode) { case 'new': // save submitted data as new (non-recurring) event $event['recurrence'] = array(); $event['uid'] = $this->cal->generate_uid(); // copy attachment data to new event foreach ((array)$event['attachments'] as $idx => $attachment) { if (!$attachment['data']) $attachment['data'] = $fromcalendar->get_attachment_body($attachment['id'], $event); } $success = $storage->insert_event($event); break; case 'future': case 'current': // recurring instances shall not store recurrence rules $event['recurrence'] = array(); $event['thisandfuture'] = $savemode == 'future'; // remove some internal properties which should not be saved unset($event['_savemode'], $event['_fromcalendar'], $event['_identity']); // save properties to a recurrence exception instance if ($old['recurrence_id']) { $i = $old['_instance'] - 1; if (!empty($master['recurrence']['EXCEPTIONS'][$i])) { $master['recurrence']['EXCEPTIONS'][$i] = $event; $success = $storage->update_event($master, $old['id']); break; } } $add_exception = true; // adjust matching RDATE entry if dates changed if ($savemode == 'current' && $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) { $master['recurrence']['EXCEPTIONS'][] = $event; } $success = $storage->update_event($master); break; default: // 'all' is default $event['id'] = $master['id']; $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 = $old['allday'] ? '' : $old['start']->format('H:i'); $old_duration = $old['end']->format('U') - $old['start']->format('U'); $new_start_date = $event['start']->format('Y-m-d'); $new_start_time = $event['allday'] ? '' : $event['start']->format('H:i'); $new_duration = $event['end']->format('U') - $event['start']->format('U'); $diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration; // shifted or resized if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) { $event['start'] = $master['start']->add($old['start']->diff($event['start'])); $event['end'] = clone $event['start']; $event['end']->add(new DateInterval('PT'.$new_duration.'S')); // remove fixed weekday, will be re-set to the new weekday in kolab_calendar::update_event() if ($old_start_date != $new_start_date) { if (strlen($event['recurrence']['BYDAY']) == 2) unset($event['recurrence']['BYDAY']); if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) unset($event['recurrence']['BYMONTH']); } } // dates did not change, use the ones from master else if ($event['start'] == $old['start'] && $event['end'] == $old['end']) { $event['start'] = $master['start']; $event['end'] = $master['end']; } // unset _dateonly flags in (cached) date objects unset($event['start']->_dateonly, $event['end']->_dateonly); $success = $storage->update_event($event); break; } if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success; } /** * Get events from source. * * @param integer Event's new start (unix timestamp) * @param integer 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 boolean Include virtual events (optional) * @param integer 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) $calendars = array_keys($this->calendars); $query = array(); if ($modifiedsince) $query[] = array('changed', '>=', $modifiedsince); $events = $categories = array(); 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); if ($newcats = array_udiff(array_keys($categories), array_keys($old_categories), function($a, $b){ return strcasecmp($a, $b); })) { foreach ($newcats as $category) $old_categories[$category] = ''; // no color set yet $this->rc->user->save_prefs(array('calendar_categories' => $old_categories)); } return $events; } /** * 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 array(); if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); $time = $slot + $interval; $candidates = array(); $query = array(array('tags', '=', 'x-has-alarms')); 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 && $alarm['time'] && $alarm['time'] >= $last && in_array($alarm['action'], $this->alarm_types)) { $id = $alarm['id']; // use alarm-id as primary identifier $candidates[$id] = array( '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)) { $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates)); - $result = $this->rc->db->query(sprintf( - "SELECT * FROM " . $this->rc->db->table_name('kolab_alarms') . " - WHERE alarm_id IN (%s) AND user_id=?", - join(',', $alarm_ids), - $this->rc->db->now() - ), - $this->rc->user->ID - ); + $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; } } $alarms = array(); 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 " . $this->rc->db->table_name('kolab_alarms') . " - WHERE alarm_id=? AND user_id=?", - $alarm_id, - $this->rc->user->ID + $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 " . $this->rc->db->table_name('kolab_alarms') . " - (alarm_id, user_id, dismissed, notifyat) - VALUES(?, ?, ?, ?)", + $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; $event = $storage->get_event($event['id']); if ($event && !empty($event['attachments'])) { foreach ($event['attachments'] as $att) { if ($att['id'] == $id) { return $att; } } } return null; } /** * 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; return $cal->storage->get_attachment($event['id'], $id); } /** * 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); } /** * 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 = array( '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 = array( 'store_body' => true, 'follow_redirects' => true, ); $request = libkolab::http_request(kolab_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 (!$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(array('email'), $email, true, true, true/*, 'freebusyurl'*/)) { while ($contact = $result->iterate()) { if ($fburl = $contact['freebusyurl']) { $fbdata = @file_get_contents($fburl); break; } } } if ($fbdata) break; } } // parse free-busy information using Horde classes if ($fbdata) { $ical = $this->cal->get_ical(); $ical->import($fbdata); if ($fb = $ical->freebusy) { $result = array(); foreach ($fb['periods'] as $tuple) { list($from, $to, $type) = $tuple; $result[] = array($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 ($fb['start'] && ($fbstart = $fb['start']->format('U')) && $start < $fbstart) { array_unshift($result, array($start, $fbstart, calendar::FREEBUSY_UNKNOWN)); } // pad period till $end with status 'unknown' if ($fb['end'] && ($fbend = $fb['end']->format('U')) && $fbend < $end) { $result[] = array($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 = get_input_value('source', RCUBE_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(array( 'code' => 900, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed triggering folder. Error was " . $trigger->getMessage()), true, false); } exit; } /** * Convert from Kolab_Format to internal representation */ public static function to_rcube_event($record) { $record['id'] = $record['uid']; // all-day events go from 12:00 - 13:00 if ($record['end'] <= $record['start'] && $record['allday']) { $record['end'] = clone $record['start']; $record['end']->add(new DateInterval('PT1H')); } if (!empty($record['_attachments'])) { foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (!$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 (is_array($attendee['delegated-from'])) { $record['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); } if (is_array($attendee['delegated-to'])) { $record['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); } } } // Roundcube only supports one category assignment if (is_array($record['categories'])) $record['categories'] = $record['categories'][0]; // the cancelled flag transltes into status=CANCELLED if ($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']); // remove internals unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments'], $record['x-custom']); return $record; } /** * 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) = $this->_resolve_event_identity($event); $result = $this->bonnie_api->changelog('event', $uid, $mailbox); 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 $rev Revisions: "from:to" * * @return array List of property changes, each as a hash array * @see calendar_driver::get_event_diff() */ public function get_event_diff($event, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox) = $this->_resolve_event_identity($event); // call Bonnie API $result = $this->bonnie_api->diff('event', $uid, $rev, $mailbox); if (is_array($result) && $result['uid'] == $uid) { $result['rev'] = $rev; $keymap = array( 'dtstart' => 'start', 'dtend' => 'end', 'dstamp' => 'changed', 'summary' => 'title', 'alarm' => 'alarms', 'attendee' => 'attendees', 'attach' => 'attachments', 'rrule' => 'recurrence', 'transparency' => 'free_busy', 'classification' => 'sensitivity', 'lastmodified-date' => 'changed', ); $prop_keymaps = array( 'attachments' => array('fmttype' => 'mimetype', 'label' => 'name'), 'attendees' => array('partstat' => 'status'), ); $special_changes = array(); // 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'] = $old['old'] ? 'free' : 'busy'; $change['new'] = $old['new'] ? 'free' : 'busy'; } // map alarms trigger value if ($change['property'] == 'alarms') { if (is_array($change['old']) && is_array($change['old']['trigger'])) $change['old']['trigger'] = $change['old']['trigger']['value']; if (is_array($change['new']) && is_array($change['new']['trigger'])) $change['new']['trigger'] = $change['new']['trigger']['value']; } // make all property keys uppercase if ($change['property'] == 'recurrence') { $special_changes['recurrence'] = $i; foreach (array('old','new') as $m) { if (is_array($change[$m])) { $props = array(); foreach ($change[$m] as $k => $v) $props[strtoupper($k)] = $v; $change[$m] = $props; } } } // map property keys names if (is_array($prop_keymaps[$change['property']])) { foreach ($prop_keymaps[$change['property']] as $k => $dest) { if (is_array($change['old']) && array_key_exists($k, $change['old'])) { $change['old'][$dest] = $change['old'][$k]; unset($change['old'][$k]); } if (is_array($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 (array('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] = array('property' => 'recurrence', 'old' => array(), 'new' => array()); $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) { if (empty($this->bonnie_api)) { return false; } $calid = $event['calendar']; list($uid, $mailbox) = $this->_resolve_event_identity($event); // call Bonnie API $result = $this->bonnie_api->get('event', $uid, $rev, $mailbox); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('event'); $format->load($result['xml']); $event = $format->to_array(); if ($format->is_valid()) { $event['calendar'] = $calid; $event['rev'] = $result['rev']; return self::to_rcube_event($event); } } return false; } /** * Helper method to resolved the given event identifier into uid and folder * * @return array (uid,folder) tuple */ private function _resolve_event_identity($event) { $mailbox = null; if (is_array($event)) { $uid = $event['id'] ?: $event['uid']; if (($cal = $this->get_calendar($event['calendar'])) && !($cal instanceof kolab_invitation_calendar)) { $mailbox = $cal->get_mailbox_id(); } } else { $uid = $event; } return array($uid, $mailbox); } /** * 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) { // show default dialog for birthday calendar if (in_array($calendar['id'], array(self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { if ($calendar['id'] != self::BIRTHDAY_CALENDAR_ID) unset($formfields['showalarms']); return parent::calendar_form($action, $calendar, $formfields); } if ($calendar['id'] && ($cal = $this->calendars[$calendar['id']])) { $folder = $cal->get_realname(); // UTF7 $color = $cal->get_color(); } else { $folder = ''; $color = ''; } $hidden_fields[] = array('name' => 'oldname', 'value' => $folder); $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder)) { $path_imap = explode($delim, $folder); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder); } else { $path_imap = ''; } // General tab $form['props'] = array( 'name' => $this->rc->gettext('properties'), ); // Disable folder name input if (!empty($options) && ($options['norename'] || $options['protected'])) { $input_name = new html_hiddenfield(array('name' => 'name', 'id' => 'calendar-name')); $formfields['name']['value'] = kolab_storage::object_name($folder) . $input_name->show($folder); } // calendar name (default field) $form['props']['fieldsets']['location'] = array( 'name' => $this->rc->gettext('location'), 'content' => array( 'name' => $formfields['name'] ), ); if (!empty($options) && ($options['norename'] || $options['protected'])) { // prevent user from moving folder $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); } else { $select = kolab_storage::folder_selector('event', array('name' => 'parent', 'id' => 'calendar-parent'), $folder); $form['props']['fieldsets']['location']['content']['path'] = array( 'id' => 'calendar-parent', 'label' => $this->cal->gettext('parentcalendar'), 'value' => $select->show(strlen($folder) ? $path_imap : ''), ); } // calendar color (default field) $form['props']['fieldsets']['settings'] = array( 'name' => $this->rc->gettext('settings'), 'content' => array( 'color' => $formfields['color'], 'showalarms' => $formfields['showalarms'], ), ); if ($action != 'form-new') { $form['sharing'] = array( 'name' => Q($this->cal->gettext('tabsharing')), 'content' => html::tag('iframe', array( 'src' => $this->cal->rc->url(array('_action' => 'calendar-acl', 'id' => $calendar['id'], 'framed' => 1)), 'width' => '100%', 'height' => 350, 'border' => 0, 'style' => 'border:0'), ''), ); } $this->form_html = ''; if (is_array($hidden_fields)) { foreach ($hidden_fields as $field) { $hiddenfield = new html_hiddenfield($field); $this->form_html .= $hiddenfield->show() . "\n"; } } // Create form output foreach ($form as $tab) { if (!empty($tab['fieldsets']) && is_array($tab['fieldsets'])) { $content = ''; foreach ($tab['fieldsets'] as $fieldset) { $subcontent = $this->get_form_part($fieldset); if ($subcontent) { $content .= html::tag('fieldset', null, html::tag('legend', null, Q($fieldset['name'])) . $subcontent) ."\n"; } } } else { $content = $this->get_form_part($tab); } if ($content) { $this->form_html .= html::tag('fieldset', null, html::tag('legend', null, Q($tab['name'])) . $content) ."\n"; } } // Parse form template for skin-dependent stuff $this->rc->output->add_handler('calendarform', array($this, 'calendar_form_html')); return $this->rc->output->parse('calendar.kolabform', false, false); } /** * Handler for template object */ public function calendar_form_html() { return $this->form_html; } /** * Helper function used in calendar_form_content(). Creates a part of the form. */ private function get_form_part($form) { $content = ''; if (is_array($form['content']) && !empty($form['content'])) { $table = new html_table(array('cols' => 2)); foreach ($form['content'] as $col => $colprop) { $label = !empty($colprop['label']) ? $colprop['label'] : rcube_label($col); $table->add('title', html::label($colprop['id'], Q($label))); $table->add(null, $colprop['value']); } $content = $table->show(); } else { $content = $form['content']; } return $content; } /** * Handler to render ACL form for a calendar folder */ public function calendar_acl() { $this->rc->output->add_handler('folderacl', array($this, 'calendar_acl_form')); $this->rc->output->send('calendar.kolabacl'); } /** * Handler for ACL form template object */ public function calendar_acl_form() { $calid = get_input_value('_id', RCUBE_INPUT_GPC); if ($calid && ($cal = $this->get_calendar($calid))) { $folder = $cal->get_realname(); // UTF7 $color = $cal->get_color(); } else { $folder = ''; $color = ''; } $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder)) { $path_imap = explode($delim, $folder); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder); // Allow plugins to modify the form content (e.g. with ACL form) $plugin = $this->rc->plugins->exec_hook('calendar_form_kolab', array('form' => $form, 'options' => $options, 'name' => $folder)); } if (!$plugin['form']['sharing']['content']) $plugin['form']['sharing']['content'] = html::div('hint', $this->cal->gettext('aclnorights')); return $plugin['form']['sharing']['content']; } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $db = $this->rc->get_dbh(); foreach (array('kolab_alarms', 'itipinvitations') as $table) { - $db->query("DELETE FROM " . $this->rc->db->table_name($table) . " WHERE user_id=?", $args['user']->ID); + $db->query("DELETE FROM " . $this->rc->db->table_name($table, true) + . " WHERE `user_id` = ?", $args['user']->ID); } } } diff --git a/plugins/calendar/lib/calendar_itip.php b/plugins/calendar/lib/calendar_itip.php index de401229..56223727 100644 --- a/plugins/calendar/lib/calendar_itip.php +++ b/plugins/calendar/lib/calendar_itip.php @@ -1,235 +1,235 @@ * @package @package_name@ * * Copyright (C) 2011, 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 calendar_itip extends libcalendaring_itip { /** * Constructor to set text domain to calendar */ function __construct($plugin, $domain = 'calendar') { parent::__construct($plugin, $domain); - $this->db_itipinvitations = $this->rc->db->table_name('itipinvitations'); + $this->db_itipinvitations = $this->rc->db->table_name('itipinvitations', true); } /** * Handler for calendar/itip-status requests */ public function get_itip_status($event, $existing = null) { $status = parent::get_itip_status($event, $existing); // don't ask for deleting events when declining if ($this->rc->config->get('kolab_invitation_calendars')) $status['saved'] = false; return $status; } /** * Find invitation record by token * * @param string Invitation token * @return mixed Invitation record as hash array or False if not found */ public function get_invitation($token) { if ($parts = $this->decode_token($token)) { - $result = $this->rc->db->query("SELECT * FROM $this->db_itipinvitations WHERE token=?", $parts['base']); + $result = $this->rc->db->query("SELECT * FROM $this->db_itipinvitations WHERE `token` = ?", $parts['base']); if ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $rec['event'] = unserialize($rec['event']); $rec['attendee'] = $parts['attendee']; return $rec; } } return false; } /** * Update the attendee status of the given invitation record * * @param array Invitation record as fetched with calendar_itip::get_invitation() * @param string Attendee email address * @param string New attendee status */ public function update_invitation($invitation, $email, $newstatus) { if (is_string($invitation)) $invitation = $this->get_invitation($invitation); if ($invitation['token'] && $invitation['event']) { // update attendee record in event data foreach ($invitation['event']['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $organizer = $attendee; } else if ($attendee['email'] == $email) { // nothing to be done here if ($attendee['status'] == $newstatus) return true; $invitation['event']['attendees'][$i]['status'] = $newstatus; $this->sender = $attendee; } } $invitation['event']['changed'] = new DateTime(); // send iTIP REPLY message to organizer if ($organizer) { $status = strtolower($newstatus); if ($this->send_itip_message($invitation['event'], 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) $this->rc->output->command('display_message', $this->plugin->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation'); else $this->rc->output->command('display_message', $this->plugin->gettext('itipresponseerror'), 'error'); } // update record in DB $query = $this->rc->db->query( "UPDATE $this->db_itipinvitations - SET event=? - WHERE token=?", + SET `event` = ? + WHERE `token` = ?", self::serialize_event($invitation['event']), $invitation['token'] ); if ($this->rc->db->affected_rows($query)) return true; } return false; } /** * Create iTIP invitation token for later replies via URL * * @param array Hash array with event properties * @param string Attendee email address * @return string Invitation token */ public function store_invitation($event, $attendee) { static $stored = array(); if (!$event['uid'] || !$attendee) return false; // generate token for this invitation $token = $this->generate_token($event, $attendee); $base = substr($token, 0, 40); // already stored this if ($stored[$base]) return $token; // delete old entry - $this->rc->db->query("DELETE FROM $this->db_itipinvitations WHERE token=?", $base); + $this->rc->db->query("DELETE FROM $this->db_itipinvitations WHERE `token` = ?", $base); $query = $this->rc->db->query( "INSERT INTO $this->db_itipinvitations - (token, event_uid, user_id, event, expires) + (`token`, `event_uid`, `user_id`, `event`, `expires`) VALUES(?, ?, ?, ?, ?)", $base, $event['uid'], $this->rc->user->ID, self::serialize_event($event), date('Y-m-d H:i:s', $event['end']->format('U') + 86400 * 2) ); if ($this->rc->db->affected_rows($query)) { $stored[$base] = 1; return $token; } return false; } /** * Mark invitations for the given event as cancelled * * @param array Hash array with event properties */ public function cancel_itip_invitation($event) { // flag invitation record as cancelled $this->rc->db->query( "UPDATE $this->db_itipinvitations - SET cancelled=1 - WHERE event_uid=? AND user_id=?", + SET `cancelled` = 1 + WHERE `event_uid` = ? AND `user_id` = ?", $event['uid'], $this->rc->user->ID ); } /** * Generate an invitation request token for the given event and attendee * * @param array Event hash array * @param string Attendee email address */ public function generate_token($event, $attendee) { $base = sha1($event['uid'] . ';' . $this->rc->user->ID); $mail = base64_encode($attendee); $hash = substr(md5($base . $mail . $this->rc->config->get('des_key')), 0, 6); return "$base.$mail.$hash"; } /** * Decode the given iTIP request token and return its parts * * @param string Request token to decode * @return mixed Hash array with parts or False if invalid */ public function decode_token($token) { list($base, $mail, $hash) = explode('.', $token); // validate and return parts if ($mail && $hash && $hash == substr(md5($base . $mail . $this->rc->config->get('des_key')), 0, 6)) { return array('base' => $base, 'attendee' => base64_decode($mail)); } return false; } /** * Helper method to serialize the given event for storing in invitations table */ private static function serialize_event($event) { $ev = $event; $ev['description'] = abbreviate_string($ev['description'], 100); unset($ev['attachments']); return serialize($ev); } } diff --git a/plugins/libkolab/SQL/oracle.initial.sql b/plugins/libkolab/SQL/oracle.initial.sql new file mode 100644 index 00000000..2c078cb6 --- /dev/null +++ b/plugins/libkolab/SQL/oracle.initial.sql @@ -0,0 +1,184 @@ +/** + * libkolab database schema + * + * @version 1.1 + * @author Aleksander Machniak + * @licence GNU AGPL + **/ + + +CREATE TABLE "kolab_folders" ( + "folder_id" number NOT NULL PRIMARY KEY, + "resource" VARCHAR(255) NOT NULL, + "type" VARCHAR(32) NOT NULL, + "synclock" integer DEFAULT 0 NOT NULL, + "ctag" VARCHAR(40) DEFAULT NULL +); + +CREATE INDEX "kolab_folders_resource_idx" ON "kolab_folders" ("resource", "type"); + +CREATE SEQUENCE "kolab_folders_seq" + START WITH 1 INCREMENT BY 1 NOMAXVALUE; + +CREATE TRIGGER "kolab_folders_seq_trig" +BEFORE INSERT ON "kolab_folders" FOR EACH ROW +BEGIN + :NEW."folder_id" := "kolab_folders_seq".nextval; +END; + + +CREATE TABLE "kolab_cache_contact" ( + "folder_id" number NOT NULL + REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, + "msguid" number NOT NULL, + "uid" varchar(128) NOT NULL, + "created" timestamp DEFAULT NULL, + "changed" timestamp DEFAULT NULL, + "data" clob NOT NULL, + "xml" clob NOT NULL, + "tags" varchar(255) DEFAULT NULL, + "words" clob DEFAULT NULL, + "type" varchar(32) NOT NULL, + "name" varchar(255) DEFAULT NULL, + "firstname" varchar(255) DEFAULT NULL, + "surname" varchar(255) DEFAULT NULL, + "email" varchar(255) DEFAULT NULL, + PRIMARY KEY ("folder_id", "msguid") +); + +CREATE INDEX "kolab_cache_contact_type_idx" ON "kolab_cache_contact" ("folder_id", "type"); +CREATE INDEX "kolab_cache_contact_uid2msguid" ON "kolab_cache_contact" ("folder_id", "uid", "msguid"); + + +CREATE TABLE "kolab_cache_event" ( + "folder_id" number NOT NULL + REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, + "msguid" number NOT NULL, + "uid" varchar(128) NOT NULL, + "created" timestamp DEFAULT NULL, + "changed" timestamp DEFAULT NULL, + "data" clob NOT NULL, + "xml" clob NOT NULL, + "tags" varchar(255) DEFAULT NULL, + "words" clob DEFAULT NULL, + "dtstart" timestamp DEFAULT NULL, + "dtend" timestamp DEFAULT NULL, + PRIMARY KEY ("folder_id", "msguid") +); + +CREATE INDEX "kolab_cache_event_uid2msguid" ON "kolab_cache_event" ("folder_id", "uid", "msguid"); + + +CREATE TABLE "kolab_cache_task" ( + "folder_id" number NOT NULL + REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, + "msguid" number NOT NULL, + "uid" varchar(128) NOT NULL, + "created" timestamp DEFAULT NULL, + "changed" timestamp DEFAULT NULL, + "data" clob NOT NULL, + "xml" clob NOT NULL, + "tags" varchar(255) DEFAULT NULL, + "words" clob DEFAULT NULL, + "dtstart" timestamp DEFAULT NULL, + "dtend" timestamp DEFAULT NULL, + PRIMARY KEY ("folder_id", "msguid") +); + +CREATE INDEX "kolab_cache_task_uid2msguid" ON "kolab_cache_task" ("folder_id", "uid", "msguid"); + + +CREATE TABLE "kolab_cache_journal" ( + "folder_id" number NOT NULL + REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, + "msguid" number NOT NULL, + "uid" varchar(128) NOT NULL, + "created" timestamp DEFAULT NULL, + "changed" timestamp DEFAULT NULL, + "data" clob NOT NULL, + "xml" clob NOT NULL, + "tags" varchar(255) DEFAULT NULL, + "words" clob DEFAULT NULL, + "dtstart" timestamp DEFAULT NULL, + "dtend" timestamp DEFAULT NULL, + PRIMARY KEY ("folder_id", "msguid") +); + +CREATE INDEX "kolab_cache_journal_uid2msguid" ON "kolab_cache_journal" ("folder_id", "uid", "msguid"); + + +CREATE TABLE "kolab_cache_note" ( + "folder_id" number NOT NULL + REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, + "msguid" number NOT NULL, + "uid" varchar(128) NOT NULL, + "created" timestamp DEFAULT NULL, + "changed" timestamp DEFAULT NULL, + "data" clob NOT NULL, + "xml" clob NOT NULL, + "tags" varchar(255) DEFAULT NULL, + "words" clob DEFAULT NULL, + PRIMARY KEY ("folder_id", "msguid") +); + +CREATE INDEX "kolab_cache_note_uid2msguid" ON "kolab_cache_note" ("folder_id", "uid", "msguid"); + + +CREATE TABLE "kolab_cache_file" ( + "folder_id" number NOT NULL + REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, + "msguid" number NOT NULL, + "uid" varchar(128) NOT NULL, + "created" timestamp DEFAULT NULL, + "changed" timestamp DEFAULT NULL, + "data" clob NOT NULL, + "xml" clob NOT NULL, + "tags" varchar(255) DEFAULT NULL, + "words" clob DEFAULT NULL, + "filename" varchar(255) DEFAULT NULL, + PRIMARY KEY ("folder_id", "msguid") +); + +CREATE INDEX "kolab_cache_file_filename" ON "kolab_cache_file" ("folder_id", "filename"); +CREATE INDEX "kolab_cache_file_uid2msguid" ON "kolab_cache_file" ("folder_id", "uid", "msguid"); + + +CREATE TABLE "kolab_cache_configuration" ( + "folder_id" number NOT NULL + REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, + "msguid" number NOT NULL, + "uid" varchar(128) NOT NULL, + "created" timestamp DEFAULT NULL, + "changed" timestamp DEFAULT NULL, + "data" clob NOT NULL, + "xml" clob NOT NULL, + "tags" varchar(255) DEFAULT NULL, + "words" clob DEFAULT NULL, + "type" varchar(32) NOT NULL, + PRIMARY KEY ("folder_id", "msguid") +); + +CREATE INDEX "kolab_cache_config_type" ON "kolab_cache_configuration" ("folder_id", "type"); +CREATE INDEX "kolab_cache_config_uid2msguid" ON "kolab_cache_configuration" ("folder_id", "uid", "msguid"); + + +CREATE TABLE "kolab_cache_freebusy" ( + "folder_id" number NOT NULL + REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, + "msguid" number NOT NULL, + "uid" varchar(128) NOT NULL, + "created" timestamp DEFAULT NULL, + "changed" timestamp DEFAULT NULL, + "data" clob NOT NULL, + "xml" clob NOT NULL, + "tags" varchar(255) DEFAULT NULL, + "words" clob DEFAULT NULL, + "dtstart" timestamp DEFAULT NULL, + "dtend" timestamp DEFAULT NULL, + PRIMARY KEY("folder_id", "msguid") +); + +CREATE INDEX "kolab_cache_fb_uid2msguid" ON "kolab_cache_freebusy" ("folder_id", "uid", "msguid"); + + +INSERT INTO "system" ("name", "value") VALUES ('libkolab-version', '2014021000'); diff --git a/plugins/libkolab/lib/kolab_storage.php b/plugins/libkolab/lib/kolab_storage.php index 61c82088..dfd18877 100644 --- a/plugins/libkolab/lib/kolab_storage.php +++ b/plugins/libkolab/lib/kolab_storage.php @@ -1,1569 +1,1567 @@ * @author Aleksander Machniak * * Copyright (C) 2012-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 { const CTYPE_KEY = '/shared/vendor/kolab/folder-type'; const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type'; const COLOR_KEY_SHARED = '/shared/vendor/kolab/color'; const COLOR_KEY_PRIVATE = '/private/vendor/kolab/color'; const NAME_KEY_SHARED = '/shared/vendor/kolab/displayname'; const NAME_KEY_PRIVATE = '/private/vendor/kolab/displayname'; const UID_KEY_SHARED = '/shared/vendor/kolab/uniqueid'; const UID_KEY_PRIVATE = '/private/vendor/kolab/uniqueid'; const UID_KEY_CYRUS = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; public static $version = '3.0'; public static $last_error; public static $encode_ids = false; private static $ready = false; private static $with_tempsubs = true; private static $subscriptions; private static $typedata = array(); private static $states; private static $config; private static $imap; private static $ldap; // Default folder names private static $default_folders = array( 'event' => 'Calendar', 'contact' => 'Contacts', 'task' => 'Tasks', 'note' => 'Notes', 'file' => 'Files', 'configuration' => 'Configuration', 'journal' => 'Journal', 'mail.inbox' => 'INBOX', 'mail.drafts' => 'Drafts', 'mail.sentitems' => 'Sent', 'mail.wastebasket' => 'Trash', 'mail.outbox' => 'Outbox', 'mail.junkemail' => 'Junk', ); /** * Setup the environment needed by the libs */ public static function setup() { if (self::$ready) return true; $rcmail = rcube::get_instance(); self::$config = $rcmail->config; self::$version = strval($rcmail->config->get('kolab_format_version', self::$version)); self::$imap = $rcmail->get_storage(); self::$ready = class_exists('kolabformat') && (self::$imap->get_capability('METADATA') || self::$imap->get_capability('ANNOTATEMORE') || self::$imap->get_capability('ANNOTATEMORE2')); if (self::$ready) { // set imap options self::$imap->set_options(array( 'skip_deleted' => true, 'threading' => false, )); } else if (!class_exists('kolabformat')) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "required kolabformat module not found" ), true); } else { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "IMAP server doesn't support METADATA or ANNOTATEMORE" ), true); } return self::$ready; } /** * Initializes LDAP object to resolve Kolab users */ public static function ldap() { if (self::$ldap) { return self::$ldap; } self::setup(); $config = self::$config->get('kolab_users_directory', self::$config->get('kolab_auth_addressbook')); if (!is_array($config)) { $ldap_config = (array)self::$config->get('ldap_public'); $config = $ldap_config[$config]; } if (empty($config)) { return null; } // overwrite filter option if ($filter = self::$config->get('kolab_users_filter')) { self::$config->set('kolab_auth_filter', $filter); } // re-use the LDAP wrapper class from kolab_auth plugin require_once rtrim(RCUBE_PLUGINS_DIR, '/') . '/kolab_auth/kolab_auth_ldap.php'; self::$ldap = new kolab_auth_ldap($config); return self::$ldap; } /** * 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) * @param boolean Enable to return subscribed folders only (null to use configured subscription mode) * * @return array List of Kolab_Folder objects (folder names in UTF7-IMAP) */ public static function get_folders($type, $subscribed = null) { $folders = $folderdata = array(); if (self::setup()) { foreach ((array)self::list_folders('', '*', $type, $subscribed, $folderdata) as $foldername) { $folders[$foldername] = new kolab_storage_folder($foldername, $folderdata[$foldername]); } } 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_folder The folder object */ public static function get_default_folder($type) { if (self::setup()) { foreach ((array)self::list_folders('', '*', $type . '.default', false, $folderdata) as $foldername) { return new kolab_storage_folder($foldername, $folderdata[$foldername]); } } return null; } /** * Getter for a specific storage folder * * @param string IMAP folder to access (UTF7-IMAP) * @return object kolab_storage_folder The folder object */ public static function get_folder($folder) { return self::setup() ? new kolab_storage_folder($folder) : null; } /** * 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 static function get_object($uid, $type) { self::setup(); $folder = null; foreach ((array)self::list_folders('', '*', $type) as $foldername) { if (!$folder) $folder = new kolab_storage_folder($foldername); else $folder->set_folder($foldername); if ($object = $folder->get_object($uid, '*')) return $object; } return false; } /** * Execute cross-folder searches with the given query. * * @param array Pseudo-SQL query as list of filter parameter triplets * @param string Object type (contact,event,task,journal,file,note,configuration) * @return array List of Kolab data objects (each represented as hash array) * @see kolab_storage_format::select() */ public static function select($query, $type) { self::setup(); $folder = null; $result = array(); foreach ((array)self::list_folders('', '*', $type) as $foldername) { if (!$folder) $folder = new kolab_storage_folder($foldername); else $folder->set_folder($foldername); foreach ($folder->select($query, '*') as $object) { $result[] = $object; } } return $result; } /** * Returns Free-busy server URL */ public static function get_freebusy_server() { $url = 'https://' . $_SESSION['imap_host'] . '/freebusy'; $url = self::$config->get('kolab_freebusy_server', $url); $url = rcube_utils::resolve_url($url); return unslashify($url); } /** * Compose an URL to query the free/busy status for the given user */ public static function get_freebusy_url($email) { return self::get_freebusy_server() . '/' . $email . '.ifb'; } /** * Creates folder ID from folder name * * @param string $folder Folder name (UTF7-IMAP) * @param boolean $enc Use lossless encoding * @return string Folder ID string */ public static function folder_id($folder, $enc = null) { return $enc == true || ($enc === null && self::$encode_ids) ? self::id_encode($folder) : asciiwords(strtr($folder, '/.-', '___')); } /** * Encode the given ID to a safe ascii representation * * @param string $id Arbitrary identifier string * * @return string Ascii representation */ public static function id_encode($id) { return rtrim(strtr(base64_encode($id), '+/', '-_'), '='); } /** * Convert the given identifier back to it's raw value * * @param string $id Ascii identifier * @return string Raw identifier string */ public static function id_decode($id) { return base64_decode(str_pad(strtr($id, '-_', '+/'), strlen($id) % 4, '=', STR_PAD_RIGHT)); } /** * Return the (first) path of the requested IMAP namespace * * @param string Namespace name (personal, shared, other) * @return string IMAP root path for that namespace */ public static function namespace_root($name) { foreach ((array)self::$imap->get_namespace($name) as $paths) { if (strlen($paths[0]) > 1) { return $paths[0]; } } return ''; } /** * Deletes IMAP folder * * @param string $name Folder name (UTF7-IMAP) * * @return bool True on success, false on failure */ public static function folder_delete($name) { // clear cached entries first if ($folder = self::get_folder($name)) $folder->cache->purge(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_delete', array('name' => $name)); $success = self::$imap->delete_folder($name); self::$last_error = self::$imap->get_error_str(); return $success; } /** * Creates IMAP 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 static function folder_create($name, $type = null, $subscribed = false, $active = false) { self::setup(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_create', array('record' => array( 'name' => $name, 'subscribe' => $subscribed, ))); if ($saved = self::$imap->create_folder($name, $subscribed)) { // set metadata for folder type if ($type) { $saved = self::set_folder_type($name, $type); // revert if metadata could not be set if (!$saved) { self::$imap->delete_folder($name); } // activate folder else if ($active) { self::set_state($name, true); } } } if ($saved) { return true; } self::$last_error = self::$imap->get_error_str(); return false; } /** * Renames IMAP 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 static function folder_rename($oldname, $newname) { self::setup(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_rename', array( 'oldname' => $oldname, 'newname' => $newname)); $oldfolder = self::get_folder($oldname); $active = self::folder_is_active($oldname); $success = self::$imap->rename_folder($oldname, $newname); self::$last_error = self::$imap->get_error_str(); // pass active state to new folder name if ($success && $active) { self::set_state($oldnam, false); self::set_state($newname, true); } // assign existing cache entries to new resource uri if ($success && $oldfolder) { $oldfolder->cache->rename($newname); } return $success; } /** * Rename or Create a new IMAP folder. * * Does additional checks for permissions and folder name restrictions * * @param array 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 mixed New folder name or False on failure */ public static function folder_update(&$prop) { self::setup(); $folder = rcube_charset::convert($prop['name'], RCUBE_CHARSET, 'UTF7-IMAP'); $oldfolder = $prop['oldname']; // UTF7 $parent = $prop['parent']; // UTF7 $delimiter = self::$imap->get_hierarchy_delimiter(); if (strlen($oldfolder)) { $options = self::$imap->folder_info($oldfolder); } if (!empty($options) && ($options['norename'] || $options['protected'])) { } // sanity checks (from steps/settings/save_folder.inc) else if (!strlen($folder)) { self::$last_error = 'cannotbeempty'; return false; } else if (strlen($folder) > 128) { self::$last_error = 'nametoolong'; return false; } else { // these characters are problematic e.g. when used in LIST/LSUB foreach (array($delimiter, '%', '*') as $char) { if (strpos($folder, $char) !== false) { self::$last_error = 'forbiddencharacter'; return false; } } } if (!empty($options) && ($options['protected'] || $options['norename'])) { $folder = $oldfolder; } else if (strlen($parent)) { $folder = $parent . $delimiter . $folder; } else { // add namespace prefix (when needed) $folder = self::$imap->mod_folder($folder, 'in'); } // Check access rights to the parent folder if (strlen($parent) && (!strlen($oldfolder) || $oldfolder != $folder)) { $parent_opts = self::$imap->folder_info($parent); if ($parent_opts['namespace'] != 'personal' && (empty($parent_opts['rights']) || !preg_match('/[ck]/', implode($parent_opts['rights']))) ) { self::$last_error = 'No permission to create folder'; return false; } } // update the folder name if (strlen($oldfolder)) { if ($oldfolder != $folder) { $result = self::folder_rename($oldfolder, $folder); } else $result = true; } // create new folder else { $result = self::folder_create($folder, $prop['type'], $prop['subscribed'], $prop['active']); } if ($result) { self::set_folder_props($folder, $prop); } return $result ? $folder : false; } /** * Getter for human-readable name of Kolab object (folder) * See http://wiki.kolab.org/UI-Concepts/Folder-Listing for reference * * @param string $folder IMAP folder name (UTF7-IMAP) * @param string $folder_ns Will be set to namespace name of the folder * * @return string Name of the folder-object */ public static function object_name($folder, &$folder_ns=null) { self::setup(); // find custom display name in folder METADATA if ($name = self::custom_displayname($folder)) { return $name; } $found = false; $namespace = self::$imap->get_namespace(); if (!empty($namespace['shared'])) { foreach ($namespace['shared'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { $prefix = ''; $folder = substr($folder, strlen($ns[0])); $delim = $ns[1]; $found = true; $folder_ns = 'shared'; break; } } } if (!$found && !empty($namespace['other'])) { foreach ($namespace['other'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { // remove namespace prefix $folder = substr($folder, strlen($ns[0])); $delim = $ns[1]; // get username $pos = strpos($folder, $delim); if ($pos) { $prefix = '('.substr($folder, 0, $pos).')'; $folder = substr($folder, $pos+1); } else { $prefix = '('.$folder.')'; $folder = ''; } $found = true; $folder_ns = 'other'; break; } } } if (!$found && !empty($namespace['personal'])) { foreach ($namespace['personal'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { // remove namespace prefix $folder = substr($folder, strlen($ns[0])); $prefix = ''; $delim = $ns[1]; $found = true; break; } } } if (empty($delim)) $delim = self::$imap->get_hierarchy_delimiter(); $folder = rcube_charset::convert($folder, 'UTF7-IMAP'); $folder = html::quote($folder); $folder = str_replace(html::quote($delim), ' » ', $folder); if ($prefix) $folder = html::quote($prefix) . ($folder !== '' ? ' ' . $folder : ''); if (!$folder_ns) $folder_ns = 'personal'; return $folder; } /** * Get custom display name (saved in metadata) for the given folder */ public static function custom_displayname($folder) { // find custom display name in folder METADATA if (self::$config->get('kolab_custom_display_names', true)) { $metadata = self::$imap->get_metadata($folder, array(self::NAME_KEY_PRIVATE, self::NAME_KEY_SHARED)); if (($name = $metadata[$folder][self::NAME_KEY_PRIVATE]) || ($name = $metadata[$folder][self::NAME_KEY_SHARED])) { return $name; } } return false; } /** * Helper method to generate a truncated folder name to display. * Note: $origname is a string returned by self::object_name() */ public static function folder_displayname($origname, &$names) { $name = $origname; // find folder prefix to truncate for ($i = count($names)-1; $i >= 0; $i--) { if (strpos($name, $names[$i] . ' » ') === 0) { $length = strlen($names[$i] . ' » '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $diff = 1; // check if prefix folder is in other users namespace for ($n = count($names)-1; $n >= 0; $n--) { if (strpos($prefix, '(' . $names[$n] . ') ') === 0) { $diff = 0; break; } } $name = str_repeat('   ', $count - $diff) . '» ' . substr($name, $length); break; } // other users namespace and parent folder exists else if (strpos($name, '(' . $names[$i] . ') ') === 0) { $length = strlen('(' . $names[$i] . ') '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $name = str_repeat('   ', $count) . '» ' . substr($name, $length); break; } } $names[] = $origname; return $name; } /** * 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 static function folder_selector($type, $attrs, $current = '') { // get all folders of specified type (sorted) $folders = self::get_folders($type, true); $delim = self::$imap->get_hierarchy_delimiter(); $names = array(); $len = strlen($current); if ($len && ($rpos = strrpos($current, $delim))) { $parent = substr($current, 0, $rpos); $p_len = strlen($parent); } // Filter folders list foreach ($folders as $c_folder) { $name = $c_folder->name; // skip current folder and it's subfolders if ($len) { if ($name == $current) { // Make sure parent folder is listed (might be skipped e.g. if it's namespace root) if ($p_len && !isset($names[$parent])) { $names[$parent] = self::object_name($parent); } continue; } if (strpos($name, $current.$delim) === 0) { continue; } } // always show the parent of current folder if ($p_len && $name == $parent) { } // skip folders where user have no rights to create subfolders else if ($c_folder->get_owner() != $_SESSION['username']) { $rights = $c_folder->get_myrights(); if (!preg_match('/[ck]/', $rights)) { continue; } } $names[$name] = self::object_name($name); } // Build SELECT field of parent folder $attrs['is_escaped'] = true; $select = new html_select($attrs); $select->add('---', ''); $listnames = array(); foreach (array_keys($names) as $imap_name) { $name = $origname = $names[$imap_name]; // find folder prefix to truncate for ($i = count($listnames)-1; $i >= 0; $i--) { if (strpos($name, $listnames[$i].' » ') === 0) { $length = strlen($listnames[$i].' » '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $name = str_repeat('  ', $count-1) . '» ' . substr($name, $length); break; } } $listnames[] = $origname; $select->add($name, $imap_name); } return $select; } /** * 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 boolean 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 static function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = array()) { if (!self::setup()) { return null; } // use IMAP subscriptions if ($subscribed === null && self::$config->get('kolab_use_subscriptions')) { $subscribed = true; } if (!$filter) { // Get ALL folders list, standard way if ($subscribed) { $folders = self::$imap->list_folders_subscribed($root, $mbox); // add temporarily subscribed folders if (self::$with_tempsubs && is_array($_SESSION['kolab_subscribed_folders'])) { $folders = array_unique(array_merge($folders, $_SESSION['kolab_subscribed_folders'])); } } else { $folders = self::_imap_list_folders($root, $mbox); } return $folders; } $prefix = $root . $mbox; $regexp = '/^' . preg_quote($filter, '/') . '(\..+)?$/'; // get folders types for all folders if (!$subscribed || $prefix == '*' || !self::$config->get('kolab_skip_namespace')) { $folderdata = self::folders_typedata($prefix); } else { // fetch folder types for the effective list of (subscribed) folders when post-filtering $folderdata = array(); } if (!is_array($folderdata)) { return array(); } // In some conditions we can skip LIST command (?) if (!$subscribed && $filter != 'mail' && $prefix == '*') { foreach ($folderdata as $folder => $type) { if (!preg_match($regexp, $type)) { unset($folderdata[$folder]); } } return self::$imap->sort_folder_list(array_keys($folderdata), true); } // Get folders list if ($subscribed) { $folders = self::$imap->list_folders_subscribed($root, $mbox); // add temporarily subscribed folders if (self::$with_tempsubs && is_array($_SESSION['kolab_subscribed_folders'])) { $folders = array_unique(array_merge($folders, $_SESSION['kolab_subscribed_folders'])); } } else { $folders = self::_imap_list_folders($root, $mbox); } // In case of an error, return empty list (?) if (!is_array($folders)) { return array(); } // Filter folders list foreach ($folders as $idx => $folder) { // lookup folder type if (!array_key_exists($folder, $folderdata)) { $folderdata[$folder] = self::folder_type($folder); } $type = $folderdata[$folder]; if ($filter == 'mail' && empty($type)) { continue; } if (empty($type) || !preg_match($regexp, $type)) { unset($folders[$idx]); } } return $folders; } /** * Wrapper for rcube_imap::list_folders() with optional post-filtering */ protected static function _imap_list_folders($root, $mbox) { $postfilter = null; // compose a post-filter expression for the excluded namespaces if ($root . $mbox == '*' && ($skip_ns = self::$config->get('kolab_skip_namespace'))) { $excludes = array(); foreach ((array)$skip_ns as $ns) { if ($ns_root = self::namespace_root($ns)) { $excludes[] = $ns_root; } } if (count($excludes)) { $postfilter = '!^(' . join(')|(', array_map('preg_quote', $excludes)) . ')!'; } } // use normal LIST command to return all folders, it's fast enough $folders = self::$imap->list_folders($root, $mbox, null, null, !empty($postfilter)); if (!empty($postfilter)) { $folders = array_filter($folders, function($folder) use ($postfilter) { return !preg_match($postfilter, $folder); }); $folders = self::$imap->sort_folder_list($folders); } return $folders; } /** * 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 static function search_folders($type, $query, $exclude_ns = array()) { if (!self::setup()) { return array(); } $folders = array(); $query = str_replace('*', '', $query); // find unsubscribed IMAP folders of the given type foreach ((array)self::list_folders('', '*', $type, false, $folderdata) as $foldername) { // FIXME: only consider the last part of the folder path for searching? $realname = strtolower(rcube_charset::convert($foldername, 'UTF7-IMAP')); if (($query == '' || strpos($realname, $query) !== false) && !self::folder_is_subscribed($foldername, true) && !in_array(self::$imap->folder_namespace($foldername), (array)$exclude_ns) ) { $folders[] = new kolab_storage_folder($foldername, $folderdata[$foldername]); } } return $folders; } /** * Sort the given list of kolab folders by namespace/name * * @param array List of kolab_storage_folder objects * @return array Sorted list of folders */ public static function sort_folders($folders) { $pad = ' '; $out = array(); $nsnames = array('personal' => array(), 'shared' => array(), 'other' => array()); foreach ($folders as $folder) { $folders[$folder->name] = $folder; $ns = $folder->get_namespace(); $nsnames[$ns][$folder->name] = strtolower(html_entity_decode(self::object_name($folder->name, $ns), ENT_COMPAT, RCUBE_CHARSET)) . $pad; // decode » } // $folders is a result of get_folders() we can assume folders were already sorted foreach (array_keys($nsnames) as $ns) { asort($nsnames[$ns], SORT_LOCALE_STRING); foreach (array_keys($nsnames[$ns]) as $utf7name) { $out[] = $folders[$utf7name]; } } return $out; } /** * Check the folder tree and add the missing parents as virtual folders * * @param array $folders Folders list * @param object $tree Reference to the root node of the folder tree * * @return array Flat folders list */ public static function folder_hierarchy($folders, &$tree = null) { $_folders = array(); $delim = self::$imap->get_hierarchy_delimiter(); $other_ns = rtrim(self::namespace_root('other'), $delim); $tree = new kolab_storage_folder_virtual('', '', ''); // create tree root $refs = array('' => $tree); foreach ($folders as $idx => $folder) { $path = explode($delim, $folder->name); array_pop($path); $folder->parent = join($delim, $path); $folder->children = array(); // reset list // skip top folders or ones with a custom displayname if (count($path) < 1 || kolab_storage::custom_displayname($folder->name)) { $tree->children[] = $folder; } else { $parents = array(); $depth = $folder->get_namespace() == 'personal' ? 1 : 2; while (count($path) >= $depth && ($parent = join($delim, $path))) { array_pop($path); $parent_parent = join($delim, $path); if (!$refs[$parent]) { if ($folder->type && self::folder_type($parent) == $folder->type) { $refs[$parent] = new kolab_storage_folder($parent, $folder->type); $refs[$parent]->parent = $parent_parent; } else if ($parent_parent == $other_ns) { $refs[$parent] = new kolab_storage_folder_user($parent, $parent_parent); } else { $name = kolab_storage::object_name($parent, $folder->get_namespace()); $refs[$parent] = new kolab_storage_folder_virtual($parent, $name, $folder->get_namespace(), $parent_parent); } $parents[] = $refs[$parent]; } } if (!empty($parents)) { $parents = array_reverse($parents); foreach ($parents as $parent) { $parent_node = $refs[$parent->parent] ?: $tree; $parent_node->children[] = $parent; $_folders[] = $parent; } } $parent_node = $refs[$folder->parent] ?: $tree; $parent_node->children[] = $folder; } $refs[$folder->name] = $folder; $_folders[] = $folder; unset($folders[$idx]); } 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 static function folders_typedata($prefix = '*') { if (!self::setup()) { return false; } // return cached result if (is_array(self::$typedata[$prefix])) { return self::$typedata[$prefix]; } $type_keys = array(self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE); // fetch metadata from *some* folders only if (($prefix == '*' || $prefix == '') && ($skip_ns = self::$config->get('kolab_skip_namespace'))) { $delimiter = self::$imap->get_hierarchy_delimiter(); $folderdata = $blacklist = array(); foreach ((array)$skip_ns as $ns) { if ($ns_root = rtrim(self::namespace_root($ns), $delimiter)) { $blacklist[] = $ns_root; } } foreach (array('personal','other','shared') as $ns) { if (!in_array($ns, (array)$skip_ns)) { $ns_root = rtrim(self::namespace_root($ns), $delimiter); // list top-level folders and their childs one by one // GETMETADATA "%" doesn't list shared or other namespace folders but "*" would if ($ns_root == '') { foreach ((array)self::$imap->get_metadata('%', $type_keys) as $folder => $metadata) { if (!in_array($folder, $blacklist)) { $folderdata[$folder] = $metadata; $opts = self::$imap->folder_attributes($folder); if (!in_array('\\HasNoChildren', $opts) && ($data = self::$imap->get_metadata($folder.$delimiter.'*', $type_keys))) { $folderdata += $data; } } } } else if ($data = self::$imap->get_metadata($ns_root.$delimiter.'*', $type_keys)) { $folderdata += $data; } } } } else { $folderdata = self::$imap->get_metadata($prefix, $type_keys); } if (!is_array($folderdata)) { return false; } // keep list in memory self::$typedata[$prefix] = array_map(array('kolab_storage', 'folder_select_metadata'), $folderdata); return self::$typedata[$prefix]; } /** * Callback for array_map to select the correct annotation value */ public static function folder_select_metadata($types) { if (!empty($types[self::CTYPE_KEY_PRIVATE])) { return $types[self::CTYPE_KEY_PRIVATE]; } else if (!empty($types[self::CTYPE_KEY])) { list($ctype, $suffix) = explode('.', $types[self::CTYPE_KEY]); return $ctype; } return null; } /** * Returns type of IMAP folder * * @param string $folder Folder name (UTF7-IMAP) * * @return string Folder type */ public static function folder_type($folder) { self::setup(); // return in-memory cached result foreach (self::$typedata as $typedata) { if (array_key_exists($folder, $typedata)) { return $typedata[$folder]; } } $metadata = self::$imap->get_metadata($folder, array(self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE)); if (!is_array($metadata)) { return null; } if (!empty($metadata[$folder])) { return self::folder_select_metadata($metadata[$folder]); } return 'mail'; } /** * Sets folder content-type. * * @param string $folder Folder name * @param string $type Content type * * @return boolean True on success */ public static function set_folder_type($folder, $type='mail') { self::setup(); list($ctype, $subtype) = explode('.', $type); $success = self::$imap->set_metadata($folder, array(self::CTYPE_KEY => $ctype, self::CTYPE_KEY_PRIVATE => $subtype ? $type : null)); if (!$success) // fallback: only set private annotation $success |= self::$imap->set_metadata($folder, array(self::CTYPE_KEY_PRIVATE => $type)); return $success; } /** * Check subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Include temporary/session subscriptions * * @return boolean True if subscribed, false if not */ public static function folder_is_subscribed($folder, $temp = false) { if (self::$subscriptions === null) { self::setup(); self::$with_tempsubs = false; self::$subscriptions = self::$imap->list_folders_subscribed(); self::$with_tempsubs = true; } return in_array($folder, self::$subscriptions) || ($temp && in_array($folder, (array)$_SESSION['kolab_subscribed_folders'])); } /** * Change subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Only subscribe temporarily for the current session * * @return True on success, false on error */ public static function folder_subscribe($folder, $temp = false) { self::setup(); // temporary/session subscription if ($temp) { if (self::folder_is_subscribed($folder)) { return true; } else if (!is_array($_SESSION['kolab_subscribed_folders']) || !in_array($folder, $_SESSION['kolab_subscribed_folders'])) { $_SESSION['kolab_subscribed_folders'][] = $folder; return true; } } else if (self::$imap->subscribe($folder)) { self::$subscriptions === null; return true; } return false; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Only remove temporary subscription * * @return True on success, false on error */ public static function folder_unsubscribe($folder, $temp = false) { self::setup(); // temporary/session subscription if ($temp) { if (is_array($_SESSION['kolab_subscribed_folders']) && ($i = array_search($folder, $_SESSION['kolab_subscribed_folders'])) !== false) { unset($_SESSION['kolab_subscribed_folders'][$i]); } return true; } else if (self::$imap->unsubscribe($folder)) { self::$subscriptions === null; return true; } return false; } /** * Check activation status of this folder * * @param string $folder Folder name * * @return boolean True if active, false if not */ public static function folder_is_active($folder) { $active_folders = self::get_states(); return in_array($folder, $active_folders); } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public static function folder_activate($folder) { // activation implies temporary subscription self::folder_subscribe($folder, true); return self::set_state($folder, true); } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public static function folder_deactivate($folder) { // remove from temp subscriptions, really? self::folder_unsubscribe($folder, true); return self::set_state($folder, false); } /** * Return list of active folders */ private static function get_states() { if (self::$states !== null) { return self::$states; } $rcube = rcube::get_instance(); $folders = $rcube->config->get('kolab_active_folders'); if ($folders !== null) { self::$states = !empty($folders) ? explode('**', $folders) : array(); } // for backward-compatibility copy server-side subscriptions to activation states else { self::setup(); if (self::$subscriptions === null) { self::$with_tempsubs = false; self::$subscriptions = self::$imap->list_folders_subscribed(); self::$with_tempsubs = true; } self::$states = self::$subscriptions; $folders = implode(self::$states, '**'); $rcube->user->save_prefs(array('kolab_active_folders' => $folders)); } return self::$states; } /** * Update list of active folders */ private static function set_state($folder, $state) { self::get_states(); // update in-memory list $idx = array_search($folder, self::$states); if ($state && $idx === false) { self::$states[] = $folder; } else if (!$state && $idx !== false) { unset(self::$states[$idx]); } // update user preferences $folders = implode(self::$states, '**'); $rcube = rcube::get_instance(); return $rcube->user->save_prefs(array('kolab_active_folders' => $folders)); } /** * 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 static function create_default_folder($type, $props = array()) { if (!self::setup()) { return; } $folders = self::$imap->get_metadata('*', array(kolab_storage::CTYPE_KEY_PRIVATE)); // from kolab_folders config $folder_type = strpos($type, '.') ? str_replace('.', '_', $type) : $type . '_default'; $default_name = self::$config->get('kolab_folders_' . $folder_type); $folder_type = str_replace('_', '.', $folder_type); // check if we have any folder in personal namespace // folder(s) may exist but not subscribed foreach ((array)$folders as $f => $data) { if (strpos($data[self::CTYPE_KEY_PRIVATE], $type) === 0) { $folder = $f; break; } } if (!$folder) { if (!$default_name) { $default_name = self::$default_folders[$type]; } if (!$default_name) { return; } $folder = rcube_charset::convert($default_name, RCUBE_CHARSET, 'UTF7-IMAP'); $prefix = self::$imap->get_namespace('prefix'); // add personal namespace prefix if needed if ($prefix && strpos($folder, $prefix) !== 0 && $folder != 'INBOX') { $folder = $prefix . $folder; } if (!self::$imap->folder_exists($folder)) { if (!self::$imap->create_folder($folder)) { return; } } self::set_folder_type($folder, $folder_type); } self::folder_subscribe($folder); if ($props['active']) { self::set_state($folder, true); } if (!empty($props)) { self::set_folder_props($folder, $props); } return $folder; } /** * Sets folder metadata properties * * @param string $folder Folder name * @param array $prop Folder properties */ public static function set_folder_props($folder, &$prop) { if (!self::setup()) { return; } // TODO: also save 'showalarams' and other properties here $ns = self::$imap->folder_namespace($folder); $supported = array( 'color' => array(self::COLOR_KEY_SHARED, self::COLOR_KEY_PRIVATE), 'displayname' => array(self::NAME_KEY_SHARED, self::NAME_KEY_PRIVATE), ); foreach ($supported as $key => $metakeys) { if (array_key_exists($key, $prop)) { $meta_saved = false; if ($ns == 'personal') // save in shared namespace for personal folders $meta_saved = self::$imap->set_metadata($folder, array($metakeys[0] => $prop[$key])); if (!$meta_saved) // try in private namespace $meta_saved = self::$imap->set_metadata($folder, array($metakeys[1] => $prop[$key])); if ($meta_saved) unset($prop[$key]); // unsetting will prevent fallback to local user prefs } } } /** * * @param mixed $query Search value (or array of field => value pairs) * @param int $mode Matching mode: 0 - partial (*abc*), 1 - strict (=), 2 - prefix (abc*) * @param array $required List of fields that shall ot be empty * @param int $limit Maximum number of records * @param int $count Returns the number of records found * * @return array List or false on error */ public static function search_users($query, $mode = 1, $required = array(), $limit = 0, &$count = 0) { $query = str_replace('*', '', $query); // requires a working LDAP setup if (!self::ldap() || strlen($query) == 0) { return array(); } // search users using the configured attributes $results = self::$ldap->dosearch(self::$config->get('kolab_users_search_attrib', array('cn','mail','alias')), $query, $mode, $required, $limit, $count); // exclude myself if ($_SESSION['kolab_dn']) { unset($results[$_SESSION['kolab_dn']]); } // resolve to IMAP folder name $root = self::namespace_root('other'); $user_attrib = self::$config->get('kolab_users_id_attrib', self::$config->get('kolab_auth_login', 'mail')); array_walk($results, function(&$user, $dn) use ($root, $user_attrib) { list($localpart, $domain) = explode('@', $user[$user_attrib]); $user['kolabtargetfolder'] = $root . $localpart; }); return $results; } /** * 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 boolean 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 static function list_user_folders($user, $type, $subscribed = null, &$folderdata = array()) { self::setup(); $folders = array(); // use localpart of user attribute as root for folder listing $user_attrib = self::$config->get('kolab_users_id_attrib', self::$config->get('kolab_auth_login', 'mail')); if (!empty($user[$user_attrib])) { list($mbox) = explode('@', $user[$user_attrib]); $delimiter = self::$imap->get_hierarchy_delimiter(); $other_ns = self::namespace_root('other'); $folders = self::list_folders($other_ns . $mbox . $delimiter, '*', $type, $subscribed, $folderdata); } return $folders; } /** * 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 boolean Enable to return subscribed folders only (null to use configured subscription mode) * * @return array List of kolab_storage_folder_user objects */ public static function get_user_folders($type, $subscribed) { $folders = $folderdata = array(); if (self::setup()) { $delimiter = self::$imap->get_hierarchy_delimiter(); $other_ns = rtrim(self::namespace_root('other'), $delimiter); $path_len = count(explode($delimiter, $other_ns)); foreach ((array)self::list_folders($other_ns . $delimiter, '*', '', $subscribed) as $foldername) { if ($foldername == 'INBOX') // skip INBOX which is added by default continue; $path = explode($delimiter, $foldername); // compare folder type if a subfolder is listed if ($type && count($path) > $path_len + 1 && $type != self::folder_type($foldername)) { continue; } // truncate folder path to top-level folders of the 'other' namespace $foldername = join($delimiter, array_slice($path, 0, $path_len + 1)); if (!$folders[$foldername]) { $folders[$foldername] = new kolab_storage_folder_user($foldername, $other_ns); } } // for every (subscribed) user folder, list all (unsubscribed) subfolders foreach ($folders as $userfolder) { foreach ((array)self::list_folders($userfolder->name . $delimiter, '*', $type, false, $folderdata) as $foldername) { if (!$folders[$foldername]) { $folders[$foldername] = new kolab_storage_folder($foldername, $folderdata[$foldername]); $userfolder->children[] = $folders[$foldername]; } } } } return $folders; } /** * 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(); $prefix = 'imap://' . urlencode($args['username']) . '@' . $args['host'] . '/%'; - $db->query("DELETE FROM " . $db->table_name('kolab_folders') . " WHERE resource LIKE ?", $prefix); + $db->query("DELETE FROM " . $db->table_name('kolab_folders', true) . " WHERE `resource` LIKE ?", $prefix); } - } - diff --git a/plugins/libkolab/lib/kolab_storage_cache.php b/plugins/libkolab/lib/kolab_storage_cache.php index eec058d8..4f09e0f6 100644 --- a/plugins/libkolab/lib/kolab_storage_cache.php +++ b/plugins/libkolab/lib/kolab_storage_cache.php @@ -1,1014 +1,1061 @@ * * 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'; 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 $binary_items = array(); protected $extra_cols = array(); protected $order_by = null; protected $limit = null; /** * 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); 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) { - $folders_table = $this->db->table_name('kolab_folders'); - $sql_arr = $this->db->fetch_assoc($this->db->query("SELECT * FROM $folders_table WHERE folder_id=?", $folder_id)); + $folders_table = $this->db->table_name('kolab_folders', true); + $sql_arr = $this->db->fetch_assoc($this->db->query("SELECT * FROM $folders_table WHERE `folder_id` = ?", $folder_id)); if ($sql_arr) { $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->ready = false; return; } // compose fully qualified ressource uri for this instance $this->resource_uri = $this->folder->get_resource_uri(); $this->folders_table = $this->db->table_name('kolab_folders'); $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; } /** * Synchronize local cache data with remote */ public function synchronize() { // only sync once per request cycle if ($this->synched) return; // increase time limit @set_time_limit($this->max_sync_lock_time); if (!$this->ready) { // kolab cache is disabled, synchronize IMAP mailbox cache only $this->imap->folder_sync($this->folder->name); } else { // read cached folder metadata $this->_read_folder_data(); // check cache status hash first ($this->metadata is set in _read_folder_data()) if ($this->metadata['ctag'] != $this->folder->get_ctag()) { // lock synchronization for this folder or wait if locked $this->_sync_lock(); // disable messages cache if configured to do so $this->bypass(true); // synchronize IMAP mailbox cache $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); // determine objects to fetch or to invalidate if (!$imap_index->is_error()) { $imap_index = $imap_index->get(); // read cache index $sql_result = $this->db->query( - "SELECT msguid, uid FROM $this->cache_table WHERE folder_id=?", + "SELECT `msguid`, `uid` FROM `{$this->cache_table}` WHERE `folder_id` = ?", $this->folder_id ); $old_index = array(); while ($sql_arr = $this->db->fetch_assoc($sql_result)) { $old_index[] = $sql_arr['msguid']; $this->uid2msg[$sql_arr['uid']] = $sql_arr['msguid']; } // fetch new objects from imap foreach (array_diff($imap_index, $old_index) as $msguid) { if ($object = $this->folder->read_object($msguid, '*')) { $this->_extended_insert($msguid, $object); } } $this->_extended_insert(0, null); // delete invalid entries from local DB $del_index = array_diff($old_index, $imap_index); if (!empty($del_index)) { $quoted_ids = join(',', array_map(array($this->db, 'quote'), $del_index)); $this->db->query( - "DELETE FROM $this->cache_table WHERE folder_id=? AND msguid IN ($quoted_ids)", + "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `msguid` IN ($quoted_ids)", $this->folder_id ); } // update ctag value (will be written to database in _sync_unlock()) $this->metadata['ctag'] = $this->folder->get_ctag(); } $this->bypass(false); // remove lock $this->_sync_unlock(); } } $this->synched = time(); } /** * 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) { return kolab_storage::get_folder($foldername)->cache->get($msguid, $type); } // 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=?", + "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); } } } return $this->objects[$msguid]; } /** * 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) { kolab_storage::get_folder($foldername)->cache->set($msguid, $object); 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->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; } } /** * 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', 'xml', '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 = ?"; + $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) + $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); } /** * Move an existing cache entry to a new resource * * @param string Entry's IMAP message UID * @param string Entry's Object UID * @param string Target IMAP folder to move it to */ public function move($msguid, $uid, $target_folder) { if ($this->ready) { $target = kolab_storage::get_folder($target_folder); // resolve new message UID in target folder if ($new_msguid = $target->cache->uid2msguid($uid)) { $this->_read_folder_data(); $this->db->query( - "UPDATE $this->cache_table SET folder_id=?, msguid=? ". - "WHERE folder_id=? AND msguid=?", + "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]); } /** * Remove all objects from local cache */ public function purge($type = null) { if (!$this->ready) { return true; } $this->_read_folder_data(); $result = $this->db->query( - "DELETE FROM $this->cache_table WHERE folder_id=?", + "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; } $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=?", + "UPDATE `{$this->folders_table}` SET `resource` = ? ". + "WHERE `resource` = ?", $target->get_resource_uri(), $this->resource_uri ); } /** * 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 * @return array List of Kolab data objects (each represented as hash array) or UIDs */ public function select($query = array(), $uids = 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] : $this->count($query)) < 500; - $sql_query = "SELECT " . ($fetchall ? '*' : 'msguid AS _msguid, uid') . " FROM $this->cache_table ". - "WHERE folder_id=? " . $this->_sql_where($query); + $sql_query = "SELECT " . ($fetchall ? '*' : '`msguid` AS `_msguid`, `uid`') . " FROM `{$this->cache_table}` ". + "WHERE `folder_id` = ? " . $this->_sql_where($query); if (!empty($this->order_by)) { $sql_query .= ' 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 ($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); 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); } if ($index->is_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]); } } 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), + "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); 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); } if ($index->is_error()) { return null; } // TODO: post-filter result according to query $count = $index->count(); } return $count; } /** * Define ORDER BY clause for cache queries */ public function set_order_by($sortcols) { if (!empty($sortcols)) { - $this->order_by = join(', ', (array)$sortcols); + $this->order_by = '`' . join('`, `', (array)$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[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) { $sql_data = array('changed' => null, 'xml' => '', 'tags' => '', 'words' => ''); if ($object['changed']) { $sql_data['changed'] = date('Y-m-d H:i:s', is_object($object['changed']) ? $object['changed']->format('U') : $object['changed']); } if ($object['_formatobj']) { $sql_data['xml'] = preg_replace('!()[\n\r\t\s]+!ms', '$1', (string)$object['_formatobj']->write(3.0)); $sql_data['tags'] = ' ' . join(' ', $object['_formatobj']->get_tags()) . ' '; // pad with spaces for strict/prefix search $sql_data['words'] = ' ' . join(' ', $object['_formatobj']->get_words()) . ' '; } // extract object data $data = array(); foreach ($object as $key => $val) { // skip empty properties if ($val === "" || $val === null) { continue; } // mark binary data to be extracted from xml on unserialize() if (isset($this->binary_items[$key])) { $data[$key] = true; } else if ($key[0] != '_') { $data[$key] = $val; } else if ($key == '_attachments') { foreach ($val as $k => $att) { unset($att['content'], $att['path']); if ($att['id']) $data[$key][$k] = $att; } } } // use base64 encoding (Bug #1912, #2662) $sql_data['data'] = base64_encode(serialize($data)); return $sql_data; } /** * Helper method to turn stored cache data into a valid storage object */ protected function _unserialize($sql_arr) { // check if data is a base64-encoded string, for backward compat. if (strpos(substr($sql_arr['data'], 0, 64), ':') === false) { $sql_arr['data'] = base64_decode($sql_arr['data']); } $object = unserialize($sql_arr['data']); // de-serialization failed if ($object === false) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "Malformed data for {$this->resource_uri}/{$sql_arr['msguid']} object." ), true); return null; } // decode binary properties foreach ($this->binary_items as $key => $regexp) { if (!empty($object[$key]) && preg_match($regexp, $sql_arr['xml'], $m)) { $object[$key] = base64_decode($m[1]); } } $object_type = $sql_arr['type'] ?: $this->folder->type; $format_type = $this->folder->type == 'configuration' ? 'configuration' : $object_type; // add meta data $object['_type'] = $object_type; $object['_msguid'] = $sql_arr['msguid']; $object['_mailbox'] = $this->folder->name; $object['_size'] = strlen($sql_arr['xml']); $object['_formatobj'] = kolab_format::factory($format_type, 3.0, $sql_arr['xml']); 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 = ''; if ($object) { $sql_data = $this->_serialize($object); + + // Skip multifolder insert for Oracle, we can't put long data inline + if ($this->db->db_provider == 'oracle') { + $extra_cols = ''; + if ($this->extra_cols) { + $extra_cols = array_map(function($n) { return "`{$n}`"; }, $this->extra_cols); + $extra_cols = ', ' . join(', ', $extra_cols); + $extra_args = str_repeat(', ?', count($this->extra_cols)); + } + + $params = array($this->folder_id, $msguid, $object['uid'], $sql_data['changed'], + $sql_data['data'], $sql_data['xml'], $sql_data['tags'], $sql_data['words']); + + foreach ($this->extra_cols as $col) { + $params[] = $sql_data[$col]; + } + + $result = $this->db->query( + "INSERT INTO `{$this->cache_table}` " + . " (`folder_id`, `msguid`, `uid`, `created`, `changed`, `data`, `xml`, `tags`, `words` $extra_cols)" + . " VALUES (?, ?, ?, " . $this->db->now() . ", ?, ?, ?, ?, ? $extra_args)", + $params + ); + + if (!$this->db->affected_rows($result)) { + rcube::raise_error(array( + 'code' => 900, 'type' => 'php', + '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['xml']), $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()))) { - $extra_cols = $this->extra_cols ? ', ' . join(', ', $this->extra_cols) : ''; + $extra_cols = ''; + if ($this->extra_cols) { + $extra_cols = array_map(function($n) { return "`{$n}`"; }, $this->extra_cols); + $extra_cols = ', ' . join(', ', $extra_cols); + } + $result = $this->db->query( - "INSERT INTO $this->cache_table ". - " (folder_id, msguid, uid, created, changed, data, xml, tags, words $extra_cols)". + "INSERT INTO `{$this->cache_table}` ". + " (`folder_id`, `msguid`, `uid`, `created`, `changed`, `data`, `xml`, `tags`, `words` $extra_cols)". " VALUES $buffer" ); + if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', '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 FROM $this->folders_table WHERE resource=?", $this->resource_uri)); + $sql_arr = $this->db->fetch_assoc($this->db->query( + "SELECT `folder_id`, `synclock`, `ctag`" + . " 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->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(); - $sql_query = "SELECT synclock, ctag FROM $this->folders_table WHERE folder_id=?"; + $sql_query = "SELECT `synclock`, `ctag` FROM `{$this->folders_table}` WHERE `folder_id` = ?"; // abort if database is not set-up if ($this->db->is_error()) { $this->ready = false; return; } $this->synclock = true; // wait if locked (expire locks after 10 minutes) while ($this->metadata && intval($this->metadata['synclock']) > 0 && $this->metadata['synclock'] + $this->max_sync_lock_time > time()) { usleep(500000); $this->metadata = $this->db->fetch_assoc($this->db->query($sql_query, $this->folder_id)); } // set lock - $this->db->query("UPDATE $this->folders_table SET synclock = ? WHERE folder_id = ?", time(), $this->folder_id); + $this->db->query("UPDATE `{$this->folders_table}` SET `synclock` = ? WHERE `folder_id` = ?", time(), $this->folder_id); } /** * 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 = ? WHERE folder_id = ?", + "UPDATE `{$this->folders_table}` SET `synclock` = 0, `ctag` = ? WHERE `folder_id` = ?", $this->metadata['ctag'], $this->folder_id ); $this->synclock = false; } /** * 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", + "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; } /** * Bypass Roundcube messages cache. * Roundcube cache duplicates information already stored in kolab_cache. * * @param bool $disable True disables, False enables messages cache */ public function bypass($disable = false) { // 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 ($disable) { $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 cache completely $this->imap->set_messages_caching(!$disable); break; case 1: // We'll disable messages cache, but keep index cache. // Default mode is both (MODE_INDEX | MODE_MESSAGE) $mode = rcube_imap_cache::MODE_INDEX; if (!$disable) { $mode |= rcube_imap_cache::MODE_MESSAGE; } $this->imap->set_messages_caching(true, $mode); } } } } diff --git a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php index 5af57300..2134302c 100644 --- a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php +++ b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php @@ -1,1303 +1,1301 @@ * * Copyright (C) 2012, 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 tasklist_kolab_driver extends tasklist_driver { // features supported by the backend public $alarms = false; public $attachments = true; public $attendees = true; public $undelete = false; // task undelete action public $alarm_types = array('DISPLAY','AUDIO'); public $search_more_results; private $rc; private $plugin; private $lists; private $folders = array(); private $tasks = array(); private $tags = array(); /** * Default constructor */ public function __construct($plugin) { $this->rc = $plugin->rc; $this->plugin = $plugin; if (kolab_storage::$version == '2.0') { $this->alarm_absolute = false; } // tasklist use fully encoded identifiers kolab_storage::$encode_ids = true; $this->_read_lists(); $this->plugin->register_action('folder-acl', array($this, 'folder_acl')); } /** * Read available calendars for the current user and store them internally */ private function _read_lists($force = false) { // already read sources if (isset($this->lists) && !$force) return $this->lists; // get all folders that have type "task" $folders = kolab_storage::sort_folders(kolab_storage::get_folders('task')); $this->lists = $this->folders = array(); $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); // find default folder $default_index = 0; foreach ($folders as $i => $folder) { if ($folder->default && strpos($folder->name, $delim) === false) $default_index = $i; } // put default folder (aka INBOX) on top of the list if ($default_index > 0) { $default_folder = $folders[$default_index]; unset($folders[$default_index]); array_unshift($folders, $default_folder); } $prefs = $this->rc->config->get('kolab_tasklists', array()); foreach ($folders as $folder) { $tasklist = $this->folder_props($folder, $delim, $prefs); $this->lists[$tasklist['id']] = $tasklist; $this->folders[$tasklist['id']] = $folder; $this->folders[$folder->name] = $folder; } } /** * Derive list properties from the given kolab_storage_folder object */ protected function folder_props($folder, $delim, $prefs) { if ($folder->get_namespace() == 'personal') { $norename = false; $readonly = false; $alarms = true; } else { $alarms = false; $readonly = true; if (($rights = $folder->get_myrights()) && !PEAR::isError($rights)) { if (strpos($rights, 'i') !== false) $readonly = false; } $info = $folder->get_folder_info(); $norename = $readonly || $info['norename'] || $info['protected']; } $list_id = $folder->id; #kolab_storage::folder_id($folder->name); $old_id = kolab_storage::folder_id($folder->name, false); if (!isset($prefs[$list_id]['showalarms']) && isset($prefs[$old_id]['showalarms'])) { $prefs[$list_id]['showalarms'] = $prefs[$old_id]['showalarms']; } return array( 'id' => $list_id, 'name' => $folder->get_name(), 'listname' => $folder->get_foldername(), 'editname' => $folder->get_foldername(), 'color' => $folder->get_color('0000CC'), 'showalarms' => isset($prefs[$list_id]['showalarms']) ? $prefs[$list_id]['showalarms'] : $alarms, 'editable' => !$readonly, 'norename' => $norename, 'active' => $folder->is_active(), 'parentfolder' => $folder->get_parent(), 'default' => $folder->default, 'virtual' => $folder->virtual, 'children' => true, // TODO: determine if that folder indeed has child folders 'subscribed' => (bool)$folder->is_subscribed(), 'removable' => !$folder->default, 'group' => $folder->default ? 'default' : $folder->get_namespace(), 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), ); } /** * Get a list of available task lists from this source */ public function get_lists(&$tree = null) { // attempt to create a default list for this user if (empty($this->lists) && !isset($this->search_more_results)) { $prop = array('name' => 'Tasks', 'color' => '0000CC', 'default' => true); if ($this->create_list($prop)) $this->_read_lists(true); } $folders = array(); foreach ($this->lists as $id => $list) { if (!empty($this->folders[$id])) { $folders[] = $this->folders[$id]; } } // include virtual folders for a full folder tree if (!is_null($tree)) { $folders = kolab_storage::folder_hierarchy($folders, $tree); } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $prefs = $this->rc->config->get('kolab_tasklists', array()); $lists = array(); foreach ($folders as $folder) { $list_id = $folder->id; #kolab_storage::folder_id($folder->name); $imap_path = explode($delim, $folder->name); // find parent do { array_pop($imap_path); $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !$this->folders[$parent_id]); // restore "real" parent ID if ($parent_id && !$this->folders[$parent_id]) { $parent_id = kolab_storage::folder_id($folder->get_parent()); } $fullname = $folder->get_name(); $listname = $folder->get_foldername(); // special handling for virtual folders if ($folder instanceof kolab_storage_folder_user) { $lists[$list_id] = array( 'id' => $list_id, 'name' => $folder->get_name(), 'listname' => $listname, 'title' => $folder->get_title(), 'virtual' => true, 'editable' => false, 'group' => 'other virtual', 'class' => 'user', 'parent' => $parent_id, ); } else if ($folder->virtual) { $lists[$list_id] = array( 'id' => $list_id, 'name' => kolab_storage::object_name($fullname), 'listname' => $listname, 'virtual' => true, 'editable' => false, 'group' => $folder->get_namespace(), 'class' => 'folder', 'parent' => $parent_id, ); } else { if (!$this->lists[$list_id]) { $this->lists[$list_id] = $this->folder_props($folder, $delim, $prefs); $this->folders[$list_id] = $folder; } $this->lists[$list_id]['parent'] = $parent_id; $lists[$list_id] = $this->lists[$list_id]; } } return $lists; } /** * Get the kolab_calendar instance for the given calendar ID * * @param string List identifier (encoded imap folder name) * @return object kolab_storage_folder Object nor null if list doesn't exist */ protected function get_folder($id) { // create list and folder instance if necesary if (!$this->lists[$id]) { $folder = kolab_storage::get_folder(kolab_storage::id_decode($id)); if ($folder->type) { $this->folders[$id] = $folder; $this->lists[$id] = $this->folder_props($folder, $this->rc->get_storage()->get_hierarchy_delimiter(), $this->rc->config->get('kolab_tasklists', array())); } } return $this->folders[$id]; } /** * Create a new list assigned to the current user * * @param array Hash array with list properties * name: List name * color: The color of the list * showalarms: True if alarms are enabled * @return mixed ID of the new list on success, False on error */ public function create_list(&$prop) { $prop['type'] = 'task' . ($prop['default'] ? '.default' : ''); $prop['active'] = true; // activate folder by default $prop['subscribed'] = true; $folder = kolab_storage::folder_update($prop); if ($folder === false) { $this->last_error = kolab_storage::$last_error; return false; } // create ID $id = kolab_storage::folder_id($folder); $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', array()); if (isset($prop['showalarms'])) $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_tasklists'][$id]) $this->rc->user->save_prefs($prefs); // force page reload to properly render folder hierarchy if (!empty($prop['parent'])) { $prop['_reload'] = true; } else { $folder = kolab_storage::get_folder($folder); $prop += $this->folder_props($folder, $this->rc->get_storage()->get_hierarchy_delimiter(), array()); } return $id; } /** * Update properties of an existing tasklist * * @param array Hash array with list properties * id: List Identifier * name: List name * color: The color of the list * showalarms: True if alarms are enabled (if supported) * @return boolean True on success, Fales on failure */ public function edit_list(&$prop) { if ($prop['id'] && ($folder = $this->get_folder($prop['id']))) { $prop['oldname'] = $folder->name; $prop['type'] = 'task'; $newfolder = kolab_storage::folder_update($prop); if ($newfolder === false) { $this->last_error = kolab_storage::$last_error; return false; } // create ID $id = kolab_storage::folder_id($newfolder); // fallback to local prefs $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', array()); unset($prefs['kolab_tasklists'][$prop['id']]); if (isset($prop['showalarms'])) $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_tasklists'][$id]) $this->rc->user->save_prefs($prefs); // force page reload if folder name/hierarchy changed if ($newfolder != $prop['oldname']) $prop['_reload'] = true; return $id; } return false; } /** * Set active/subscribed state of a list * * @param array Hash array with list properties * id: List Identifier * active: True if list is active, false if not * permanent: True if list is to be subscribed permanently * @return boolean True on success, Fales on failure */ public function subscribe_list($prop) { if ($prop['id'] && ($folder = $this->get_folder($prop['id']))) { $ret = false; if (isset($prop['permanent'])) $ret |= $folder->subscribe(intval($prop['permanent'])); if (isset($prop['active'])) $ret |= $folder->activate(intval($prop['active'])); // apply to child folders, too if ($prop['recursive']) { foreach ((array)kolab_storage::list_folders($folder->name, '*', 'task') as $subfolder) { if (isset($prop['permanent'])) ($prop['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder)); if (isset($prop['active'])) ($prop['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder)); } } return $ret; } return false; } /** * Delete the given list with all its contents * * @param array Hash array with list properties * id: list Identifier * @return boolean True on success, Fales on failure */ public function delete_list($prop) { if ($prop['id'] && ($folder = $this->get_folder($prop['id']))) { if (kolab_storage::folder_delete($folder->name)) return true; else $this->last_error = kolab_storage::$last_error; } return false; } /** * Search for shared or otherwise not listed tasklists the user has access * * @param string Search string * @param string Section/source to search * @return array List of tasklists */ public function search_lists($query, $source) { if (!kolab_storage::setup()) { return array(); } $this->search_more_results = false; $this->lists = $this->folders = array(); $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('task', $query, array('other')) as $folder) { $this->folders[$folder->id] = $folder; $this->lists[$folder->id] = $this->folder_props($folder, $delim, array()); } } // search other user's namespace via LDAP else if ($source == 'users') { $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number foreach (kolab_storage::search_users($query, 0, array(), $limit * 10) as $user) { $folders = array(); // search for tasks folders shared by this user foreach (kolab_storage::list_user_folders($user, 'task', false) as $foldername) { $folders[] = new kolab_storage_folder($foldername, 'task'); } if (count($folders)) { $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); $this->folders[$userfolder->id] = $userfolder; $this->lists[$userfolder->id] = $this->folder_props($userfolder, $delim, array()); foreach ($folders as $folder) { $this->folders[$folder->id] = $folder; $this->lists[$folder->id] = $this->folder_props($folder, $delim, array()); $count++; } } if ($count >= $limit) { $this->search_more_results = true; break; } } } return $this->get_lists(); } /** * Get a list of tags to assign tasks to * * @return array List of tags */ public function get_tags() { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags(); $backend_tags = array_map(function($v) { return $v['name']; }, $tags); return array_values(array_unique(array_merge($this->tags, $backend_tags))); } /** * Get number of tasks matching the given filter * * @param array List of lists to count tasks of * @return array Hash array with counts grouped by status (all|flagged|completed|today|tomorrow|nodate) */ public function count_tasks($lists = null) { if (empty($lists)) $lists = array_keys($this->lists); else if (is_string($lists)) $lists = explode(',', $lists); $today_date = new DateTime('now', $this->plugin->timezone); $today = $today_date->format('Y-m-d'); $tomorrow_date = new DateTime('now + 1 day', $this->plugin->timezone); $tomorrow = $tomorrow_date->format('Y-m-d'); $counts = array('all' => 0, 'flagged' => 0, 'today' => 0, 'tomorrow' => 0, 'overdue' => 0, 'nodate' => 0, 'mytasks' => 0); foreach ($lists as $list_id) { if (!$folder = $this->get_folder($list_id)) { continue; } foreach ($folder->select(array(array('tags','!~','x-complete'))) as $record) { $rec = $this->_to_rcube_task($record); if ($this->is_complete($rec)) // don't count complete tasks continue; $counts['all']++; if ($rec['flagged']) $counts['flagged']++; if (empty($rec['date'])) $counts['nodate']++; else if ($rec['date'] == $today) $counts['today']++; else if ($rec['date'] == $tomorrow) $counts['tomorrow']++; else if ($rec['date'] < $today) $counts['overdue']++; if ($this->plugin->is_attendee($rec) !== false) $counts['mytasks']++; } } // avoid session race conditions that will loose temporary subscriptions $this->plugin->rc->session->nowrite = true; return $counts; } /** * Get all taks records matching the given filter * * @param array Hash array with filter criterias: * - mask: Bitmask representing the filter selection (check against tasklist::FILTER_MASK_* constants) * - from: Date range start as string (Y-m-d) * - to: Date range end as string (Y-m-d) * - search: Search query string * @param array List of lists to get tasks from * @return array List of tasks records matchin the criteria */ public function list_tasks($filter, $lists = null) { if (empty($lists)) $lists = array_keys($this->lists); else if (is_string($lists)) $lists = explode(',', $lists); $results = array(); // query Kolab storage $query = array(); if ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE) $query[] = array('tags','~','x-complete'); else if (empty($filter['since'])) $query[] = array('tags','!~','x-complete'); // full text search (only works with cache enabled) if ($filter['search']) { $search = mb_strtolower($filter['search']); foreach (rcube_utils::normalize_string($search, true) as $word) { $query[] = array('words', '~', $word); } } if ($filter['since']) { $query[] = array('changed', '>=', $filter['since']); } // load all tags into memory first kolab_storage_config::get_instance()->get_tags(); foreach ($lists as $list_id) { if (!$folder = $this->get_folder($list_id)) { continue; } foreach ($folder->select($query) as $record) { $this->load_tags($record); $task = $this->_to_rcube_task($record); $task['list'] = $list_id; // TODO: post-filter tasks returned from storage $results[] = $task; } } // avoid session race conditions that will loose temporary subscriptions $this->plugin->rc->session->nowrite = true; return $results; } /** * Return data of a specific task * * @param mixed Hash array with task properties or task UID * @return array Hash array with task properties or false if not found */ public function get_task($prop) { $id = is_array($prop) ? ($prop['uid'] ?: $prop['id']) : $prop; $list_id = is_array($prop) ? $prop['list'] : null; $folders = $list_id ? array($list_id => $this->get_folder($list_id)) : $this->folders; // find task in the available folders foreach ($folders as $list_id => $folder) { if (is_numeric($list_id) || !$folder) continue; if (!$this->tasks[$id] && ($object = $folder->get_object($id))) { $this->load_tags($object); $this->tasks[$id] = $this->_to_rcube_task($object); $this->tasks[$id]['list'] = $list_id; break; } } return $this->tasks[$id]; } /** * Get all decendents of the given task record * * @param mixed Hash array with task properties or task UID * @param boolean True if all childrens children should be fetched * @return array List of all child task IDs */ public function get_childs($prop, $recursive = false) { if (is_string($prop)) { $task = $this->get_task($prop); $prop = array('id' => $task['id'], 'list' => $task['list']); } $childs = array(); $list_id = $prop['list']; $task_ids = array($prop['id']); $folder = $this->get_folder($list_id); // query for childs (recursively) while ($folder && !empty($task_ids)) { $query_ids = array(); foreach ($task_ids as $task_id) { $query = array(array('tags','=','x-parent:' . $task_id)); foreach ($folder->select($query) as $record) { // don't rely on kolab_storage_folder filtering if ($record['parent_id'] == $task_id) { $childs[] = $record['uid']; $query_ids[] = $record['uid']; } } } if (!$recursive) break; $task_ids = $query_ids; } return $childs; } /** * Get a list of pending alarms to be displayed to the user * * @param integer Current time (unix timestamp) * @param mixed List of list IDs to show alarms for (either as array or comma-separated string) * @return array A list of alarms, each encoded as hash array with task properties * @see tasklist_driver::pending_alarms() */ public function pending_alarms($time, $lists = 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 array(); if ($lists && is_string($lists)) $lists = explode(',', $lists); $time = $slot + $interval; $candidates = array(); $query = array(array('tags', '=', 'x-has-alarms'), array('tags', '!=', 'x-complete')); foreach ($this->lists as $lid => $list) { // skip lists with alarms disabled if (!$list['showalarms'] || ($lists && !in_array($lid, $lists))) continue; $folder = $this->get_folder($lid); foreach ($folder->select($query) as $record) { if (!($record['valarms'] || $record['alarms']) || $record['status'] == 'COMPLETED' || $record['complete'] == 100) // don't trust query :-) continue; $task = $this->_to_rcube_task($record); // add to list if alarm is set $alarm = libcalendaring::get_next_alarm($task, 'task'); if ($alarm && $alarm['time'] && $alarm['time'] <= $time && in_array($alarm['action'], $this->alarm_types)) { $id = $alarm['id']; // use alarm-id as primary identifier $candidates[$id] = array( 'id' => $id, 'title' => $task['title'], 'date' => $task['date'], 'time' => $task['time'], 'notifyat' => $alarm['time'], 'action' => $alarm['action'], ); } } } // get alarm information stored in local database if (!empty($candidates)) { $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates)); - $result = $this->rc->db->query(sprintf( - "SELECT * FROM " . $this->rc->db->table_name('kolab_alarms') . " - WHERE alarm_id IN (%s) AND user_id=?", - join(',', $alarm_ids), - $this->rc->db->now() - ), + $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 && ($rec = $this->rc->db->fetch_assoc($result))) { $dbdata[$rec['alarm_id']] = $rec; } } $alarms = array(); foreach ($candidates as $id => $task) { // skip dismissed if ($dbdata[$id]['dismissed']) continue; // snooze function may have shifted alarm time $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $task['notifyat']; if ($notifyat <= $time) $alarms[] = $task; } return $alarms; } /** * (User) feedback after showing an alarm notification * This should mark the alarm as 'shown' or snooze it for the given amount of time * * @param string Task identifier * @param integer Suspend the alarm for this number of seconds */ public function dismiss_alarm($id, $snooze = 0) { // delete old alarm entry $this->rc->db->query( - "DELETE FROM " . $this->rc->db->table_name('kolab_alarms') . " - WHERE alarm_id=? AND user_id=?", + "DELETE FROM " . $this->rc->db->table_name('kolab_alarms', true) . " + WHERE `alarm_id` = ? AND `user_id` = ?", $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 " . $this->rc->db->table_name('kolab_alarms') . " - (alarm_id, user_id, dismissed, notifyat) - VALUES(?, ?, ?, ?)", + "INSERT INTO " . $this->rc->db->table_name('kolab_alarms', true) . " + (`alarm_id`, `user_id`, `dismissed`, `notifyat`) + VALUES (?, ?, ?, ?)", $id, $this->rc->user->ID, $snooze > 0 ? 0 : 1, $notifyat ); return $this->rc->db->affected_rows($query); } /** * Remove alarm dismissal or snooze state * * @param string Task identifier */ public function clear_alarms($id) { // delete alarm entry $this->rc->db->query( - "DELETE FROM " . $this->rc->db->table_name('kolab_alarms') . " - WHERE alarm_id=? AND user_id=?", + "DELETE FROM " . $this->rc->db->table_name('kolab_alarms', true) . " + WHERE `alarm_id` = ? AND `user_id` = ?", $id, $this->rc->user->ID ); return true; } /** * Get task tags */ private function load_tags(&$object) { // this task hasn't been migrated yet if (!empty($object['categories'])) { // OPTIONAL: call kolab_storage_config::apply_tags() to migrate the object $object['tags'] = (array)$object['categories']; if (!empty($object['tags'])) { $this->tags = array_merge($this->tags, $object['tags']); } } else { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($object['uid']); $object['tags'] = array_map(function($v) { return $v['name']; }, $tags); } } /** * Update task tags */ private function save_tags($uid, $tags) { $config = kolab_storage_config::get_instance(); $config->save_tags($uid, $tags); } /** * Convert from Kolab_Format to internal representation */ private function _to_rcube_task($record) { $task = array( 'id' => $record['uid'], 'uid' => $record['uid'], 'title' => $record['title'], // 'location' => $record['location'], 'description' => $record['description'], 'flagged' => $record['priority'] == 1, 'complete' => floatval($record['complete'] / 100), 'status' => $record['status'], 'parent_id' => $record['parent_id'], 'recurrence' => $record['recurrence'], 'attendees' => $record['attendees'], 'organizer' => $record['organizer'], 'sequence' => $record['sequence'], 'tags' => $record['tags'], ); // convert from DateTime to internal date format if (is_a($record['due'], 'DateTime')) { $due = $this->plugin->lib->adjust_timezone($record['due']); $task['date'] = $due->format('Y-m-d'); if (!$record['due']->_dateonly) $task['time'] = $due->format('H:i'); } // convert from DateTime to internal date format if (is_a($record['start'], 'DateTime')) { $start = $this->plugin->lib->adjust_timezone($record['start']); $task['startdate'] = $start->format('Y-m-d'); if (!$record['start']->_dateonly) $task['starttime'] = $start->format('H:i'); } if (is_a($record['changed'], 'DateTime')) { $task['changed'] = $record['changed']; } if (is_a($record['created'], 'DateTime')) { $task['created'] = $record['created']; } if ($record['valarms']) { $task['valarms'] = $record['valarms']; } else if ($record['alarms']) { $task['alarms'] = $record['alarms']; } if (!empty($task['attendees'])) { foreach ((array)$task['attendees'] as $i => $attendee) { if (is_array($attendee['delegated-from'])) { $task['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); } if (is_array($attendee['delegated-to'])) { $task['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); } } } if (!empty($record['_attachments'])) { foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (!$attachment['name']) $attachment['name'] = $key; $attachments[] = $attachment; } } $task['attachments'] = $attachments; } return $task; } /** * Convert the given task record into a data structure that can be passed to kolab_storage backend for saving * (opposite of self::_to_rcube_event()) */ private function _from_rcube_task($task, $old = array()) { $object = $task; if (!empty($task['date'])) { $object['due'] = rcube_utils::anytodatetime($task['date'].' '.$task['time'], $this->plugin->timezone); if (empty($task['time'])) $object['due']->_dateonly = true; unset($object['date']); } if (!empty($task['startdate'])) { $object['start'] = rcube_utils::anytodatetime($task['startdate'].' '.$task['starttime'], $this->plugin->timezone); if (empty($task['starttime'])) $object['start']->_dateonly = true; unset($object['startdate']); } $object['complete'] = $task['complete'] * 100; if ($task['complete'] == 1.0 && empty($task['complete'])) $object['status'] = 'COMPLETED'; if ($task['flagged']) $object['priority'] = 1; else $object['priority'] = $old['priority'] > 1 ? $old['priority'] : 0; // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($object[$key]) && $key[0] == '_') $object[$key] = $val; } // copy recurrence rules if the client didn't submit it (#2713) if (!array_key_exists('recurrence', $object) && $old['recurrence']) { $object['recurrence'] = $old['recurrence']; } // delete existing attachment(s) if (!empty($task['deleted_attachments'])) { foreach ($task['deleted_attachments'] as $attachment) { if (is_array($object['_attachments'])) { foreach ($object['_attachments'] as $idx => $att) { if ($att['id'] == $attachment) $object['_attachments'][$idx] = false; } } } unset($task['deleted_attachments']); } // in kolab_storage attachments are indexed by content-id if (is_array($task['attachments'])) { foreach ($task['attachments'] as $idx => $attachment) { $key = null; // Roundcube ID has nothing to do with the storage ID, remove it if ($attachment['content'] || $attachment['path']) { unset($attachment['id']); } else { foreach ((array)$old['_attachments'] as $cid => $oldatt) { if ($oldatt && $attachment['id'] == $oldatt['id']) $key = $cid; } } // replace existing entry if ($key) { $object['_attachments'][$key] = $attachment; } // append as new attachment else { $object['_attachments'][] = $attachment; } } unset($object['attachments']); } // allow sequence increments if I'm the organizer if ($this->plugin->is_organizer($object)) { unset($object['sequence']); } else if (isset($old['sequence'])) { $object['sequence'] = $old['sequence']; } unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['tags'], $object['created']); return $object; } /** * Add a single task to the database * * @param array Hash array with task properties (see header of tasklist_driver.php) * @return mixed New task ID on success, False on error */ public function create_task($task) { return $this->edit_task($task); } /** * Update an task entry with the given data * * @param array Hash array with task properties (see header of tasklist_driver.php) * @return boolean True on success, False on error */ public function edit_task($task) { $list_id = $task['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) return false; // tags are stored separately $tags = $task['tags']; unset($task['tags']); // moved from another folder if ($task['_fromlist'] && ($fromfolder = $this->get_folder($task['_fromlist']))) { if (!$fromfolder->move($task['id'], $folder->name)) return false; unset($task['_fromlist']); } // load previous version of this task to merge if ($task['id']) { $old = $folder->get_object($task['id']); if (!$old || PEAR::isError($old)) return false; // merge existing properties if the update isn't complete if (!isset($task['title']) || !isset($task['complete'])) $task += $this->_to_rcube_task($old); } // generate new task object from RC input $object = $this->_from_rcube_task($task, $old); $saved = $folder->save($object, 'task', $task['id']); if (!$saved) { raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving task object to Kolab server"), true, false); $saved = false; } else { // save tags in configuration.relation object $this->save_tags($object['uid'], $tags); $task = $this->_to_rcube_task($object); $task['list'] = $list_id; $task['tags'] = (array) $tags; $this->tasks[$task['id']] = $task; } return $saved; } /** * Move a single task to another list * * @param array Hash array with task properties: * @return boolean True on success, False on error * @see tasklist_driver::move_task() */ public function move_task($task) { $list_id = $task['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) return false; // execute move command if ($task['_fromlist'] && ($fromfolder = $this->get_folder($task['_fromlist']))) { return $fromfolder->move($task['id'], $folder->name); } return false; } /** * Remove a single task from the database * * @param array Hash array with task properties: * id: Task identifier * @param boolean Remove record irreversible (mark as deleted otherwise, if supported by the backend) * @return boolean True on success, False on error */ public function delete_task($task, $force = true) { $list_id = $task['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) return false; $status = $folder->delete($task['id']); if ($status) { // remove tag assignments // @TODO: don't do this when undelete feature will be implemented $this->save_tags($task['id'], null); } return $status; } /** * Restores a single deleted task (if supported) * * @param array Hash array with task properties: * id: Task identifier * @return boolean True on success, False on error */ public function undelete_task($prop) { // TODO: implement this return false; } /** * Get attachment properties * * @param string $id Attachment identifier * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier * * @return array Hash array with attachment properties: * id: Attachment identifier * name: Attachment name * mimetype: MIME content type of the attachment * size: Attachment size */ public function get_attachment($id, $task) { $task['uid'] = $task['id']; $task = $this->get_task($task); if ($task && !empty($task['attachments'])) { foreach ($task['attachments'] as $att) { if ($att['id'] == $id) return $att; } } return null; } /** * Get attachment body * * @param string $id Attachment identifier * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier * * @return string Attachment body */ public function get_attachment_body($id, $task) { if ($storage = $this->get_folder($task['list'])) { return $storage->get_attachment($task['id'], $id); } return false; } /** * */ public function tasklist_edit_form($action, $list, $fieldprop) { if ($list['id'] && ($list = $this->lists[$list['id']])) { $folder_name = $this->get_folder($list['id'])->name; // UTF7 } else { $folder_name = ''; } $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder_name)) { $path_imap = explode($delim, $folder_name); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder_name); } else { $path_imap = ''; } $hidden_fields[] = array('name' => 'oldname', 'value' => $folder_name); // folder name (default field) $input_name = new html_inputfield(array('name' => 'name', 'id' => 'taskedit-tasklistame', 'size' => 20)); $fieldprop['name']['value'] = $input_name->show($list['editname'], array('disabled' => ($options['norename'] || $options['protected']))); // prevent user from moving folder if (!empty($options) && ($options['norename'] || $options['protected'])) { $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); } else { $select = kolab_storage::folder_selector('task', array('name' => 'parent', 'id' => 'taskedit-parentfolder'), $folder_name); $fieldprop['parent'] = array( 'id' => 'taskedit-parentfolder', 'label' => $this->plugin->gettext('parentfolder'), 'value' => $select->show($path_imap), ); } // General tab $form['properties'] = array( 'name' => $this->rc->gettext('properties'), 'fields' => array(), ); foreach (array('name','parent','showalarms') as $f) { $form['properties']['fields'][$f] = $fieldprop[$f]; } // add folder ACL tab if ($action != 'form-new') { $form['sharing'] = array( 'name' => Q($this->plugin->gettext('tabsharing')), 'content' => html::tag('iframe', array( 'src' => $this->rc->url(array('_action' => 'folder-acl', '_folder' => $folder_name, 'framed' => 1)), 'width' => '100%', 'height' => 280, 'border' => 0, 'style' => 'border:0'), '') ); } $form_html = ''; if (is_array($hidden_fields)) { foreach ($hidden_fields as $field) { $hiddenfield = new html_hiddenfield($field); $form_html .= $hiddenfield->show() . "\n"; } } // create form output foreach ($form as $tab) { if (is_array($tab['fields']) && empty($tab['content'])) { $table = new html_table(array('cols' => 2)); foreach ($tab['fields'] as $col => $colprop) { $label = !empty($colprop['label']) ? $colprop['label'] : $this->plugin->gettext($col); $table->add('title', html::label($colprop['id'], Q($label))); $table->add(null, $colprop['value']); } $content = $table->show(); } else { $content = $tab['content']; } if (!empty($content)) { $form_html .= html::tag('fieldset', null, html::tag('legend', null, Q($tab['name'])) . $content) . "\n"; } } return $form_html; } /** * Handler to render ACL form for a notes folder */ public function folder_acl() { $this->plugin->require_plugin('acl'); $this->rc->output->add_handler('folderacl', array($this, 'folder_acl_form')); $this->rc->output->send('tasklist.kolabacl'); } /** * Handler for ACL form template object */ public function folder_acl_form() { $folder = rcube_utils::get_input_value('_folder', RCUBE_INPUT_GPC); if (strlen($folder)) { $storage = $this->rc->get_storage(); $options = $storage->folder_info($folder); // get sharing UI from acl plugin $acl = $this->rc->plugins->exec_hook('folder_form', array('form' => array(), 'options' => $options, 'name' => $folder)); } return $acl['form']['sharing']['content'] ?: html::div('hint', $this->plugin->gettext('aclnorights')); } }