diff --git a/plugins/kolab_addressbook/kolab_addressbook.php b/plugins/kolab_addressbook/kolab_addressbook.php index d5d7bac6..07c85f17 100644 --- a/plugins/kolab_addressbook/kolab_addressbook.php +++ b/plugins/kolab_addressbook/kolab_addressbook.php @@ -1,1142 +1,1142 @@ * @author Aleksander Machniak * * Copyright (C) 2011-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_addressbook extends rcube_plugin { public $task = '?(?!logout).*'; public $driver; public $bonnie_api = false; private $sources; private $rc; private $ui; private $recurrent = false; const GLOBAL_FIRST = 0; const PERSONAL_FIRST = 1; const GLOBAL_ONLY = 2; const PERSONAL_ONLY = 3; /** * Startup method of a Roundcube plugin */ public function init() { $this->rc = rcube::get_instance(); // load required plugin $this->require_plugin('libkolab'); $this->load_config(); $driver = $this->rc->config->get('kolab_addressbook_driver') ?: 'kolab'; $driver_class = "{$driver}_contacts_driver"; require_once dirname(__FILE__) . "/drivers/{$driver}/{$driver}_contacts_driver.php"; require_once dirname(__FILE__) . "/drivers/{$driver}/{$driver}_contacts.php"; $this->driver = new $driver_class($this); // register hooks $this->add_hook('addressbooks_list', array($this, 'address_sources')); $this->add_hook('addressbook_get', array($this, 'get_address_book')); $this->add_hook('config_get', array($this, 'config_get')); if ($this->rc->task == 'addressbook') { $this->add_texts('localization'); if ($this->driver instanceof kolab_contacts_driver) { $this->add_hook('contact_form', array($this, 'contact_form')); $this->add_hook('contact_photo', array($this, 'contact_photo')); } $this->add_hook('template_object_directorylist', array($this, 'directorylist_html')); // Plugin actions $this->register_action('plugin.book', array($this, 'book_actions')); $this->register_action('plugin.book-save', array($this, 'book_save')); $this->register_action('plugin.book-search', array($this, 'book_search')); $this->register_action('plugin.book-subscribe', array($this, 'book_subscribe')); $this->register_action('plugin.contact-changelog', array($this, 'contact_changelog')); $this->register_action('plugin.contact-diff', array($this, 'contact_diff')); $this->register_action('plugin.contact-restore', array($this, 'contact_restore')); // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); // Load UI elements if ($this->api->output->type == 'html') { require_once $this->home . '/lib/kolab_addressbook_ui.php'; $this->ui = new kolab_addressbook_ui($this); if ($this->bonnie_api) { $this->add_button(array( 'command' => 'contact-history-dialog', 'class' => 'history contact-history disabled', 'classact' => 'history contact-history active', 'innerclass' => 'icon inner', 'label' => 'kolab_addressbook.showhistory', 'type' => 'link-menuitem' ), 'contactmenu'); } } } else if ($this->rc->task == 'settings') { $this->add_texts('localization'); $this->add_hook('preferences_list', array($this, 'prefs_list')); $this->add_hook('preferences_save', array($this, 'prefs_save')); } if ($this->driver instanceof kolab_contacts_driver) { $this->add_hook('folder_delete', array($this, 'prefs_folder_delete')); $this->add_hook('folder_rename', array($this, 'prefs_folder_rename')); $this->add_hook('folder_update', array($this, 'prefs_folder_update')); } } /** * Handler for the addressbooks_list hook. * * This will add all instances of available Kolab-based address books * to the list of address sources of Roundcube. * This will also hide some addressbooks according to kolab_addressbook_prio setting. * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function address_sources($p) { $abook_prio = $this->addressbook_prio(); // Disable all global address books // Assumes that all non-kolab_addressbook sources are global if ($abook_prio == self::PERSONAL_ONLY) { $p['sources'] = array(); } $sources = array(); foreach ($this->_list_sources() as $abook_id => $abook) { // register this address source $sources[$abook_id] = $this->driver->abook_prop($abook_id, $abook); // flag folders with 'i' right as writeable if ($this->rc->action == 'add' && strpos($abook->rights, 'i') !== false) { $sources[$abook_id]['readonly'] = false; } } // Add personal address sources to the list if ($abook_prio == self::PERSONAL_FIRST) { // $p['sources'] = array_merge($sources, $p['sources']); // Don't use array_merge(), because if you have folders name // that resolve to numeric identifier it will break output array keys foreach ($p['sources'] as $idx => $value) { $sources[$idx] = $value; } $p['sources'] = $sources; } else { // $p['sources'] = array_merge($p['sources'], $sources); foreach ($sources as $idx => $value) { $p['sources'][$idx] = $value; } } return $p; } /** * */ public function directorylist_html($args) { $out = ''; $spec = ''; $kolab = ''; $jsdata = []; $sources = (array) $this->rc->get_address_sources(); // list all non-kolab sources first (also exclude hidden sources), special folders will go last foreach ($sources as $j => $source) { $id = strval(strlen($source['id']) ? $source['id'] : $j); if (!empty($source['kolab']) || !empty($source['hidden'])) { continue; } // Roundcube >= 1.5, Collected Recipients and Trusted Senders sources will be listed at the end if ((defined('rcube_addressbook::TYPE_RECIPIENT') && $source['id'] == (string) rcube_addressbook::TYPE_RECIPIENT) || (defined('rcube_addressbook::TYPE_TRUSTED_SENDER') && $source['id'] == (string) rcube_addressbook::TYPE_TRUSTED_SENDER) ) { $spec .= $this->addressbook_list_item($id, $source, $jsdata) . ''; } else { $out .= $this->addressbook_list_item($id, $source, $jsdata) . ''; } } // render a hierarchical list of kolab contact folders // TODO: Move this to the drivers if ($this->driver instanceof kolab_contacts_driver) { $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact')); kolab_storage::folder_hierarchy($folders, $tree); if ($tree && !empty($tree->children)) { $kolab .= $this->folder_tree_html($tree, $sources, $jsdata); } } else { $filter = function($source) { return !empty($source['kolab']) && empty($source['hidden']); }; foreach (array_filter($sources, $filter) as $j => $source) { $id = strval(strlen($source['id']) ? $source['id'] : $j); $kolab .= $this->addressbook_list_item($id, $source, $jsdata) . ''; } } $out .= $kolab . $spec; $this->rc->output->set_env('contactgroups', array_filter($jsdata, function($src){ return isset($src['type']) && $src['type'] == 'group'; })); $this->rc->output->set_env('address_sources', array_filter($jsdata, function($src){ return !isset($src['type']) || $src['type'] != 'group'; })); $args['content'] = html::tag('ul', $args, $out, html::$common_attrib); return $args; } /** * Return html for a structured list
    for the folder tree */ protected function folder_tree_html($node, $data, &$jsdata) { $out = ''; foreach ($node->children as $folder) { $id = $folder->id; $source = $data[$id]; $is_collapsed = strpos($this->rc->config->get('collapsed_abooks',''), '&'.rawurlencode($id).'&') !== false; if (!empty($folder->virtual)) { $source = $this->driver->abook_prop($folder->id, $folder); } else if (empty($source)) { $this->sources[$id] = new kolab_contacts($folder->name); $source = $this->driver->abook_prop($id, $this->sources[$id]); } $content = $this->addressbook_list_item($id, $source, $jsdata); if (!empty($folder->children)) { $child_html = $this->folder_tree_html($folder, $data, $jsdata); // copy group items... if (preg_match('!]*>(.*)
\n*$!Ums', $content, $m)) { $child_html = $m[1] . $child_html; - $content = substr($content, 0, -strlen($m[0]) - 1); + $content = substr($content, 0, -strlen($m[0])); } // ... and re-create the subtree if (!empty($child_html)) { $content .= html::tag('ul', array('class' => 'groups', 'style' => ($is_collapsed ? "display:none;" : null)), $child_html); } } $out .= $content . ''; } return $out; } /** * */ protected function addressbook_list_item($id, $source, &$jsdata, $search_mode = false) { $current = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); if (empty($source['virtual'])) { $jsdata[$id] = $source; $jsdata[$id]['name'] = html_entity_decode($source['name'], ENT_NOQUOTES, RCUBE_CHARSET); } // set class name(s) $classes = array('addressbook'); if (!empty($source['group'])) $classes[] = $source['group']; if ($current === $id) $classes[] = 'selected'; if (!empty($source['readonly'])) $classes[] = 'readonly'; if (!empty($source['virtual'])) $classes[] = 'virtual'; if (!empty($source['class_name'])) $classes[] = $source['class_name']; $name = !empty($source['listname']) ? $source['listname'] : (!empty($source['name']) ? $source['name'] : $id); $label_id = 'kabt:' . $id; $inner = (!empty($source['virtual']) ? html::a(array('tabindex' => '0'), $name) : html::a(array( 'href' => $this->rc->url(array('_source' => $id)), 'rel' => $source['id'], 'id' => $label_id, 'onclick' => "return " . rcmail_output::JS_OBJECT_NAME.".command('list','" . rcube::JQ($id) . "',this)", ), $name) ); if ($this->driver instanceof kolab_contacts_driver && isset($source['subscribed'])) { $inner .= html::span(array( 'class' => 'subscribed', 'title' => $this->gettext('foldersubscribe'), 'role' => 'checkbox', 'aria-checked' => $source['subscribed'] ? 'true' : 'false', ), ''); } // don't wrap in
  • but add a checkbox for search results listing if ($search_mode) { $jsdata[$id]['group'] = join(' ', $classes); if (!$source['virtual']) { $inner .= html::tag('input', array( 'type' => 'checkbox', 'name' => '_source[]', 'value' => $id, 'checked' => false, 'aria-labelledby' => $label_id, )); } return html::div(null, $inner); } $out = html::tag('li', array( 'id' => 'rcmli' . rcube_utils::html_identifier($id, true), - 'class' => join(' ', $classes), + 'class' => join(' ', $classes), 'noclose' => true, ), html::div(!empty($source['subscribed']) ? 'subscribed' : null, $inner) ); $groupdata = array('out' => '', 'jsdata' => $jsdata, 'source' => $id); if ($source['groups']) { if (function_exists('rcmail_contact_groups')) { $groupdata = rcmail_contact_groups($groupdata); } else { // Roundcube >= 1.5 $groupdata = rcmail_action_contacts_index::contact_groups($groupdata); } } $jsdata = $groupdata['jsdata']; $out .= $groupdata['out']; return $out; } /** * Sets autocomplete_addressbooks option according to * kolab_addressbook_prio setting extending list of address sources * to be used for autocompletion. */ public function config_get($args) { if ($args['name'] != 'autocomplete_addressbooks' || $this->recurrent) { return $args; } $abook_prio = $this->addressbook_prio(); // Get the original setting, use temp flag to prevent from an infinite recursion $this->recurrent = true; $sources = $this->rc->config->get('autocomplete_addressbooks'); $this->recurrent = false; // Disable all global address books // Assumes that all non-kolab_addressbook sources are global if ($abook_prio == self::PERSONAL_ONLY) { $sources = array(); } if (!is_array($sources)) { $sources = array(); } $kolab_sources = array(); foreach (array_keys($this->_list_sources()) as $abook_id) { if (!in_array($abook_id, $sources)) $kolab_sources[] = $abook_id; } // Add personal address sources to the list if (!empty($kolab_sources)) { if ($abook_prio == self::PERSONAL_FIRST) { $sources = array_merge($kolab_sources, $sources); } else { $sources = array_merge($sources, $kolab_sources); } } $args['result'] = $sources; return $args; } /** * Getter for the rcube_addressbook instance * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function get_address_book($p) { if ($p['id']) { if ($source = $this->driver->get_address_book($p['id'])) { $p['instance'] = $source; // flag source as writeable if 'i' right is given if ($p['writeable'] && $this->rc->action == 'save' && strpos($p['instance']->rights, 'i') !== false) { $p['instance']->readonly = false; } else if ($this->rc->action == 'delete' && strpos($p['instance']->rights, 't') !== false) { $p['instance']->readonly = false; } } } return $p; } /** * List addressbook sources list */ private function _list_sources() { // already read sources if (isset($this->sources)) { return $this->sources; } $this->sources = []; $abook_prio = $this->addressbook_prio(); // Personal address source(s) disabled? if ($abook_prio == kolab_addressbook::GLOBAL_ONLY) { return $this->sources; } $folders = $this->driver->list_folders(); // get all folders that have "contact" type foreach ($folders as $id => $source) { $this->sources[$id] = $source; } return $this->sources; } /** * Plugin hook called before rendering the contact form or detail view * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function contact_form($p) { // none of our business if (empty($GLOBALS['CONTACTS']) || !($GLOBALS['CONTACTS'] instanceof kolab_contacts)) { return $p; } // extend the list of contact fields to be displayed in the 'personal' section if (is_array($p['form']['personal'])) { $p['form']['personal']['content']['profession'] = array('size' => 40); $p['form']['personal']['content']['children'] = array('size' => 40); $p['form']['personal']['content']['freebusyurl'] = array('size' => 40); $p['form']['personal']['content']['pgppublickey'] = array('size' => 70); $p['form']['personal']['content']['pkcs7publickey'] = array('size' => 70); // re-order fields according to the coltypes list $p['form']['contact']['content'] = $this->_sort_form_fields($p['form']['contact']['content'], $GLOBALS['CONTACTS']); $p['form']['personal']['content'] = $this->_sort_form_fields($p['form']['personal']['content'], $GLOBALS['CONTACTS']); /* define a separate section 'settings' $p['form']['settings'] = array( 'name' => $this->gettext('settings'), 'content' => array( 'freebusyurl' => array('size' => 40, 'visible' => true), 'pgppublickey' => array('size' => 70, 'visible' => true), 'pkcs7publickey' => array('size' => 70, 'visible' => false), ) ); */ } if ($this->bonnie_api && $this->rc->action == 'show' && empty($p['record']['rev'])) { $this->rc->output->set_env('kolab_audit_trail', true); } return $p; } /** * Plugin hook for the contact photo image */ public function contact_photo($p) { // add photo data from old revision inline as data url if (!empty($p['record']['rev']) && !empty($p['data'])) { $p['url'] = 'data:image/gif;base64,' . base64_encode($p['data']); } return $p; } /** * Handler for contact audit trail changelog requests */ public function contact_changelog() { if (empty($this->bonnie_api)) { return false; } $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source); $result = $uid && $mailbox ? $this->bonnie_api->changelog('contact', $uid, $mailbox, $msguid) : null; if (is_array($result) && $result['uid'] == $uid) { if (is_array($result['changes'])) { $rcmail = $this->rc; $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); array_walk($result['changes'], function(&$change) use ($rcmail, $dtformat) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTime) { $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); } $this->rc->output->command('contact_render_changelog', $result['changes']); } else { $this->rc->output->command('contact_render_changelog', false); } $this->rc->output->send(); } /** * Handler for audit trail diff view requests */ public function contact_diff() { if (empty($this->bonnie_api)) { return false; } $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); $rev1 = rcube_utils::get_input_value('rev1', rcube_utils::INPUT_POST); $rev2 = rcube_utils::get_input_value('rev2', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source); $result = $this->bonnie_api->diff('contact', $uid, $rev1, $rev2, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $result['cid'] = $contact; // convert some properties, similar to kolab_contacts::_to_rcube_contact() $keymap = array( 'lastmodified-date' => 'changed', 'additional' => 'middlename', 'fn' => 'name', 'tel' => 'phone', 'url' => 'website', 'bday' => 'birthday', 'note' => 'notes', 'role' => 'profession', 'title' => 'jobtitle', ); $propmap = array('email' => 'address', 'website' => 'url', 'phone' => 'number'); $date_format = $this->rc->config->get('date_format', 'Y-m-d'); // map kolab object properties to keys and values the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap, $propmap, $date_format) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } // format date-time values if ($change['property'] == 'created' || $change['property'] == 'changed') { if ($old_ = rcube_utils::anytodatetime($change['old'])) { $change['old_'] = $this->rc->format_date($old_); } if ($new_ = rcube_utils::anytodatetime($change['new'])) { $change['new_'] = $this->rc->format_date($new_); } } // format dates else if ($change['property'] == 'birthday' || $change['property'] == 'anniversary') { if ($old_ = rcube_utils::anytodatetime($change['old'])) { $change['old_'] = $this->rc->format_date($old_, $date_format); } if ($new_ = rcube_utils::anytodatetime($change['new'])) { $change['new_'] = $this->rc->format_date($new_, $date_format); } } // convert email, website, phone values else if (array_key_exists($change['property'], $propmap)) { $propname = $propmap[$change['property']]; foreach (array('old','new') as $k) { $k_ = $k . '_'; if (!empty($change[$k])) { $change[$k_] = html::quote($change[$k][$propname] ?: '--'); if ($change[$k]['type']) { $change[$k_] .= ' ' . html::span('subtype', $this->get_type_label($change[$k]['type'])); } $change['ishtml'] = true; } } } // serialize address structs if ($change['property'] == 'address') { foreach (array('old','new') as $k) { $k_ = $k . '_'; $change[$k]['zipcode'] = $change[$k]['code']; $template = $this->rc->config->get('address_template', '{'.join('} {', array_keys($change[$k])).'}'); $composite = array(); foreach ($change[$k] as $p => $val) { if (strlen($val)) $composite['{'.$p.'}'] = $val; } $change[$k_] = preg_replace('/\{\w+\}/', '', strtr($template, $composite)); if ($change[$k]['type']) { $change[$k_] .= html::div('subtype', $this->get_type_label($change[$k]['type'])); } $change['ishtml'] = true; } $change['diff_'] = libkolab::html_diff($change['old_'], $change['new_'], true); } // localize gender values else if ($change['property'] == 'gender') { if ($change['old']) $change['old_'] = $this->rc->gettext($change['old']); if ($change['new']) $change['new_'] = $this->rc->gettext($change['new']); } // translate 'key' entries in individual properties else if ($change['property'] == 'key') { $p = $change['old'] ?: $change['new']; $t = $p['type']; $change['property'] = $t . 'publickey'; $change['old'] = $change['old'] ? $change['old']['key'] : ''; $change['new'] = $change['new'] ? $change['new']['key'] : ''; } // compute a nice diff of notes else if ($change['property'] == 'notes') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new'], false); } }); $this->rc->output->command('contact_show_diff', $result); } else { $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); } $this->rc->output->send(); } /** * Handler for audit trail revision restore requests */ public function contact_restore() { if (empty($this->bonnie_api)) { return false; } $success = false; $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); $rev = rcube_utils::get_input_value('rev', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source, $folder); if ($folder && ($raw_msg = $this->bonnie_api->rawdata('contact', $uid, $rev, $mailbox))) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($folder->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $folder->name); $folder->cache->set($msguid, false); $this->cache = array(); } } if ($success) { $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $rev))), 'confirmation'); $this->rc->output->command('close_contact_history_dialog', $contact); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); } $this->rc->output->send(); } /** * Get a previous revision of the given contact record from the Bonnie API */ public function get_revision($cid, $source, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($cid, $source); // call Bonnie API $result = $this->bonnie_api->get('contact', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('contact'); $format->load($result['xml']); $rec = $format->to_array(); if ($format->is_valid()) { $rec['rev'] = $result['rev']; return $rec; } } return false; } /** * Helper method to resolved the given contact identifier into uid and mailbox * * @return array (uid,mailbox,msguid) tuple */ private function _resolve_contact_identity($id, $abook, &$folder = null) { $mailbox = $msguid = null; $source = $this->get_address_book(array('id' => $abook)); if ($source['instance']) { $uid = $source['instance']->id2uid($id); $list = kolab_storage::id_decode($abook); } else { return array(null, $mailbox, $msguid); } // get resolve message UID and mailbox identifier if ($folder = kolab_storage::get_folder($list)) { $mailbox = $folder->get_mailbox_id(); $msguid = $folder->cache->uid2msguid($uid); } return array($uid, $mailbox, $msguid); } /** * */ private function _sort_form_fields($contents, $source) { $block = []; foreach (array_keys($source->coltypes) as $col) { if (isset($contents[$col])) { $block[$col] = $contents[$col]; } } return $block; } /** * Handler for user preferences form (preferences_list hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function prefs_list($args) { if ($args['section'] != 'addressbook') { return $args; } $ldap_public = $this->rc->config->get('ldap_public'); // Hide option if there's no global addressbook if (empty($ldap_public)) { return $args; } // Check that configuration is not disabled $dont_override = (array) $this->rc->config->get('dont_override', array()); $prio = $this->addressbook_prio(); if (!in_array('kolab_addressbook_prio', $dont_override)) { // Load localization $this->add_texts('localization'); $field_id = '_kolab_addressbook_prio'; $select = new html_select(array('name' => $field_id, 'id' => $field_id)); $select->add($this->gettext('globalfirst'), self::GLOBAL_FIRST); $select->add($this->gettext('personalfirst'), self::PERSONAL_FIRST); $select->add($this->gettext('globalonly'), self::GLOBAL_ONLY); $select->add($this->gettext('personalonly'), self::PERSONAL_ONLY); $args['blocks']['main']['options']['kolab_addressbook_prio'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('addressbookprio'))), 'content' => $select->show($prio), ); } return $args; } /** * Handler for user preferences save (preferences_save hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function prefs_save($args) { if ($args['section'] != 'addressbook') { return $args; } // Check that configuration is not disabled $dont_override = (array) $this->rc->config->get('dont_override', array()); $key = 'kolab_addressbook_prio'; if (!in_array('kolab_addressbook_prio', $dont_override) || !isset($_POST['_'.$key])) { $args['prefs'][$key] = (int) rcube_utils::get_input_value('_'.$key, rcube_utils::INPUT_POST); } return $args; } /** * Handler for plugin actions */ public function book_actions() { $action = trim(rcube_utils::get_input_value('_act', rcube_utils::INPUT_GPC)); if ($action == 'create') { $this->ui->book_edit(); } else if ($action == 'edit') { $this->ui->book_edit(); } else if ($action == 'delete') { $this->book_delete(); } } /** * Handler for address book create/edit form submit */ public function book_save() { $this->driver->folder_save(); $this->rc->output->send('iframe'); } /** * */ public function book_search() { $results = []; $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); kolab_storage::$encode_ids = true; $search_more_results = false; $this->sources = array(); $this->folders = array(); // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('contact', $query, array('other')) as $folder) { $this->folders[$folder->id] = $folder; $this->sources[$folder->id] = new kolab_contacts($folder->name); } } // 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 contact folders shared by this user foreach (kolab_storage::list_user_folders($user, 'contact', false) as $foldername) { $folders[] = new kolab_storage_folder($foldername, 'contact'); } if (count($folders)) { $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); $this->folders[$userfolder->id] = $userfolder; $this->sources[$userfolder->id] = $userfolder; foreach ($folders as $folder) { $this->folders[$folder->id] = $folder; $this->sources[$folder->id] = new kolab_contacts($folder->name);; $count++; } } if ($count >= $limit) { $search_more_results = true; break; } } } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); // build results list foreach ($this->sources as $id => $source) { $folder = $this->folders[$id]; $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()); } $prop = $this->driver->abook_prop($id, $source); $prop['parent'] = $parent_id; $html = $this->addressbook_list_item($id, $prop, $jsdata, true); unset($prop['group']); $prop += (array)$jsdata[$id]; $prop['html'] = $html; $results[] = $prop; } // report more results available if ($search_more_results) { $this->rc->output->show_message('autocompletemore', 'notice'); } $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); } /** * */ public function book_subscribe() { $success = false; $id = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); if ($id && ($folder = kolab_storage::get_folder(kolab_storage::id_decode($id)))) { if (isset($_POST['_permanent'])) $success |= $folder->subscribe(intval($_POST['_permanent'])); if (isset($_POST['_active'])) $success |= $folder->activate(intval($_POST['_active'])); // list groups for this address book if (!empty($_POST['_groups'])) { $abook = new kolab_contacts($folder->name); foreach ((array)$abook->list_groups() as $prop) { $prop['source'] = $id; $prop['id'] = $prop['ID']; unset($prop['ID']); $this->rc->output->command('insert_contact_group', $prop); } } } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); } else { $this->rc->output->show_message($this->gettext('errorsaving'), 'error'); } $this->rc->output->send(); } /** * Handler for address book delete action (AJAX) */ private function book_delete() { $source = trim(rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC, true)); if ($source && ($result = $this->driver->folder_delete($source))) { $storage = $this->rc->get_storage(); $delimiter = $storage->get_hierarchy_delimiter(); $this->rc->output->show_message('kolab_addressbook.bookdeleted', 'confirmation'); $this->rc->output->set_env('pagecount', 0); $this->rc->output->command('set_rowcount', $this->rc->gettext('nocontactsfound')); $this->rc->output->command('set_env', 'delimiter', $delimiter); $this->rc->output->command('list_contacts_clear'); $this->rc->output->command('book_delete_done', $source); } else { $this->rc->output->show_message('kolab_addressbook.bookdeleteerror', 'error'); } $this->rc->output->send(); } /** * Returns value of kolab_addressbook_prio setting */ private function addressbook_prio() { $abook_prio = (int) $this->rc->config->get('kolab_addressbook_prio'); // Make sure any global addressbooks are defined if ($abook_prio == 0 || $abook_prio == 2) { $ldap_public = $this->rc->config->get('ldap_public'); if (empty($ldap_public)) { $abook_prio = 1; } } return $abook_prio; } /** * Hook for (contact) folder deletion */ function prefs_folder_delete($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } $this->_contact_folder_rename($args['name'], false); } /** * Hook for (contact) folder renaming */ function prefs_folder_rename($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } $this->_contact_folder_rename($args['oldname'], $args['newname']); } /** * Hook for (contact) folder updates. Forward to folder_rename handler if name was changed */ function prefs_folder_update($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } if ($args['record']['name'] != $args['record']['oldname']) { $this->_contact_folder_rename($args['record']['oldname'], $args['record']['name']); } } /** * Apply folder renaming or deletion to the registered birthday calendar address books */ private function _contact_folder_rename($oldname, $newname = false) { $update = false; $delimiter = $this->rc->get_storage()->get_hierarchy_delimiter(); $bday_addressbooks = (array) $this->rc->config->get('calendar_birthday_adressbooks', []); foreach ($bday_addressbooks as $i => $id) { $folder_name = kolab_storage::id_decode($id); if ($oldname === $folder_name || strpos($folder_name, $oldname.$delimiter) === 0) { if ($newname) { // rename $new_folder = $newname . substr($folder_name, strlen($oldname)); $bday_addressbooks[$i] = kolab_storage::id_encode($new_folder); } else { // delete unset($bday_addressbooks[$i]); } $update = true; } } if ($update) { $this->rc->user->save_prefs(['calendar_birthday_adressbooks' => $bday_addressbooks]); } } /** * Get a localization label for specified field type */ private function get_type_label($type) { // Roundcube < 1.5 if (function_exists('rcmail_get_type_label')) { return rcmail_get_type_label($type); } // Roundcube >= 1.5 return rcmail_action_contacts_index::get_type_label($type); } } diff --git a/plugins/kolab_notes/kolab_notes_ui.php b/plugins/kolab_notes/kolab_notes_ui.php index 855f3352..63660169 100644 --- a/plugins/kolab_notes/kolab_notes_ui.php +++ b/plugins/kolab_notes/kolab_notes_ui.php @@ -1,348 +1,348 @@ plugin = $plugin; $this->rc = $plugin->rc; } /** * Calendar UI initialization and requests handlers */ public function init() { if ($this->ready) // already done return; // add taskbar button $this->plugin->add_button(array( 'command' => 'notes', 'class' => 'button-notes', 'classsel' => 'button-notes button-selected', 'innerclass' => 'button-inner', 'label' => 'kolab_notes.navtitle', 'type' => 'link' ), 'taskbar'); $this->plugin->include_stylesheet($this->plugin->local_skin_path() . '/notes.css'); $this->ready = true; - } + } /** * Register handler methods for the template engine */ public function init_templates() { $this->plugin->register_handler('plugin.notebooks', array($this, 'folders')); #$this->plugin->register_handler('plugin.folders_select', array($this, 'folders_select')); $this->plugin->register_handler('plugin.searchform', array($this->rc->output, 'search_form')); $this->plugin->register_handler('plugin.listing', array($this, 'listing')); $this->plugin->register_handler('plugin.editform', array($this, 'editform')); $this->plugin->register_handler('plugin.notetitle', array($this, 'notetitle')); $this->plugin->register_handler('plugin.detailview', array($this, 'detailview')); $this->plugin->register_handler('plugin.attachments_list', array($this, 'attachments_list')); $this->plugin->register_handler('plugin.object_changelog_table', array('libkolab', 'object_changelog_table')); $this->rc->output->include_script('list.js'); $this->rc->output->include_script('treelist.js'); $this->plugin->include_script('notes.js'); $this->plugin->api->include_script('libkolab/libkolab.js'); // load config options and user prefs relevant for the UI $settings = array( 'sort_col' => $this->rc->config->get('kolab_notes_sort_col', 'changed'), ); if ($list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC)) { $settings['selected_list'] = $list; } if ($uid = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC)) { $settings['selected_uid'] = $uid; } $this->rc->html_editor(); $this->rc->output->set_env('kolab_notes_settings', $settings); $this->rc->output->add_label('save','cancel','delete','close','listoptionstitle'); } public function folders($attrib) { $attrib += array('id' => 'rcmkolabnotebooks'); $is_select = ($attrib['type'] ?? null) == 'select'; if ($is_select) { $attrib['is_escaped'] = true; $select = new html_select($attrib); } - $tree = $is_select ? true : null; + $tree = !$is_select ? true : null; $lists = $this->plugin->get_lists($tree); $jsenv = array(); if (is_object($tree)) { $html = $this->folder_tree_html($tree, $lists, $jsenv, $attrib); } else { $html = ''; foreach ($lists as $prop) { $id = $prop['id']; if (empty($prop['virtual'])) { unset($prop['user_id']); $jsenv[$id] = $prop; } if ($is_select) { if ($prop['editable'] || strpos($prop['rights'], 'i') !== false) { $select->add($prop['name'], $prop['id']); } } else { $html .= html::tag('li', array('id' => 'rcmliknb' . rcube_utils::html_identifier($id), 'class' => $prop['group']), $this->folder_list_item($id, $prop, $jsenv) ); } } } $this->rc->output->set_env('kolab_notebooks', $jsenv); $this->rc->output->add_gui_object('notebooks', $attrib['id']); return $is_select ? $select->show() : html::tag('ul', $attrib, $html, html::$common_attrib); } /** * Return html for a structured list
      for the folder tree */ public function folder_tree_html($node, $data, &$jsenv, $attrib) { $out = ''; foreach ($node->children as $folder) { $id = $folder->id; $prop = $data[$id]; $is_collapsed = false; // TODO: determine this somehow? $content = $this->folder_list_item($id, $prop, $jsenv); if (!empty($folder->children)) { $content .= html::tag('ul', array('style' => ($is_collapsed ? "display:none;" : null)), $this->folder_tree_html($folder, $data, $jsenv, $attrib)); } if (strlen($content)) { $out .= html::tag('li', array( 'id' => 'rcmliknb' . rcube_utils::html_identifier($id), 'class' => $prop['group'] . (!empty($prop['virtual']) ? ' virtual' : ''), ), $content); } } return $out; } /** * Helper method to build a tasklist item (HTML content and js data) */ public function folder_list_item($id, $prop, &$jsenv, $checkbox = false) { if (empty($prop['virtual'])) { unset($prop['user_id']); $jsenv[$id] = $prop; } $classes = array('folder'); if (!empty($prop['virtual'])) { $classes[] = 'virtual'; } else if (!$prop['editable']) { $classes[] = 'readonly'; } if ($prop['subscribed']) { $classes[] = 'subscribed'; } if ($prop['class']) { $classes[] = $prop['class']; } $title = !empty($prop['title']) ? $prop['title'] : ($prop['name'] != $prop['listname'] || strlen($prop['name']) > 25 ? html_entity_decode($prop['name'], ENT_COMPAT, RCUBE_CHARSET) : ''); $label_id = 'nl:' . $id; $attr = !empty($prop['virtual']) ? array('tabindex' => '0') : array('href' => $this->rc->url(array('_list' => $id))); return html::div(join(' ', $classes), html::a($attr + array('class' => 'listname', 'title' => $title, 'id' => $label_id), $prop['listname'] ?: $prop['name']) . (!empty($prop['virtual']) ? '' : ($checkbox ? html::tag('input', array('type' => 'checkbox', 'name' => '_list[]', 'value' => $id, 'checked' => $prop['active'], 'aria-labelledby' => $label_id)) : '' ) . html::span('handle', '') . html::span('actions', (empty($prop['default']) ? html::a(array('href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')), ' ') : '' ) . (isset($prop['subscribed']) ? html::a(array('href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('foldersubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'), ' ') : '' ) ) ) ); return ''; } public function listing($attrib) { $attrib += array('id' => 'rcmkolabnoteslist'); $this->rc->output->add_gui_object('noteslist', $attrib['id']); return html::tag('table', $attrib, '', html::$common_attrib); } public function editform($attrib) { $attrib += array('action' => '#', 'id' => 'rcmkolabnoteseditform'); $textarea = new html_textarea(array( 'name' => 'content', 'id' => 'notecontent', 'cols' => 60, 'rows' => 20, 'tabindex' => 0, 'class' => 'mce_editor form-control', )); $this->rc->output->add_gui_object('noteseditform', $attrib['id']); return html::tag('form', $attrib, $textarea->show(), array_merge(html::$common_attrib, array('action'))); } public function detailview($attrib) { $attrib += array('id' => 'rcmkolabnotesdetailview'); $this->rc->output->add_gui_object('notesdetailview', $attrib['id']); return html::div($attrib, ''); } public function notetitle($attrib) { $attrib += array('id' => 'rcmkolabnotestitle'); $this->rc->output->add_gui_object('noteviewtitle', $attrib['id']); $summary = new html_inputfield(array( 'name' => 'summary', 'class' => 'notetitle inline-edit form-control', 'size' => 60, 'id' => 'notetitleinput', 'tabindex' => 0 )); $html = html::div('form-group row', html::label(array('class' => 'col-sm-2 col-form-label', 'for' => 'notetitleinput'), $this->plugin->gettext('kolab_notes.title')) . html::span('col-sm-10', $summary->show()) ) . html::div('form-group row', html::label(array('class' => 'col-sm-2 col-form-label'), $this->plugin->gettext('kolab_notes.tags')) . html::div(array('class' => 'tagline tagedit col-sm-10'), ' ') ) . html::div(array('class' => 'dates text-only', 'style' => 'display:none'), html::div('form-group row', html::label(array('class' => 'col-sm-2 col-form-label'), $this->plugin->gettext('created')) . html::span('col-sm-10', html::span('notecreated form-control-plaintext', '')) ) . html::div('form-group row', html::label(array('class' => 'col-sm-2 col-form-label'), $this->plugin->gettext('changed')) . html::span('col-sm-10', html::span('notechanged form-control-plaintext', '')) ) ); return html::div($attrib, $html); } public function attachments_list($attrib) { $attrib += array('id' => 'rcmkolabnotesattachmentslist'); $this->rc->output->add_gui_object('notesattachmentslist', $attrib['id']); return html::tag('ul', $attrib, '', html::$common_attrib); } /** * Render create/edit form for notes lists (folders) */ public function list_editform($action, $list, $folder) { $this->action = $action; $this->list = $list; $this->folder = is_object($folder) ? $folder->name : ''; // UTF7; $this->rc->output->set_env('pagetitle', $this->plugin->gettext('arialabelnotebookform')); $this->rc->output->add_handler('folderform', array($this, 'notebookform')); $this->rc->output->send('libkolab.folderform'); } /** * Render create/edit form for notes lists (folders) */ public function notebookform($attrib) { $folder_name = $this->folder; $hidden_fields[] = array('name' => 'oldname', 'value' => $folder_name); $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder_name)) { $options = $storage->folder_info($folder_name); $path_imap = explode($delim, $folder_name); array_pop($path_imap); // pop off name part $path_imap = implode($delim, $path_imap); } else { $path_imap = ''; $options = array(); } // General tab $form['properties'] = array( 'name' => $this->rc->gettext('properties'), 'fields' => array(), ); // folder name (default field) $input_name = new html_inputfield(array('name' => 'name', 'id' => 'noteslist-name', 'size' => 20)); $form['properties']['fields']['name'] = array( 'label' => $this->plugin->gettext('listname'), 'value' => $input_name->show($this->list['editname'], array('disabled' => ($options['norename'] || $options['protected']))), 'id' => 'noteslist-name', ); // 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('note', array('name' => 'parent', 'id' => 'parent-folder'), $folder_name); $form['properties']['fields']['path'] = array( 'label' => $this->plugin->gettext('parentfolder'), 'value' => $select->show(strlen($folder_name) ? $path_imap : ''), 'id' => 'parent-folder', ); } $form_html = kolab_utils::folder_form($form, $folder_name, 'kolab_notes', $hidden_fields); return html::tag('form', $attrib + array('action' => '#', 'method' => 'post', 'id' => 'noteslistpropform'), $form_html); } } diff --git a/plugins/libkolab/lib/kolab_storage_folder.php b/plugins/libkolab/lib/kolab_storage_folder.php index 55457621..a61922f4 100644 --- a/plugins/libkolab/lib/kolab_storage_folder.php +++ b/plugins/libkolab/lib/kolab_storage_folder.php @@ -1,1175 +1,1175 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_folder extends kolab_storage_folder_api { /** * The kolab_storage_cache instance for caching operations * @var object */ public $cache; /** * Indicate validity status * @var boolean */ public $valid = false; protected $error = 0; protected $resource_uri; /** * Default constructor * * @param string The folder name/path * @param string Expected folder type * @param string Optional folder type if known */ function __construct($name, $type = null, $type_annotation = null) { parent::__construct($name); $this->set_folder($name, $type, $type_annotation); } /** * Set the IMAP folder this instance connects to * * @param string The folder name/path * @param string Expected folder type * @param string Optional folder type if known */ public function set_folder($name, $type = null, $type_annotation = null) { $this->name = $name; if (empty($type_annotation)) { $type_annotation = $this->get_type(); } $oldtype = $this->type; list($this->type, $suffix) = array_pad(explode('.', $type_annotation), 2, null); $this->default = $suffix == 'default'; $this->subtype = $this->default ? '' : $suffix; $this->id = kolab_storage::folder_id($name); $this->valid = !empty($this->type) && $this->type != 'mail' && (!$type || $this->type == $type); if (!$this->valid) { $this->error = $this->imap->get_error_code() < 0 ? kolab_storage::ERROR_IMAP_CONN : kolab_storage::ERROR_INVALID_FOLDER; } // reset cached object properties $this->owner = $this->namespace = $this->resource_uri = $this->info = $this->idata = null; // get a new cache instance if folder type changed if (!$this->cache || $this->type != $oldtype) $this->cache = kolab_storage_cache::factory($this); else $this->cache->set_folder($this); $this->imap->set_folder($this->name); } /** * Returns code of last error * * @return int Error code */ public function get_error() { return $this->error ?: $this->cache->get_error(); } /** * Check IMAP connection error state */ public function check_error() { if (($err_code = $this->imap->get_error_code()) < 0) { $this->error = kolab_storage::ERROR_IMAP_CONN; if (($res_code = $this->imap->get_response_code()) !== 0 && in_array($res_code, array(rcube_storage::NOPERM, rcube_storage::READONLY))) { $this->error = kolab_storage::ERROR_NO_PERMISSION; } } return $this->error; } /** * Compose a unique resource URI for this IMAP folder */ public function get_resource_uri() { if (!empty($this->resource_uri)) { return $this->resource_uri; } // strip namespace prefix from folder name $ns = $this->get_namespace(); $nsdata = $this->imap->get_namespace($ns); - if (is_array($nsdata[0]) && strlen($nsdata[0][0]) && strpos($this->name, $nsdata[0][0]) === 0) { + if (!empty($nsdata[0][0]) && strpos($this->name, $nsdata[0][0]) === 0) { $subpath = substr($this->name, strlen($nsdata[0][0])); if ($ns == 'other') { list($user, $suffix) = explode($nsdata[0][1], $subpath, 2); $subpath = $suffix; } } else { $subpath = $this->name; } // compose fully qualified ressource uri for this instance $this->resource_uri = 'imap://' . urlencode($this->get_owner(true)) . '@' . $this->imap->options['host'] . '/' . $subpath; return $this->resource_uri; } /** * Helper method to extract folder UID metadata * * @return string Folder's UID */ public function get_uid() { // UID is defined in folder METADATA $metakeys = array(kolab_storage::UID_KEY_SHARED, kolab_storage::UID_KEY_CYRUS); $metadata = $this->get_metadata(); if ($metadata !== null) { foreach ($metakeys as $key) { if ($uid = ($metadata[$key] ?? null)) { return $uid; } } // generate a folder UID and set it to IMAP $uid = rtrim(chunk_split(md5($this->name . $this->get_owner() . uniqid('-', true)), 12, '-'), '-'); if ($this->set_uid($uid)) { return $uid; } } $this->check_error(); // create hash from folder name if we can't write the UID metadata return md5($this->name . $this->get_owner()); } /** * Helper method to set an UID value to the given IMAP folder instance * * @param string Folder's UID * @return boolean True on succes, False on failure */ public function set_uid($uid) { $success = $this->set_metadata(array(kolab_storage::UID_KEY_SHARED => $uid)); $this->check_error(); return $success; } /** * Compose a folder Etag identifier */ public function get_ctag() { $fdata = $this->get_imap_data(); $this->check_error(); return sprintf('%d-%d-%d', $fdata['UIDVALIDITY'] ?? 0, $fdata['HIGHESTMODSEQ'] ?? 0, $fdata['UIDNEXT'] ?? 0); } /** * Check activation status of this folder * * @return boolean True if enabled, false if not */ public function is_active() { return kolab_storage::folder_is_active($this->name); } /** * Change activation status of this folder * * @param boolean The desired subscription status: true = active, false = not active * * @return True on success, false on error */ public function activate($active) { return $active ? kolab_storage::folder_activate($this->name) : kolab_storage::folder_deactivate($this->name); } /** * Check subscription status of this folder * * @return boolean True if subscribed, false if not */ public function is_subscribed() { return kolab_storage::folder_is_subscribed($this->name); } /** * Change subscription status of this folder * * @param boolean The desired subscription status: true = subscribed, false = not subscribed * * @return True on success, false on error */ public function subscribe($subscribed) { return $subscribed ? kolab_storage::folder_subscribe($this->name) : kolab_storage::folder_unsubscribe($this->name); } /** * Get number of objects stored in this folder * * @param mixed Pseudo-SQL query as list of filter parameter triplets * or string with object type (e.g. contact, event, todo, journal, note, configuration) * * @return integer The number of objects of the given type * @see self::select() */ public function count($query = null) { if (!$this->valid) { return 0; } // synchronize cache first $this->cache->synchronize(); return $this->cache->count($this->_prepare_query($query)); } /** * Getter for a single Kolab object identified by its UID * * @param string $uid Object UID * * @return array The Kolab object represented as hash array */ public function get_object($uid) { if (!$this->valid || !$uid) { return false; } // synchronize caches $this->cache->synchronize(); return $this->cache->get_by_uid($uid); } /** * List Kolab objects matching the given query * * @param mixed Pseudo-SQL query as list of filter parameter triplets * or string with object type (e.g. contact, event, todo, journal, note, configuration) * * @return array List of Kolab data objects (each represented as hash array) * @deprecated Use select() */ public function get_objects($query = array()) { return $this->select($query); } /** * Select Kolab objects matching the given query * * @param mixed Pseudo-SQL query as list of filter parameter triplets * or string with object type (e.g. contact, event, todo, journal, note, configuration) * @param boolean Use fast mode to fetch only minimal set of information * (no xml fetching and parsing, etc.) * * @return array List of Kolab data objects (each represented as hash array) */ public function select($query = array(), $fast = false) { if (!$this->valid) { return array(); } // synchronize caches $this->cache->synchronize(); // fetch objects from cache return $this->cache->select($this->_prepare_query($query), false, $fast); } /** * Getter for object UIDs only * * @param array Pseudo-SQL query as list of filter parameter triplets * @return array List of Kolab object UIDs */ public function get_uids($query = array()) { if (!$this->valid) { return array(); } // synchronize caches $this->cache->synchronize(); // fetch UIDs from cache return $this->cache->select($this->_prepare_query($query), true); } /** * Setter for ORDER BY and LIMIT parameters for cache queries * * @param array List of columns to order by * @param integer Limit result set to this length * @param integer Offset row */ public function set_order_and_limit($sortcols, $length = null, $offset = 0) { $this->cache->set_order_by($sortcols); if ($length !== null) { $this->cache->set_limit($length, $offset); } } /** * Helper method to sanitize query arguments */ private function _prepare_query($query) { // string equals type query // FIXME: should not be called this way! if (is_string($query)) { return $this->cache->has_type_col() && !empty($query) ? array(array('type','=',$query)) : array(); } foreach ((array)$query as $i => $param) { if ($param[0] == 'type' && !$this->cache->has_type_col()) { unset($query[$i]); } else if (($param[0] == 'dtstart' || $param[0] == 'dtend' || $param[0] == 'changed')) { if (is_object($param[2]) && $param[2] instanceof DateTimeInterface) { $param[2] = $param[2]->format('U'); } if (is_numeric($param[2])) { $query[$i][2] = date('Y-m-d H:i:s', $param[2]); } } } return $query; } /** * Fetch a Kolab object attachment which is stored in a separate part * of the mail MIME message that represents the Kolab record. * * @param string Object's UID * @param string The attachment's mime number * @param string IMAP folder where message is stored; * If set, that also implies that the given UID is an IMAP UID * @param bool True to print the part content * @param resource File pointer to save the message part * @param boolean Disables charset conversion * * @return mixed The attachment content as binary string */ public function get_attachment($uid, $part, $mailbox = null, $print = false, $fp = null, $skip_charset_conv = false) { if ($this->valid && ($msguid = ($mailbox ? $uid : $this->cache->uid2msguid($uid)))) { $this->imap->set_folder($mailbox ? $mailbox : $this->name); if (substr($part, 0, 2) == 'i:') { // attachment data is stored in XML if ($object = $this->cache->get($msguid)) { // load data from XML (attachment content is not stored in cache) if ($object['_formatobj'] && isset($object['_size'])) { $object['_attachments'] = array(); $object['_formatobj']->get_attachments($object); } foreach ($object['_attachments'] as $attach) { if ($attach['id'] == $part) { if ($print) echo $attach['content']; else if ($fp) fwrite($fp, $attach['content']); else return $attach['content']; return true; } } } } else { // return message part from IMAP directly // TODO: We could improve performance if we cache part's encoding // without 3rd argument get_message_part() will request BODYSTRUCTURE from IMAP return $this->imap->get_message_part($msguid, $part, null, $print, $fp, $skip_charset_conv); } } return null; } /** * Fetch the mime message from the storage server and extract * the Kolab groupware object from it * * @param string The IMAP message UID to fetch * @param string The object type expected (use wildcard '*' to accept all types) * @param string The folder name where the message is stored * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($msguid, $type = null, $folder = null) { if (!$this->valid) { return false; } if (!$type) $type = $this->type; if (!$folder) $folder = $this->name; $this->imap->set_folder($folder); $this->cache->imap_mode(true); $message = new rcube_message($msguid); $this->cache->imap_mode(false); // Message doesn't exist? if (empty($message->headers)) { return false; } // extract the X-Kolab-Type header from the XML attachment part if missing if (empty($message->headers->others['x-kolab-type'])) { foreach ((array)$message->attachments as $part) { if (strpos($part->mimetype, kolab_format::KTYPE_PREFIX) === 0) { $message->headers->others['x-kolab-type'] = $part->mimetype; break; } } } // fix buggy messages stating the X-Kolab-Type header twice else if (is_array($message->headers->others['x-kolab-type'])) { $message->headers->others['x-kolab-type'] = reset($message->headers->others['x-kolab-type']); } // no object type header found: abort if (empty($message->headers->others['x-kolab-type'])) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "No X-Kolab-Type information found in message $msguid ($this->name).", ), true); return false; } $object_type = kolab_format::mime2object_type($message->headers->others['x-kolab-type']); $content_type = kolab_format::KTYPE_PREFIX . $object_type; // check object type header and abort on mismatch if ($type != '*' && strpos($object_type, $type) !== 0 && !($object_type == 'distribution-list' && $type == 'contact')) { return false; } $attachments = array(); // get XML part $xml = null; foreach ((array)$message->attachments as $part) { if (!$xml && ($part->mimetype == $content_type || preg_match('!application/([a-z.]+\+)?xml!i', $part->mimetype))) { $xml = $message->get_part_body($part->mime_id, true); } else if ($part->filename || $part->content_id) { $key = $part->content_id ? trim($part->content_id, '<>') : $part->filename; $size = null; // Use Content-Disposition 'size' as for the Kolab Format spec. if (isset($part->d_parameters['size'])) { $size = $part->d_parameters['size']; } // we can trust part size only if it's not encoded else if ($part->encoding == 'binary' || $part->encoding == '7bit' || $part->encoding == '8bit') { $size = $part->size; } $attachments[$key] = array( 'id' => $part->mime_id, 'name' => $part->filename, 'mimetype' => $part->mimetype, 'size' => $size, ); } } if (!$xml) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not find Kolab data part in message $msguid ($this->name).", ), true); return false; } // check kolab format version $format_version = $message->headers->others['x-kolab-mime-version']; if (empty($format_version)) { list($xmltype, $subtype) = explode('.', $object_type); $xmlhead = substr($xml, 0, 512); // detect old Kolab 2.0 format if (strpos($xmlhead, '<' . $xmltype) !== false && strpos($xmlhead, 'xmlns=') === false) $format_version = '2.0'; else $format_version = '3.0'; // assume 3.0 } // get Kolab format handler for the given type $format = kolab_format::factory($object_type, $format_version); if (is_a($format, 'PEAR_Error')) return false; // load Kolab object from XML part $format->load($xml); if ($format->is_valid()) { $object = $format->to_array(array('_attachments' => $attachments)); $object['_type'] = $object_type; $object['_msguid'] = $msguid; $object['_mailbox'] = $this->name; $object['_formatobj'] = $format; $object['_size'] = strlen($xml); return $object; } else { // try to extract object UID from XML block if (preg_match('!(.+)!Uims', $xml, $m)) $msgadd = " UID = " . trim(strip_tags($m[1])); rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not parse Kolab object data in message $msguid ($this->name)." . $msgadd, ), true); self::save_user_xml("$msguid.xml", $xml); } return false; } /** * Save an object in this folder. * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param string $uid The UID of the old object if it existed before * * @return mixed False on error or IMAP message UID on success */ public function save(&$object, $type = null, $uid = null) { if (!$this->valid || empty($object)) { return false; } if (!$type) $type = $this->type; // copy attachments from old message $copyfrom = $object['_copyfrom'] ?? ($object['_msguid'] ?? null); if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) { foreach ((array)$old['_attachments'] as $key => $att) { if (!isset($object['_attachments'][$key])) { $object['_attachments'][$key] = $old['_attachments'][$key]; } // unset deleted attachment entries if ($object['_attachments'][$key] == false) { unset($object['_attachments'][$key]); } // load photo.attachment from old Kolab2 format to be directly embedded in xcard block else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) { if (!isset($object['photo'])) $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']); unset($object['_attachments'][$key]); } } } // save contact photo to attachment for Kolab2 format if (kolab_storage::$version == '2.0' && $object['photo']) { $attkey = 'kolab-picture.png'; // this file name is hard-coded in libkolab/kolabformatV2/contact.cpp $object['_attachments'][$attkey] = array( 'mimetype'=> rcube_mime::image_content_type($object['photo']), 'content' => preg_match('![^a-z0-9/=+-]!i', $object['photo']) ? $object['photo'] : base64_decode($object['photo']), ); } // process attachments if (!empty($object['_attachments'])) { $numatt = count($object['_attachments']); foreach ($object['_attachments'] as $key => $attachment) { // FIXME: kolab_storage and Roundcube attachment hooks use different fields! if (empty($attachment['content']) && !empty($attachment['data'])) { $attachment['content'] = $attachment['data']; unset($attachment['data'], $object['_attachments'][$key]['data']); } // make sure size is set, so object saved in cache contains this info if (!isset($attachment['size'])) { if (!empty($attachment['content'])) { if (is_resource($attachment['content'])) { // this need to be a seekable resource, otherwise // fstat() failes and we're unable to determine size // here nor in rcube_imap_generic before IMAP APPEND $stat = fstat($attachment['content']); $attachment['size'] = $stat ? $stat['size'] : 0; } else { $attachment['size'] = strlen($attachment['content']); } } else if (!empty($attachment['path'])) { $attachment['size'] = filesize($attachment['path']); } $object['_attachments'][$key] = $attachment; } // generate unique keys (used as content-id) for attachments if (is_numeric($key) && $key < $numatt) { // derrive content-id from attachment file name $ext = preg_match('/(\.[a-z0-9]{1,6})$/i', $attachment['name'], $m) ? $m[1] : null; $basename = preg_replace('/[^a-z0-9_.-]/i', '', basename($attachment['name'], $ext)); // to 7bit ascii if (!$basename) $basename = 'noname'; $cid = $basename . '.' . microtime(true) . $key . $ext; $object['_attachments'][$cid] = $attachment; unset($object['_attachments'][$key]); } } } // save recurrence exceptions as individual objects due to lack of support in Kolab v2 format if (kolab_storage::$version == '2.0' && $object['recurrence']['EXCEPTIONS']) { $this->save_recurrence_exceptions($object, $type); } // check IMAP BINARY extension support for 'file' objects // allow configuration to workaround bug in Cyrus < 2.4.17 $rcmail = rcube::get_instance(); $binary = $type == 'file' && !$rcmail->config->get('kolab_binary_disable') && $this->imap->get_capability('BINARY'); // generate and save object message if ($raw_msg = $this->build_message($object, $type, $binary, $body_file)) { // resolve old msguid before saving if ($uid && empty($object['_msguid']) && ($msguid = $this->cache->uid2msguid($uid))) { $object['_msguid'] = $msguid; $object['_mailbox'] = $this->name; } $result = $this->imap->save_message($this->name, $raw_msg, null, false, null, null, $binary); // update cache with new UID if ($result) { $old_uid = $object['_msguid'] ?? null; $object['_msguid'] = $result; $object['_mailbox'] = $this->name; if ($old_uid) { // delete old message $this->cache->imap_mode(true); $this->imap->delete_message($old_uid, $object['_mailbox']); $this->cache->imap_mode(false); } // insert/update message in cache $this->cache->save($result, $object, $old_uid); } // remove temp file if ($body_file) { @unlink($body_file); } } return $result; } /** * Save recurrence exceptions as individual objects. * The Kolab v2 format doesn't allow us to save fully embedded exception objects. * * @param array Hash array with event properties * @param string Object type */ private function save_recurrence_exceptions(&$object, $type = null) { if ($object['recurrence']['EXCEPTIONS']) { $exdates = []; foreach ((array) $object['recurrence']['EXDATE'] as $exdate) { $key = $exdate instanceof DateTimeInterface ? $exdate->format('Y-m-d') : strval($exdate); $exdates[$key] = 1; } // save every exception as individual object foreach ((array) $object['recurrence']['EXCEPTIONS'] as $exception) { $exception['uid'] = self::recurrence_exception_uid($object['uid'], $exception['start']->format('Ymd')); $exception['sequence'] = $object['sequence'] + 1; if ($exception['thisandfuture']) { $exception['recurrence'] = $object['recurrence']; // adjust the recurrence duration of the exception if ($object['recurrence']['COUNT']) { $recurrence = new kolab_date_recurrence($object['_formatobj']); if ($end = $recurrence->end()) { unset($exception['recurrence']['COUNT']); $exception['recurrence']['UNTIL'] = $end; } } // set UNTIL date if we have a thisandfuture exception $untildate = clone $exception['start']; $untildate->sub(new DateInterval('P1D')); $object['recurrence']['UNTIL'] = $untildate; unset($object['recurrence']['COUNT']); } else { if (!$exdates[$exception['start']->format('Y-m-d')]) $object['recurrence']['EXDATE'][] = clone $exception['start']; unset($exception['recurrence']); } unset($exception['recurrence']['EXCEPTIONS'], $exception['_formatobj'], $exception['_msguid']); $this->save($exception, $type, $exception['uid']); } unset($object['recurrence']['EXCEPTIONS']); } } /** * Generate an object UID with the given recurrence-ID in a way that it is * unique (the original UID is not a substring) but still recoverable. */ private static function recurrence_exception_uid($uid, $recurrence_id) { $offset = -2; return substr($uid, 0, $offset) . '-' . $recurrence_id . '-' . substr($uid, $offset); } /** * Delete the specified object from this folder. * * @param mixed $object The Kolab object to delete or object UID * @param boolean $expunge Should the folder be expunged? * * @return boolean True if successful, false on error */ public function delete($object, $expunge = true) { if (!$this->valid) { return false; } $msguid = is_array($object) ? $object['_msguid'] : $this->cache->uid2msguid($object); $success = false; $this->cache->imap_mode(true); if ($msguid && $expunge) { $success = $this->imap->delete_message($msguid, $this->name); } else if ($msguid) { $success = $this->imap->set_flag($msguid, 'DELETED', $this->name); } $this->cache->imap_mode(false); if ($success) { $this->cache->set($msguid, false); } return $success; } /** * */ public function delete_all() { if (!$this->valid) { return false; } $this->cache->purge(); $this->cache->imap_mode(true); $result = $this->imap->clear_folder($this->name); $this->cache->imap_mode(false); return $result; } /** * Restore a previously deleted object * * @param string Object UID * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } if ($msguid = $this->cache->uid2msguid($uid, true)) { $this->cache->imap_mode(true); $result = $this->imap->set_flag($msguid, 'UNDELETED', $this->name); $this->cache->imap_mode(false); if ($result) { return $msguid; } } return false; } /** * Move a Kolab object message to another IMAP folder * * @param string Object UID * @param string IMAP folder to move object to * @return boolean True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } if (is_string($target_folder)) $target_folder = kolab_storage::get_folder($target_folder); if ($msguid = $this->cache->uid2msguid($uid)) { $this->cache->imap_mode(true); $result = $this->imap->move_message($msguid, $target_folder->name, $this->name); $this->cache->imap_mode(false); if ($result) { $new_uid = ($copyuid = $this->imap->conn->data['COPYUID']) ? $copyuid[1] : null; $this->cache->move($msguid, $uid, $target_folder, $new_uid); return true; } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to move message $msguid to $target_folder: " . $this->imap->get_error_str(), ), true); } } return false; } /** * Creates source of the configuration object message * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param bool $binary Enables use of binary encoding of attachment(s) * @param string $body_file Reference to filename of message body * * @return mixed Message as string or array with two elements * (one for message file path, second for message headers) */ private function build_message(&$object, $type, $binary, &$body_file) { // load old object to preserve data we don't understand/process $format = null; if (is_object($object['_formatobj'] ?? null)) $format = $object['_formatobj']; else if (!empty($object['_msguid']) && ($old = $this->cache->get($object['_msguid'], $type, $object['_mailbox'] ?? null))) $format = $old['_formatobj'] ?? null; // create new kolab_format instance if (!$format) $format = kolab_format::factory($type, kolab_storage::$version); if (PEAR::isError($format)) return false; $format->set($object); $xml = $format->write(kolab_storage::$version); $object['uid'] = $format->uid; // read UID from format $object['_formatobj'] = $format; if (empty($xml) || !$format->is_valid() || empty($object['uid'])) { return false; } $mime = new Mail_mime("\r\n"); $rcmail = rcube::get_instance(); $headers = array(); $files = array(); $part_id = 1; $encoding = $binary ? 'binary' : 'base64'; if ($user_email = $rcmail->get_user_email()) { $headers['From'] = $user_email; $headers['To'] = $user_email; } $headers['Date'] = date('r'); $headers['X-Kolab-Type'] = kolab_format::KTYPE_PREFIX . $type; $headers['X-Kolab-Mime-Version'] = kolab_storage::$version; $headers['Subject'] = $object['uid']; // $headers['Message-ID'] = $rcmail->gen_message_id(); $headers['User-Agent'] = $rcmail->config->get('useragent'); // Check if we have enough memory to handle the message in it // It's faster than using files, so we'll do this if we only can if (!empty($object['_attachments']) && ($mem_limit = parse_bytes(ini_get('memory_limit'))) > 0) { $memory = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024; // safe value: 16MB foreach ($object['_attachments'] as $attachment) { $memory += $attachment['size']; } // 1.33 is for base64, we need at least 4x more memory than the message size if ($memory * ($binary ? 1 : 1.33) * 4 > $mem_limit) { $marker = '%%%~~~' . md5(microtime(true) . $memory) . '~~~%%%'; $is_file = true; $temp_dir = unslashify($rcmail->config->get('temp_dir')); $mime->setParam('delay_file_io', true); } } $mime->headers($headers); $mime->setTXTBody("This is a Kolab Groupware object. " . "To view this object you will need an email client that understands the Kolab Groupware format. " . "For a list of such email clients please visit http://www.kolab.org/\n\n"); $ctype = kolab_storage::$version == '2.0' ? $format->CTYPEv2 : $format->CTYPE; // Convert new lines to \r\n, to wrokaround "NO Message contains bare newlines" // when APPENDing from temp file $xml = preg_replace('/\r?\n/', "\r\n", $xml); $mime->addAttachment($xml, // file $ctype, // content-type 'kolab.xml', // filename false, // is_file '8bit', // encoding 'attachment', // disposition RCUBE_CHARSET // charset ); $part_id++; $is_file = false; // save object attachments as separate parts foreach ((array)($object['_attachments'] ?? []) as $key => $att) { if (empty($att['content']) && !empty($att['id'])) { // @TODO: use IMAP CATENATE to skip attachment fetch+push operation $msguid = $object['_copyfrom'] ?: ($object['_msguid'] ?: $object['uid']); if ($is_file) { $att['path'] = tempnam($temp_dir, 'rcmAttmnt'); if (($fp = fopen($att['path'], 'w')) && $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, $fp, true)) { fclose($fp); } else { return false; } } else { $att['content'] = $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, null, true); } } $headers = array('Content-ID' => Mail_mimePart::encodeHeader('Content-ID', '<' . $key . '>', RCUBE_CHARSET, 'quoted-printable')); $name = !empty($att['name']) ? $att['name'] : $key; // To store binary files we can use faster method // without writting full message content to a temporary file but // directly to IMAP, see rcube_imap_generic::append(). // I.e. use file handles where possible if (!empty($att['path'])) { if ($is_file && $binary) { $files[] = fopen($att['path'], 'r'); $mime->addAttachment($marker, $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } else { $mime->addAttachment($att['path'], $att['mimetype'], $name, true, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } } else { if (is_resource($att['content']) && $is_file && $binary) { $files[] = $att['content']; $mime->addAttachment($marker, $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } else { if (is_resource($att['content'])) { @rewind($att['content']); $att['content'] = stream_get_contents($att['content']); } $mime->addAttachment($att['content'], $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } } $object['_attachments'][$key]['id'] = ++$part_id; } if (!$is_file || !empty($files)) { $message = $mime->getMessage(); } // parse message and build message array with // attachment file pointers in place of file markers if (!empty($files)) { $message = explode($marker, $message); $tmp = array(); foreach ($message as $msg_part) { $tmp[] = $msg_part; if ($file = array_shift($files)) { $tmp[] = $file; } } $message = $tmp; } // write complete message body into temp file else if ($is_file) { // use common temp dir $body_file = tempnam($temp_dir, 'rcmMsg'); if (PEAR::isError($mime_result = $mime->saveMessageBody($body_file))) { rcube::raise_error(array('code' => 650, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not create message: ".$mime_result->getMessage()), true, false); return false; } $message = array(trim($mime->txtHeaders()) . "\r\n\r\n", fopen($body_file, 'r')); } return $message; } /** * Triggers any required updates after changes within the * folder. This is currently only required for handling free/busy * information with Kolab. * * @return boolean|PEAR_Error True if successfull. */ public function trigger() { $owner = $this->get_owner(); $result = false; switch($this->type) { case 'event': if ($this->get_namespace() == 'personal') { $result = $this->trigger_url( sprintf('%s/trigger/%s/%s.pfb', kolab_storage::get_freebusy_server(), urlencode($owner), urlencode($this->imap->mod_folder($this->name)) ), $this->imap->options['user'], $this->imap->options['password'] ); } break; default: return true; } if ($result && is_object($result) && is_a($result, 'PEAR_Error')) { return PEAR::raiseError( sprintf("Failed triggering folder %s. Error was: %s", $this->name, $result->getMessage()) ); } return $result; } /** * Triggers a URL. * * @param string $url The URL to be triggered. * @param string $auth_user Username to authenticate with * @param string $auth_passwd Password for basic auth * @return boolean|PEAR_Error True if successfull. */ private function trigger_url($url, $auth_user = null, $auth_passwd = null) { try { $request = libkolab::http_request($url); // set authentication credentials if ($auth_user && $auth_passwd) $request->setAuth($auth_user, $auth_passwd); $result = $request->send(); // rcube::write_log('trigger', $result->getBody()); } catch (Exception $e) { return PEAR::raiseError($e->getMessage()); } return true; } /** * Log content to a file in per_user_loggin dir if configured */ private static function save_user_xml($filename, $content) { $rcmail = rcube::get_instance(); if ($rcmail->config->get('kolab_format_error_log')) { $log_dir = $rcmail->config->get('log_dir', RCUBE_INSTALL_PATH . 'logs'); $user_name = $rcmail->get_user_name(); $log_dir = $log_dir . '/' . $user_name; if (!empty($user_name) && is_writable($log_dir)) { file_put_contents("$log_dir/$filename", $content); } } } }