diff --git a/plugins/kolab_addressbook/config.inc.php.dist b/plugins/kolab_addressbook/config.inc.php.dist index ba8f60d1..bf4dc1d5 100644 --- a/plugins/kolab_addressbook/config.inc.php.dist +++ b/plugins/kolab_addressbook/config.inc.php.dist @@ -1,35 +1,39 @@ diff --git a/plugins/kolab_addressbook/kolab_addressbook.php b/plugins/kolab_addressbook/kolab_addressbook.php index efbf74c8..a5c6e446 100644 --- a/plugins/kolab_addressbook/kolab_addressbook.php +++ b/plugins/kolab_addressbook/kolab_addressbook.php @@ -1,1200 +1,1178 @@ * @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 $folders; private $rc; private $ui; - - public $bonnie_api = false; + private $driver_class; 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'); - $driver = $this->rc->config->get('kolab_addressbook_driver') ?: 'kolab'; - require_once(dirname(__FILE__) . '/lib/rcube_' . $driver . '_contacts.php'); + $this->load_config(); + + $this->driver = $this->rc->config->get('kolab_addressbook_driver') ?: 'kolab'; + $this->driver_class = 'rcube_' . $this->driver . '_contacts'; + require_once(dirname(__FILE__) . '/lib/' . $this->driver_class . '.php'); // register hooks $this->add_hook('addressbooks_list', array($this, 'address_sources')); $this->add_hook('addressbook_get', array($this, 'get_address_book')); $this->add_hook('config_get', array($this, 'config_get')); if ($this->rc->task == 'addressbook') { $this->add_texts('localization'); $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') { - $this->load_config(); 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')); } - $this->add_hook('folder_delete', array($this, 'prefs_folder_delete')); - $this->add_hook('folder_rename', array($this, 'prefs_folder_rename')); - $this->add_hook('folder_update', array($this, 'prefs_folder_update')); + if ($this->driver == 'kolab') { + $this->add_hook('folder_delete', array($this, 'prefs_folder_delete')); + $this->add_hook('folder_rename', array($this, 'prefs_folder_rename')); + $this->add_hook('folder_update', array($this, 'prefs_folder_update')); + } } /** * 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->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; } /** * Helper method to build a hash array of address book properties */ protected function abook_prop($id, $abook) { if ($abook->virtual) { return array( 'id' => $id, 'name' => $abook->get_name(), 'listname' => $abook->get_foldername(), 'group' => $abook instanceof kolab_storage_folder_user ? 'user' : $abook->get_namespace(), 'readonly' => true, 'rights' => 'l', 'kolab' => true, 'virtual' => true, ); } else { return array( 'id' => $id, 'name' => $abook->get_name(), 'listname' => $abook->get_foldername(), 'readonly' => $abook->readonly, 'rights' => $abook->rights, 'groups' => $abook->groups, 'undelete' => $abook->undelete && $this->rc->config->get('undo_timeout'), 'realname' => rcube_charset::convert($abook->get_realname(), 'UTF7-IMAP'), // IMAP folder name 'group' => $abook->get_namespace(), 'subscribed' => $abook->is_subscribed(), 'carddavurl' => $abook->get_carddav_url(), 'removable' => true, 'kolab' => true, 'audittrail' => !empty($this->bonnie_api), ); } } /** * */ public function directorylist_html($args) { $out = ''; $jsdata = array(); $sources = (array)$this->rc->get_address_sources(); // list all non-kolab sources first (also exclude hidden sources) $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); $out .= $this->addressbook_list_item($id, $source, $jsdata) . ''; } + $filter = function($source) { return !empty($source['kolab']) && empty($source['hidden']); }; + $folders = array_filter($sources, $filter); + // render a hierarchical list of kolab contact folders - kolab_storage::folder_hierarchy($this->folders, $tree); - if ($tree && !empty($tree->children)) { - $out .= $this->folder_tree_html($tree, $sources, $jsdata); + // TODO: Move this to the drivers + if ($this->driver == 'kolab') { + kolab_storage::folder_hierarchy($folders, $tree); + if ($tree && !empty($tree->children)) { + $out .= $this->folder_tree_html($tree, $sources, $jsdata); + } + } + else { + foreach ($folders as $j => $source) { + $id = strval(strlen($source['id']) ? $source['id'] : $j); + $out .= $this->addressbook_list_item($id, $source, $jsdata) . ''; + } } $this->rc->output->set_env('contactgroups', array_filter($jsdata, function($src){ return $src['type'] == 'group'; })); $this->rc->output->set_env('address_sources', array_filter($jsdata, function($src){ return $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 */ public 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 ($folder->virtual) { $source = $this->abook_prop($folder->id, $folder); } else if (empty($source)) { $this->sources[$id] = new rcube_kolab_contacts($folder->name); $source = $this->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); } // ... 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 (!$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 ($source['group']) $classes[] = $source['group']; if ($current === $id) $classes[] = 'selected'; if ($source['readonly']) $classes[] = 'readonly'; if ($source['virtual']) $classes[] = 'virtual'; if ($source['class_name']) $classes[] = $source['class_name']; $name = !empty($source['listname']) ? $source['listname'] : (!empty($source['name']) ? $source['name'] : $id); $label_id = 'kabt:' . $id; $inner = ($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 (isset($source['subscribed'])) { + if ($this->driver == 'kolab' && 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), 'noclose' => true, ), html::div($source['subscribed'] ? 'subscribed' : null, $inner) ); $groupdata = array('out' => '', 'jsdata' => $jsdata, 'source' => $id); if ($source['groups'] && function_exists('rcmail_contact_groups')) { $groupdata = rcmail_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']) { - $id = kolab_storage::id_decode($p['id']); - $folder = kolab_storage::get_folder($id); - - // try with unencoded (old-style) identifier - if ((!$folder || $folder->type != 'contact') && $id != $p['id']) { - $folder = kolab_storage::get_folder($p['id']); - } - - if ($folder && $folder->type == 'contact') { - $p['instance'] = new rcube_kolab_contacts($folder->name); + if ($source = $this->driver_class::get_address_book($p['id'])) { + $p['instance'] = $source; // flag source as writeable if 'i' right is given if ($p['writeable'] && $this->rc->action == 'save' && strpos($p['instance']->rights, 'i') !== false) { $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)) + if (isset($this->sources)) { return $this->sources; + } - kolab_storage::$encode_ids = true; - $this->sources = array(); - $this->folders = array(); + $this->sources = []; $abook_prio = $this->addressbook_prio(); // Personal address source(s) disabled? - if ($abook_prio == self::GLOBAL_ONLY) { + if ($abook_prio == kolab_addressbook::GLOBAL_ONLY) { return $this->sources; } // get all folders that have "contact" type - $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact')); - - if (PEAR::isError($folders)) { - rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage()), - true, false); - } - else { - // we need at least one folder to prevent from errors in Roundcube core - // when there's also no sql nor ldap addressbook (Bug #2086) - if (empty($folders)) { - if ($folder = kolab_storage::create_default_folder('contact')) { - $folders = array(new kolab_storage_folder($folder, 'contact')); - } - } - - // convert to UTF8 and sort - foreach ($folders as $folder) { - // create instance of rcube_contacts - $abook_id = $folder->id; - $abook = new rcube_kolab_contacts($folder->name); - $this->sources[$abook_id] = $abook; - $this->folders[$abook_id] = $folder; - } + foreach ($this->driver_class::list_folders() as $id => $source) { + $this->sources[$id] = $source; } return $this->sources; } - /** * Plugin hook called before rendering the contact form or detail view * * @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 (!is_object($GLOBALS['CONTACTS']) || !is_a($GLOBALS['CONTACTS'], 'rcube_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 rcube_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', rcmail_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', rcmail_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 = array(); + $block = []; - foreach (array_keys($source->coltypes) as $col) { - if (isset($contents[$col])) - $block[$col] = $contents[$col]; - } + foreach (array_keys($source->coltypes) as $col) { + if (isset($contents[$col])) { + $block[$col] = $contents[$col]; + } + } - return $block; + return $block; } - /** * Handler for user preferences form (preferences_list hook) * * @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() { $prop = array( 'name' => trim(rcube_utils::get_input_value('_name', rcube_utils::INPUT_POST)), 'oldname' => trim(rcube_utils::get_input_value('_oldname', rcube_utils::INPUT_POST, true)), // UTF7-IMAP 'parent' => trim(rcube_utils::get_input_value('_parent', rcube_utils::INPUT_POST, true)), // UTF7-IMAP 'type' => 'contact', 'subscribed' => true, ); $result = $error = false; $type = strlen($prop['oldname']) ? 'update' : 'create'; $prop = $this->rc->plugins->exec_hook('addressbook_'.$type, $prop); if (!$prop['abort']) { if ($newfolder = kolab_storage::folder_update($prop)) { $folder = $newfolder; $result = true; } else { $error = kolab_storage::$last_error; } } else { $result = $prop['result']; $folder = $prop['name']; } if ($result) { $kolab_folder = kolab_storage::get_folder($folder); // get folder/addressbook properties $abook = new rcube_kolab_contacts($folder); $props = $this->abook_prop(kolab_storage::folder_id($folder, true), $abook); $props['parent'] = kolab_storage::folder_id($kolab_folder->get_parent(), true); $this->rc->output->show_message('kolab_addressbook.book'.$type.'d', 'confirmation'); $this->rc->output->command('book_update', $props, kolab_storage::folder_id($prop['oldname'], true)); } else { if (!$error) { $error = $plugin['message'] ? $plugin['message'] : 'kolab_addressbook.book'.$type.'error'; } $this->rc->output->show_message($error, 'error'); } $this->rc->output->send('iframe'); } /** * */ public function book_search() { - $results = array(); - $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); - $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); + $results = []; + $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); + $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); kolab_storage::$encode_ids = true; $search_more_results = false; $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 rcube_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 rcube_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->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 rcube_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() { $folder = trim(rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC, true, 'UTF7-IMAP')); if (kolab_storage::folder_delete($folder)) { $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', rcmail_get_rowcount_text(new rcube_result_set())); $this->rc->output->command('set_env', 'delimiter', $delimiter); $this->rc->output->command('list_contacts_clear'); $this->rc->output->command('book_delete_done', kolab_storage::folder_id($folder, true)); } 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() { - // Load configuration - if (!$this->config_loaded) { - $this->load_config(); - $this->config_loaded = true; - } - $abook_prio = (int) $this->rc->config->get('kolab_addressbook_prio'); // Make sure any global addressbooks are defined 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', array()); 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(array('calendar_birthday_adressbooks' => $bday_addressbooks)); } } } diff --git a/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php b/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php index 710b5562..b6bad178 100644 --- a/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php +++ b/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php @@ -1,256 +1,260 @@ * * 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 kolab_addressbook_ui { private $plugin; private $rc; /** * Class constructor * * @param kolab_addressbook $plugin Plugin object */ public function __construct($plugin) { $this->rc = rcube::get_instance(); $this->plugin = $plugin; $this->init_ui(); } /** * Adds folders management functionality to Addressbook UI */ private function init_ui() { if (!empty($this->rc->action) && !preg_match('/^plugin\.book/', $this->rc->action) && $this->rc->action != 'show') { return; } // Include script $this->plugin->include_script('kolab_addressbook.js'); if (empty($this->rc->action)) { // Include stylesheet (for directorylist) $this->plugin->include_stylesheet($this->plugin->local_skin_path().'/kolab_addressbook.css'); + if ($this->plugin->driver != 'kolab') { + return; + } + // include kolab folderlist widget if available if (in_array('libkolab', $this->plugin->api->loaded_plugins())) { $this->plugin->api->include_script('libkolab/libkolab.js'); } $this->rc->output->add_footer($this->rc->output->parse('kolab_addressbook.search_addon', false, false)); // Add actions on address books $options = array('book-create', 'book-edit', 'book-delete', 'book-remove'); $idx = 0; if ($dav_url = $this->rc->config->get('kolab_addressbook_carddav_url')) { - $options[] = 'book-showurl'; - $this->rc->output->set_env('kolab_addressbook_carddav_url', true); - - // set CardDAV URI for specified ldap addressbook - if ($ldap_abook = $this->rc->config->get('kolab_addressbook_carddav_ldap')) { - $dav_ldap_url = strtr($dav_url, array( - '%h' => $_SERVER['HTTP_HOST'], - '%u' => urlencode($this->rc->get_user_name()), - '%i' => 'ldap-directory', - '%n' => '', - )); - $this->rc->output->set_env('kolab_addressbook_carddav_ldap', $ldap_abook); - $this->rc->output->set_env('kolab_addressbook_carddav_ldap_url', $dav_ldap_url); - } + $options[] = 'book-showurl'; + $this->rc->output->set_env('kolab_addressbook_carddav_url', true); + + // set CardDAV URI for specified ldap addressbook + if ($ldap_abook = $this->rc->config->get('kolab_addressbook_carddav_ldap')) { + $dav_ldap_url = strtr($dav_url, array( + '%h' => $_SERVER['HTTP_HOST'], + '%u' => urlencode($this->rc->get_user_name()), + '%i' => 'ldap-directory', + '%n' => '', + )); + $this->rc->output->set_env('kolab_addressbook_carddav_ldap', $ldap_abook); + $this->rc->output->set_env('kolab_addressbook_carddav_ldap_url', $dav_ldap_url); + } } foreach ($options as $command) { $content = html::tag('li', $idx ? null : array('class' => 'separator_above'), $this->plugin->api->output->button(array( 'label' => 'kolab_addressbook.'.str_replace('-', '', $command), 'domain' => $this->ID, 'class' => str_replace('-', ' ', $command) . ' disabled', 'classact' => str_replace('-', ' ', $command) . ' active', 'command' => $command, 'type' => 'link' ))); $this->plugin->api->add_content($content, 'groupoptions'); $idx++; } // Link to Settings/Folders $content = html::tag('li', array('class' => 'separator_above'), $this->plugin->api->output->button(array( 'label' => 'managefolders', 'type' => 'link', 'class' => 'folders disabled', 'classact' => 'folders active', 'command' => 'folders', 'task' => 'settings', ))); $this->plugin->api->add_content($content, 'groupoptions'); $this->rc->output->add_label( 'kolab_addressbook.bookdeleteconfirm', 'kolab_addressbook.bookdeleting', 'kolab_addressbook.carddavurldescription', 'kolab_addressbook.bookdelete', 'kolab_addressbook.bookshowurl', 'kolab_addressbook.bookedit', 'kolab_addressbook.bookcreate', 'kolab_addressbook.nobooknamewarning', 'kolab_addressbook.booksaving', 'kolab_addressbook.findaddressbooks', 'kolab_addressbook.searchterms', 'kolab_addressbook.foldersearchform', 'kolab_addressbook.listsearchresults', 'kolab_addressbook.nraddressbooksfound', 'kolab_addressbook.noaddressbooksfound', 'kolab_addressbook.foldersubscribe', 'resetsearch' ); if ($this->plugin->bonnie_api) { $this->rc->output->set_env('kolab_audit_trail', true); $this->plugin->api->include_script('libkolab/libkolab.js'); $this->rc->output->add_label( 'kolab_addressbook.showhistory', 'kolab_addressbook.objectchangelog', 'kolab_addressbook.objectdiff', 'kolab_addressbook.objectdiffnotavailable', 'kolab_addressbook.objectchangelognotavailable', 'kolab_addressbook.revisionrestoreconfirm' ); $this->plugin->add_hook('render_page', array($this, 'render_audittrail_page')); $this->plugin->register_handler('plugin.object_changelog_table', array('libkolab', 'object_changelog_table')); } } // include stylesheet for audit trail else if ($this->rc->action == 'show' && $this->plugin->bonnie_api) { $this->plugin->include_stylesheet($this->plugin->local_skin_path().'/kolab_addressbook.css', true); $this->rc->output->add_label('kolab_addressbook.showhistory'); } } /** * Handler for address book create/edit action */ public function book_edit() { $this->rc->output->set_env('pagetitle', $this->plugin->gettext('bookproperties')); $this->rc->output->add_handler('folderform', array($this, 'book_form')); $this->rc->output->send('libkolab.folderform'); } /** * Handler for 'bookdetails' object returning form content for book create/edit * * @param array $attr Object attributes * * @return string HTML output */ public function book_form($attrib) { $action = trim(rcube_utils::get_input_value('_act', rcube_utils::INPUT_GPC)); $folder = trim(rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC, true)); // UTF8 $hidden_fields[] = array('name' => '_source', 'value' => $folder); $folder = rcube_charset::convert($folder, RCUBE_CHARSET, 'UTF7-IMAP'); $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); if ($action == 'edit') { $path_imap = explode($delim, $folder); $name = rcube_charset::convert(array_pop($path_imap), 'UTF7-IMAP'); $path_imap = implode($delim, $path_imap); } else { // create $path_imap = $folder; $name = ''; $folder = ''; } // Store old name, get folder options if (strlen($folder)) { $hidden_fields[] = array('name' => '_oldname', 'value' => $folder); $options = $storage->folder_info($folder); } $form = array(); // General tab $form['properties'] = array( 'name' => $this->rc->gettext('properties'), 'fields' => array(), ); if (!empty($options) && ($options['norename'] || $options['protected'])) { $foldername = rcube::Q(str_replace($delim, ' » ', kolab_storage::object_name($folder))); } else { $foldername = new html_inputfield(array('name' => '_name', 'id' => '_name', 'size' => 30)); $foldername = $foldername->show($name); } $form['properties']['fields']['name'] = array( 'label' => $this->plugin->gettext('bookname'), 'value' => $foldername, 'id' => '_name', ); if (!empty($options) && ($options['norename'] || $options['protected'])) { // prevent user from moving folder $hidden_fields[] = array('name' => '_parent', 'value' => $path_imap); } else { $prop = array('name' => '_parent', 'id' => '_parent'); $select = kolab_storage::folder_selector('contact', $prop, $folder); $form['properties']['fields']['parent'] = array( 'label' => $this->plugin->gettext('parentbook'), 'value' => $select->show(strlen($folder) ? $path_imap : ''), 'id' => '_parent', ); } $form_html = kolab_utils::folder_form($form, $folder, 'calendar', $hidden_fields); return html::tag('form', $attrib + array('action' => 'plugin.book-save', 'method' => 'post', 'id' => 'bookpropform'), $form_html); } /** * */ public function render_audittrail_page($p) { // append audit trail UI elements to contact page if ($p['template'] === 'addressbook' && !$p['kolab-audittrail']) { $this->rc->output->add_footer($this->rc->output->parse('kolab_addressbook.audittrail', false, false)); $p['kolab-audittrail'] = true; } return $p; } } diff --git a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php b/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php index 1f9ac291..be1a2b02 100644 --- a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php +++ b/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php @@ -1,1400 +1,1458 @@ * @author Aleksander Machniak * * 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 . * * @see rcube_addressbook */ class rcube_kolab_contacts extends rcube_addressbook { public $primary_key = 'ID'; public $rights = 'lrs'; public $readonly = true; public $undelete = true; public $groups = true; public $coltypes = array( 'name' => array('limit' => 1), 'firstname' => array('limit' => 1), 'surname' => array('limit' => 1), 'middlename' => array('limit' => 1), 'prefix' => array('limit' => 1), 'suffix' => array('limit' => 1), 'nickname' => array('limit' => 1), 'jobtitle' => array('limit' => 1), 'organization' => array('limit' => 1), 'department' => array('limit' => 1), 'email' => array('subtypes' => array('home','work','other')), 'phone' => array(), 'address' => array('subtypes' => array('home','work','office')), 'website' => array('subtypes' => array('homepage','blog')), 'im' => array('subtypes' => null), 'gender' => array('limit' => 1), 'birthday' => array('limit' => 1), 'anniversary' => array('limit' => 1), 'profession' => array( 'type' => 'text', 'size' => 40, 'maxlength' => 80, 'limit' => 1, 'label' => 'kolab_addressbook.profession', 'category' => 'personal' ), 'manager' => array('limit' => null), 'assistant' => array('limit' => null), 'spouse' => array('limit' => 1), 'children' => array( 'type' => 'text', 'size' => 40, 'maxlength' => 80, 'limit' => null, 'label' => 'kolab_addressbook.children', 'category' => 'personal' ), 'freebusyurl' => array( 'type' => 'text', 'size' => 40, 'limit' => 1, 'label' => 'kolab_addressbook.freebusyurl' ), 'pgppublickey' => array( 'type' => 'textarea', 'size' => 70, 'rows' => 10, 'limit' => 1, 'label' => 'kolab_addressbook.pgppublickey' ), 'pkcs7publickey' => array( 'type' => 'textarea', 'size' => 70, 'rows' => 10, 'limit' => 1, 'label' => 'kolab_addressbook.pkcs7publickey' ), 'notes' => array('limit' => 1), 'photo' => array('limit' => 1), // TODO: define more Kolab-specific fields such as: language, latitude, longitude, crypto settings ); /** * vCard additional fields mapping */ public $vcard_map = array( 'profession' => 'X-PROFESSION', 'officelocation' => 'X-OFFICE-LOCATION', 'initials' => 'X-INITIALS', 'children' => 'X-CHILDREN', 'freebusyurl' => 'X-FREEBUSY-URL', 'pgppublickey' => 'KEY', ); /** * List of date type fields */ public $date_cols = array('birthday', 'anniversary'); private $gid; private $storagefolder; private $dataset; private $sortindex; private $contacts; private $distlists; private $groupmembers; private $filter; private $result; private $namespace; private $imap_folder = 'INBOX/Contacts'; private $action; // list of fields used for searching in "All fields" mode private $search_fields = array( 'name', 'firstname', 'surname', 'middlename', 'prefix', 'suffix', 'nickname', 'jobtitle', 'organization', 'department', 'email', 'phone', 'address', 'profession', 'manager', 'assistant', 'spouse', 'children', 'notes', ); public function __construct($imap_folder = null) { if ($imap_folder) { $this->imap_folder = $imap_folder; } // extend coltypes configuration $format = kolab_format::factory('contact'); $this->coltypes['phone']['subtypes'] = array_keys($format->phonetypes); $this->coltypes['address']['subtypes'] = array_keys($format->addresstypes); $rcube = rcube::get_instance(); // set localized labels for proprietary cols foreach ($this->coltypes as $col => $prop) { if (is_string($prop['label'])) { $this->coltypes[$col]['label'] = $rcube->gettext($prop['label']); } } // fetch objects from the given IMAP folder $this->storagefolder = kolab_storage::get_folder($this->imap_folder); $this->ready = $this->storagefolder && !PEAR::isError($this->storagefolder); // Set readonly and rights flags according to folder permissions if ($this->ready) { if ($this->storagefolder->get_owner() == $_SESSION['username']) { $this->readonly = false; $this->rights = 'lrswikxtea'; } else { $rights = $this->storagefolder->get_myrights(); if ($rights && !PEAR::isError($rights)) { $this->rights = $rights; if (strpos($rights, 'i') !== false && strpos($rights, 't') !== false) { $this->readonly = false; } } } } $this->action = rcube::get_instance()->action; } /** * Getter for the address book name to be displayed * * @return string Name of this address book */ public function get_name() { return $this->storagefolder->get_name(); } /** * Wrapper for kolab_storage_folder::get_foldername() */ public function get_foldername() { return $this->storagefolder->get_foldername(); } /** * Getter for the IMAP folder name * * @return string Name of the IMAP folder */ public function get_realname() { return $this->imap_folder; } /** * Getter for the name of the namespace to which the IMAP folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { if ($this->namespace === null && $this->ready) { $this->namespace = $this->storagefolder->get_namespace(); } return $this->namespace; } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { return $this->storagefolder->get_parent(); } /** * 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->imap_folder); } /** * Compose an URL for CardDAV access to this address book (if configured) */ public function get_carddav_url() { $rcmail = rcmail::get_instance(); if ($template = $rcmail->config->get('kolab_addressbook_carddav_url', null)) { return strtr($template, array( '%h' => $_SERVER['HTTP_HOST'], '%u' => urlencode($rcmail->get_user_name()), '%i' => urlencode($this->storagefolder->get_uid()), '%n' => urlencode($this->imap_folder), )); } return false; } /** * Setter for the current group */ public function set_group($gid) { $this->gid = $gid; } /** * Save a search string for future listings * * @param mixed Search params to use in listing method, obtained by get_search_set() */ public function set_search_set($filter) { $this->filter = $filter; } /** * Getter for saved search properties * * @return mixed Search properties used by this class */ public function get_search_set() { return $this->filter; } /** * Reset saved results and search parameters */ public function reset() { $this->result = null; $this->filter = null; } + /** + * List addressbook sources (folders) + */ + public static function list_folders() + { + kolab_storage::$encode_ids = true; + + // get all folders that have "contact" type + $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact')); + + if (PEAR::isError($folders)) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage() + ], + true, false); + + return []; + } + + // we need at least one folder to prevent from errors in Roundcube core + // when there's also no sql nor ldap addressbook (Bug #2086) + if (empty($folders)) { + if ($folder = kolab_storage::create_default_folder('contact')) { + $folders = [new kolab_storage_folder($folder, 'contact')]; + } + } + + $sources = []; + foreach ($folders as $folder) { + $sources[$folder->id] = new rcube_kolab_contacts($folder->name); + } + + return $sources; + } + + /** + * Getter for the rcube_addressbook instance + * + * @param string $id Addressbook (folder) ID + * + * @return ?rcube_kolab_contacts + */ + public static function get_address_book($id) + { + $folderId = kolab_storage::id_decode($id); + $folder = kolab_storage::get_folder($folderId); + + // try with unencoded (old-style) identifier + if ((!$folder || $folder->type != 'contact') && $folderId != $id) { + $folder = kolab_storage::get_folder($id); + } + + if ($folder && $folder->type == 'contact') { + return new rcube_kolab_contacts($folder->name); + } + } + /** * List all active contact groups of this source * * @param string Optional search string to match group name * @param int Search mode. Sum of self::SEARCH_* * * @return array Indexed list of contact groups, each a hash array */ function list_groups($search = null, $mode = 0) { $this->_fetch_groups(); $groups = array(); foreach ((array)$this->distlists as $group) { if (!$search || strstr(mb_strtolower($group['name']), mb_strtolower($search))) { $groups[$group['ID']] = array('ID' => $group['ID'], 'name' => $group['name']); } } // sort groups by name uasort($groups, function($a, $b) { return strcoll($a['name'], $b['name']); }); return array_values($groups); } /** * List the current set of contact records * * @param array List of cols to show * @param int Only return this number of records, use negative values for tail * @param bool True to skip the count query (select only) * * @return array Indexed list of contact records, each a hash array */ public function list_records($cols = null, $subset = 0, $nocount = false) { $this->result = new rcube_result_set(0, ($this->list_page-1) * $this->page_size); $fetch_all = false; $fast_mode = !empty($cols) && is_array($cols); // list member of the selected group if ($this->gid) { $this->_fetch_groups(); $this->sortindex = array(); $this->contacts = array(); $local_sortindex = array(); $uids = array(); // get members with email specified foreach ((array)$this->distlists[$this->gid]['member'] as $member) { // skip member that don't match the search filter if (!empty($this->filter['ids']) && array_search($member['ID'], $this->filter['ids']) === false) { continue; } if (!empty($member['uid'])) { $uids[] = $member['uid']; } else if (!empty($member['email'])) { $this->contacts[$member['ID']] = $member; $local_sortindex[$member['ID']] = $this->_sort_string($member); $fetch_all = true; } } // get members by UID if (!empty($uids)) { $this->_fetch_contacts($query = array(array('uid', '=', $uids)), $fetch_all ? false : count($uids), $fast_mode); $this->sortindex = array_merge($this->sortindex, $local_sortindex); } } else if (is_array($this->filter['ids'])) { $ids = $this->filter['ids']; if (count($ids)) { $uids = array_map(array($this, 'id2uid'), $this->filter['ids']); $this->_fetch_contacts($query = array(array('uid', '=', $uids)), count($ids), $fast_mode); } } else { $this->_fetch_contacts($query = 'contact', true, $fast_mode); } if ($fetch_all) { // sort results (index only) asort($this->sortindex, SORT_LOCALE_STRING); $ids = array_keys($this->sortindex); // fill contact data into the current result set $this->result->count = count($ids); $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first; $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->result->first + $this->page_size, $this->result->count); for ($i = $start_row; $i < $last_row; $i++) { if (array_key_exists($i, $ids)) { $idx = $ids[$i]; $this->result->add($this->contacts[$idx] ?: $this->_to_rcube_contact($this->dataset[$idx])); } } } else if (!empty($this->dataset)) { // get all records count, skip the query if possible if (!isset($query) || count($this->dataset) < $this->page_size) { $this->result->count = count($this->dataset) + $this->page_size * ($this->list_page - 1); } else { $this->result->count = $this->storagefolder->count($query); } $start_row = $subset < 0 ? $this->page_size + $subset : 0; $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->page_size, $this->result->count); for ($i = $start_row; $i < $last_row; $i++) { $this->result->add($this->_to_rcube_contact($this->dataset[$i])); } } return $this->result; } /** * Search records * * @param mixed $fields The field name of array of field names to search in * @param mixed $value Search value (or array of values when $fields is array) * @param int $mode Matching mode: * 0 - partial (*abc*), * 1 - strict (=), * 2 - prefix (abc*) * 4 - include groups (if supported) * @param bool $select True if results are requested, False if count only * @param bool $nocount True to skip the count query (select only) * @param array $required List of fields that cannot be empty * * @return rcube_result_set List of contact records and 'count' value */ public function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array()) { // search by ID if ($fields == $this->primary_key) { $ids = !is_array($value) ? explode(',', $value) : $value; $result = new rcube_result_set(); foreach ($ids as $id) { if ($rec = $this->get_record($id, true)) { $result->add($rec); $result->count++; } } return $result; } else if ($fields == '*') { $fields = $this->search_fields; } if (!is_array($fields)) { $fields = array($fields); } if (!is_array($required) && !empty($required)) { $required = array($required); } // advanced search if (is_array($value)) { $advanced = true; $value = array_map('mb_strtolower', $value); } else { $value = mb_strtolower($value); } $scount = count($fields); // build key name regexp $regexp = '/^(' . implode('|', $fields) . ')(?:.*)$/'; // pass query to storage if only indexed cols are involved // NOTE: this is only some rough pre-filtering but probably includes false positives $squery = $this->_search_query($fields, $value, $mode); // add magic selector to select contacts with birthday dates only if (in_array('birthday', $required)) { $squery[] = array('tags', '=', 'x-has-birthday'); } $squery[] = array('type', '=', 'contact'); // get all/matching records $this->_fetch_contacts($squery); // save searching conditions $this->filter = array('fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => array()); // search by iterating over all records in dataset foreach ($this->dataset as $record) { $contact = $this->_to_rcube_contact($record); $id = $contact['ID']; // check if current contact has required values, otherwise skip it if ($required) { foreach ($required as $f) { // required field might be 'email', but contact might contain 'email:home' if (!($v = rcube_addressbook::get_col_values($f, $contact, true)) || empty($v)) { continue 2; } } } $found = array(); $contents = ''; foreach (preg_grep($regexp, array_keys($contact)) as $col) { $pos = strpos($col, ':'); $colname = $pos ? substr($col, 0, $pos) : $col; foreach ((array)$contact[$col] as $val) { if ($advanced) { $found[$colname] = $this->compare_search_value($colname, $val, $value[array_search($colname, $fields)], $mode); } else { $contents .= ' ' . join(' ', (array)$val); } } } // compare matches if (($advanced && count($found) >= $scount) || (!$advanced && rcube_utils::words_match(mb_strtolower($contents), $value))) { $this->filter['ids'][] = $id; } } // dummy result with contacts count if (!$select) { return new rcube_result_set(count($this->filter['ids']), ($this->list_page-1) * $this->page_size); } // list records (now limited by $this->filter) return $this->list_records(); } /** * Refresh saved search results after data has changed */ public function refresh_search() { if ($this->filter) { $this->search($this->filter['fields'], $this->filter['value'], $this->filter['mode']); } return $this->get_search_set(); } /** * Count number of available contacts in database * * @return rcube_result_set Result set with values for 'count' and 'first' */ public function count() { if ($this->gid) { $this->_fetch_groups(); $count = count($this->distlists[$this->gid]['member']); } else if (is_array($this->filter['ids'])) { $count = count($this->filter['ids']); } else { $count = $this->storagefolder->count('contact'); } return new rcube_result_set($count, ($this->list_page-1) * $this->page_size); } /** * Return the last result set * * @return rcube_result_set Current result set or NULL if nothing selected yet */ public function get_result() { return $this->result; } /** * Get a specific contact record * * @param mixed Record identifier(s) * @param bool True to return record as associative array, otherwise a result set is returned * * @return mixed Result object with all record fields or False if not found */ public function get_record($id, $assoc = false) { $rec = null; $uid = $this->id2uid($id); $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); if (strpos($uid, 'mailto:') === 0) { $this->_fetch_groups(true); $rec = $this->contacts[$id]; $this->readonly = true; // set source to read-only } else if (!empty($rev)) { $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->get_plugin('kolab_addressbook'); if ($plugin && ($object = $plugin->get_revision($id, kolab_storage::id_encode($this->imap_folder), $rev))) { $rec = $this->_to_rcube_contact($object); $rec['rev'] = $rev; } $this->readonly = true; // set source to read-only } else if ($object = $this->storagefolder->get_object($uid)) { $rec = $this->_to_rcube_contact($object); } if ($rec) { $this->result = new rcube_result_set(1); $this->result->add($rec); return $assoc ? $rec : $this->result; } return false; } /** * Get group assignments of a specific contact record * * @param mixed Record identifier * * @return array List of assigned groups as ID=>Name pairs */ public function get_record_groups($id) { $out = array(); $this->_fetch_groups(); if (!empty($this->groupmembers[$id])) { foreach ((array) $this->groupmembers[$id] as $gid) { if (!empty($this->distlists[$gid])) { $group = $this->distlists[$gid]; $out[$gid] = $group['name']; } } } return $out; } /** * Create a new contact record * * @param array Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values * @param bool True to check for duplicates first * * @return mixed The created record ID on success, False on error */ public function insert($save_data, $check=false) { if (!is_array($save_data)) { return false; } $insert_id = $existing = false; // check for existing records by e-mail comparison if ($check) { foreach ($this->get_col_values('email', $save_data, true) as $email) { if (($res = $this->search('email', $email, true, false)) && $res->count) { $existing = true; break; } } } if (!$existing) { // remove existing id attributes (#1101) unset($save_data['ID'], $save_data['uid']); // generate new Kolab contact item $object = $this->_from_rcube_contact($save_data); $saved = $this->storagefolder->save($object, 'contact'); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to Kolab server"), true, false); } else { $insert_id = $this->uid2id($object['uid']); } } return $insert_id; } /** * Update a specific contact record * * @param mixed Record identifier * @param array Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values * * @return bool True on success, False on error */ public function update($id, $save_data) { $updated = false; if ($old = $this->storagefolder->get_object($this->id2uid($id))) { $object = $this->_from_rcube_contact($save_data, $old); if (!$this->storagefolder->save($object, 'contact', $old['uid'])) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to Kolab server" ), true, false ); } else { $updated = true; // TODO: update data in groups this contact is member of } } return $updated; } /** * Mark one or more contact records as deleted * * @param array Record identifiers * @param bool Remove record(s) irreversible (mark as deleted otherwise) * * @return int Number of records deleted */ public function delete($ids, $force=true) { $this->_fetch_groups(); if (!is_array($ids)) { $ids = explode(',', $ids); } $count = 0; foreach ($ids as $id) { if ($uid = $this->id2uid($id)) { $is_mailto = strpos($uid, 'mailto:') === 0; $deleted = $is_mailto || $this->storagefolder->delete($uid, $force); if (!$deleted) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting a contact object $uid from the Kolab server" ), true, false ); } else { // remove from distribution lists foreach ((array) $this->groupmembers[$id] as $gid) { if (!$is_mailto || $gid == $this->gid) { $this->remove_from_group($gid, $id); } } // clear internal cache unset($this->groupmembers[$id]); $count++; } } } return $count; } /** * Undelete one or more contact records. * Only possible just after delete (see 2nd argument of delete() method). * * @param array Record identifiers * * @return int Number of records restored */ public function undelete($ids) { if (!is_array($ids)) { $ids = explode(',', $ids); } $count = 0; foreach ($ids as $id) { $uid = $this->id2uid($id); if ($this->storagefolder->undelete($uid)) { $count++; } else { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error undeleting a contact object $uid from the Kolab server" ), true, false ); } } return $count; } /** * Remove all records from the database * * @param bool $with_groups Remove also groups */ public function delete_all($with_groups = false) { if ($this->storagefolder->delete_all()) { $this->contacts = array(); $this->sortindex = array(); $this->dataset = null; $this->result = null; } } /** * Close connection to source * Called on script shutdown */ public function close() { } /** * Create a contact group with the given name * * @param string The group name * * @return mixed False on error, array with record props in success */ function create_group($name) { $this->_fetch_groups(); $result = false; $list = array( 'name' => $name, 'member' => array(), ); $saved = $this->storagefolder->save($list, 'distribution-list'); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving distribution-list object to Kolab server" ), true, false ); return false; } else { $id = $this->uid2id($list['uid']); $this->distlists[$id] = $list; $result = array('id' => $id, 'name' => $name); } return $result; } /** * Delete the given group and all linked group members * * @param string Group identifier * * @return bool True on success, false if no data was changed */ function delete_group($gid) { $this->_fetch_groups(); $result = false; if ($list = $this->distlists[$gid]) { $deleted = $this->storagefolder->delete($list['uid']); } if (!$deleted) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting distribution-list object from the Kolab server" ), true, false ); } else { $result = true; } return $result; } /** * Rename a specific contact group * * @param string Group identifier * @param string New name to set for this group * @param string New group identifier (if changed, otherwise don't set) * * @return bool New name on success, false if no data was changed */ function rename_group($gid, $newname, &$newid) { $this->_fetch_groups(); $list = $this->distlists[$gid]; if ($newname != $list['name']) { $list['name'] = $newname; $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); } if (!$saved) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving distribution-list object to Kolab server" ), true, false ); return false; } return $newname; } /** * Add the given contact records the a certain group * * @param string Group identifier * @param array List of contact identifiers to be added * @return int Number of contacts added */ function add_to_group($gid, $ids) { if (!is_array($ids)) { $ids = explode(',', $ids); } $this->_fetch_groups(true); $list = $this->distlists[$gid]; $added = 0; $uids = array(); $exists = array(); foreach ((array)$list['member'] as $member) { $exists[] = $member['ID']; } // substract existing assignments from list $ids = array_unique(array_diff($ids, $exists)); // add mailto: members foreach ($ids as $contact_id) { $uid = $this->id2uid($contact_id); if (strpos($uid, 'mailto:') === 0 && ($contact = $this->contacts[$contact_id])) { $list['member'][] = array( 'email' => $contact['email'], 'name' => $contact['name'], ); $this->groupmembers[$contact_id][] = $gid; $added++; } else { $uids[$uid] = $contact_id; } } // add members with UID if (!empty($uids)) { foreach ($uids as $uid => $contact_id) { $list['member'][] = array('uid' => $uid); $this->groupmembers[$contact_id][] = $gid; $added++; } } if ($added) { $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); } else { $saved = true; } if (!$saved) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving distribution-list to Kolab server" ), true, false ); $added = false; $this->set_error(self::ERROR_SAVING, 'errorsaving'); } else { $this->distlists[$gid] = $list; } return $added; } /** * Remove the given contact records from a certain group * * @param string Group identifier * @param array List of contact identifiers to be removed * @return int Number of deleted group members */ function remove_from_group($gid, $ids) { if (!is_array($ids)) { $ids = explode(',', $ids); } $this->_fetch_groups(); if (!($list = $this->distlists[$gid])) { return false; } $new_member = array(); foreach ((array)$list['member'] as $member) { if (!in_array($member['ID'], $ids)) { $new_member[] = $member; } } // write distribution list back to server $list['member'] = $new_member; $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving distribution-list object to Kolab server" ), true, false ); } else { // remove group assigments in local cache foreach ($ids as $id) { $j = array_search($gid, $this->groupmembers[$id]); unset($this->groupmembers[$id][$j]); } $this->distlists[$gid] = $list; return true; } return false; } /** * Check the given data before saving. * If input not valid, the message to display can be fetched using get_error() * * @param array Associative array with contact data to save * @param bool Attempt to fix/complete data automatically * * @return bool True if input is valid, False if not. */ public function validate(&$save_data, $autofix = false) { // validate e-mail addresses $valid = parent::validate($save_data); // require at least one e-mail address if there's no name // (syntax check is already done) if ($valid) { if (!strlen($save_data['name']) && !strlen($save_data['organization']) && !array_filter($this->get_col_values('email', $save_data, true)) ) { $this->set_error('warning', 'kolab_addressbook.noemailnamewarning'); $valid = false; } } return $valid; } /** * Query storage layer and store records in private member var */ private function _fetch_contacts($query = array(), $limit = false, $fast_mode = false) { if (!isset($this->dataset) || !empty($query)) { if ($limit) { $size = is_int($limit) && $limit < $this->page_size ? $limit : $this->page_size; $this->storagefolder->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page-1) * $this->page_size); } $this->sortindex = array(); $this->dataset = $this->storagefolder->select($query, $fast_mode); foreach ($this->dataset as $idx => $record) { $contact = $this->_to_rcube_contact($record); $this->sortindex[$idx] = $this->_sort_string($contact); } } } /** * Extract a string for sorting from the given contact record */ private function _sort_string($rec) { $str = ''; switch ($this->sort_col) { case 'name': $str = $rec['name'] . $rec['prefix']; case 'firstname': $str .= $rec['firstname'] . $rec['middlename'] . $rec['surname']; break; case 'surname': $str = $rec['surname'] . $rec['firstname'] . $rec['middlename']; break; default: $str = $rec[$this->sort_col]; break; } $str .= is_array($rec['email']) ? $rec['email'][0] : $rec['email']; return mb_strtolower($str); } /** * Return the cache table columns to order by */ private function _sort_columns() { $sortcols = array(); switch ($this->sort_col) { case 'name': $sortcols[] = 'name'; case 'firstname': $sortcols[] = 'firstname'; break; case 'surname': $sortcols[] = 'surname'; break; } $sortcols[] = 'email'; return $sortcols; } /** * Read distribution-lists AKA groups from server */ private function _fetch_groups($with_contacts = false) { if (!isset($this->distlists)) { $this->distlists = $this->groupmembers = array(); foreach ($this->storagefolder->select('distribution-list', true) as $record) { $record['ID'] = $this->uid2id($record['uid']); foreach ((array)$record['member'] as $i => $member) { $mid = $this->uid2id($member['uid'] ? $member['uid'] : 'mailto:' . $member['email']); $record['member'][$i]['ID'] = $mid; $record['member'][$i]['readonly'] = empty($member['uid']); $this->groupmembers[$mid][] = $record['ID']; if ($with_contacts && empty($member['uid'])) { $this->contacts[$mid] = $record['member'][$i]; } } $this->distlists[$record['ID']] = $record; } } } /** * Encode object UID into a safe identifier */ public function uid2id($uid) { return rtrim(strtr(base64_encode($uid), '+/', '-_'), '='); } /** * Convert Roundcube object identifier back into the original UID */ public function id2uid($id) { return base64_decode(str_pad(strtr($id, '-_', '+/'), strlen($id) % 4, '=', STR_PAD_RIGHT)); } /** * Build SQL query for fulltext matches */ private function _search_query($fields, $value, $mode) { $query = array(); $cols = array(); // $fulltext_cols might contain composite field names e.g. 'email:address' while $fields not foreach (kolab_format_contact::$fulltext_cols as $col) { if ($pos = strpos($col, ':')) { $col = substr($col, 0, $pos); } if (in_array($col, $fields)) { $cols[] = $col; } } if (count($cols) == count($fields)) { if ($mode & rcube_addressbook::SEARCH_STRICT) { $prefix = '^'; $suffix = '$'; } else if ($mode & rcube_addressbook::SEARCH_PREFIX) { $prefix = '^'; $suffix = ''; } else { $prefix = ''; $suffix = ''; } $search_string = is_array($value) ? join(' ', $value) : $value; foreach (rcube_utils::normalize_string($search_string, true) as $word) { $query[] = array('words', 'LIKE', $prefix . $word . $suffix); } } return $query; } /** * Map fields from internal Kolab_Format to Roundcube contact format */ private function _to_rcube_contact($record) { $record['ID'] = $this->uid2id($record['uid']); // convert email, website, phone values foreach (array('email'=>'address', 'website'=>'url', 'phone'=>'number') as $col => $propname) { if (is_array($record[$col])) { $values = $record[$col]; unset($record[$col]); foreach ((array)$values as $i => $val) { $key = $col . ($val['type'] ? ':' . $val['type'] : ''); $record[$key][] = $val[$propname]; } } } if (is_array($record['address'])) { $addresses = $record['address']; unset($record['address']); foreach ($addresses as $i => $adr) { $key = 'address' . ($adr['type'] ? ':' . $adr['type'] : ''); $record[$key][] = array( 'street' => $adr['street'], 'locality' => $adr['locality'], 'zipcode' => $adr['code'], 'region' => $adr['region'], 'country' => $adr['country'], ); } } // photo is stored as separate attachment if ($record['photo'] && strlen($record['photo']) < 255 && !empty($record['_attachments'][$record['photo']])) { $att = $record['_attachments'][$record['photo']]; // only fetch photo content if requested if ($this->action == 'photo') { if (!empty($att['content'])) { $record['photo'] = $att['content']; } else { $record['photo'] = $this->storagefolder->get_attachment($record['uid'], $att['id']); } } } // truncate publickey value for display if (!empty($record['pgppublickey']) && $this->action == 'show') { $record['pgppublickey'] = substr($record['pgppublickey'], 0, 140) . '...'; } // remove empty fields $record = array_filter($record); // remove kolab_storage internal data unset($record['_msguid'], $record['_formatobj'], $record['_mailbox'], $record['_type'], $record['_size']); return $record; } /** * Map fields from Roundcube format to internal kolab_format_contact properties */ private function _from_rcube_contact($contact, $old = array()) { if (!$contact['uid'] && $contact['ID']) { $contact['uid'] = $this->id2uid($contact['ID']); } else if (!$contact['uid'] && $old['uid']) { $contact['uid'] = $old['uid']; } $contact['im'] = array_filter($this->get_col_values('im', $contact, true)); // convert email, website, phone values foreach (array('email'=>'address', 'website'=>'url', 'phone'=>'number') as $col => $propname) { $col_values = $this->get_col_values($col, $contact); $contact[$col] = array(); foreach ($col_values as $type => $values) { foreach ((array)$values as $val) { if (!empty($val)) { $contact[$col][] = array($propname => $val, 'type' => $type); } } unset($contact[$col.':'.$type]); } } $addresses = array(); foreach ($this->get_col_values('address', $contact) as $type => $values) { foreach ((array)$values as $adr) { // skip empty address $adr = array_filter($adr); if (empty($adr)) { continue; } $addresses[] = array( 'type' => $type, 'street' => $adr['street'], 'locality' => $adr['locality'], 'code' => $adr['zipcode'], 'region' => $adr['region'], 'country' => $adr['country'], ); } unset($contact['address:'.$type]); } $contact['address'] = $addresses; // categories are not supported in the web client but should be preserved (#2608) $contact['categories'] = $old['categories']; // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($contact[$key]) && $key[0] == '_') { $contact[$key] = $val; } } // convert one-item-array elements into string element // this is needed e.g. to properly import birthday field foreach ($this->coltypes as $type => $col_def) { if ($col_def['limit'] == 1 && is_array($contact[$type])) { $contact[$type] = array_shift(array_filter($contact[$type])); } } // When importing contacts 'vcard' data is added, we don't need it (Bug #1711) unset($contact['vcard']); // add empty values for some fields which can be removed in the UI return array_filter($contact) + array( 'nickname' => '', 'birthday' => '', 'anniversary' => '', 'freebusyurl' => '', 'photo' => $contact['photo'] ); } } diff --git a/plugins/libkolab/lib/kolab_dav_client.php b/plugins/libkolab/lib/kolab_dav_client.php index d9ae88da..e6962f53 100644 --- a/plugins/libkolab/lib/kolab_dav_client.php +++ b/plugins/libkolab/lib/kolab_dav_client.php @@ -1,538 +1,571 @@ * * Copyright (C) 2022, Apheleia IT AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_dav_client { public $url; protected $user; protected $password; protected $rc; protected $responseHeaders = []; /** * Object constructor */ public function __construct($url) { $this->rc = rcube::get_instance(); $parsedUrl = parse_url($url); if (!empty($parsedUrl['user']) && !empty($parsedUrl['pass'])) { $this->user = rawurldecode($parsedUrl['user']); $this->password = rawurldecode($parsedUrl['pass']); $url = str_replace(rawurlencode($this->user) . ':' . rawurlencode($this->password) . '@', '', $url); } else { $this->user = $this->rc->user->get_username(); $this->password = $this->rc->decrypt($_SESSION['password']); } $this->url = $url; } /** * Execute HTTP request to a DAV server */ protected function request($path, $method, $body = '', $headers = []) { $rcube = rcube::get_instance(); $debug = (array) $rcube->config->get('dav_debug'); $request_config = [ 'store_body' => true, 'follow_redirects' => true, ]; $this->responseHeaders = []; if ($path && ($rootPath = parse_url($this->url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) { $path = substr($path, strlen($rootPath)); } try { - $request = $this->initRequest($this->url . $path, $method, $request_config); $request->setAuth($this->user, $this->password); if ($body) { $request->setBody($body); $request->setHeader(['Content-Type' => 'application/xml; charset=utf-8']); } if (!empty($headers)) { $request->setHeader($headers); } if ($debug) { rcube::write_log('dav', "C: {$method}: " . (string) $request->getUrl() . "\n" . $this->debugBody($body, $request->getHeaders())); } $response = $request->send(); $body = $response->getBody(); $code = $response->getStatus(); if ($debug) { rcube::write_log('dav', "S: [{$code}]\n" . $this->debugBody($body, $response->getHeader())); } if ($code >= 300) { throw new Exception("DAV Error ($code):\n{$body}"); } $this->responseHeaders = $response->getHeader(); return $this->parseXML($body); } catch (Exception $e) { rcube::raise_error($e, true, false); return false; } } /** * Discover DAV folders of specified type on the server */ public function discover($component = 'VEVENT') { $roots = [ 'VEVENT' => 'calendars', 'VTODO' => 'calendars', 'VCARD' => 'addressbooks', ]; $path = parse_url($this->url, PHP_URL_PATH); $body = '' . '' . '' . '' . '' . ''; - $response = $this->request('/' . $roots[$component], 'PROPFIND', $body); + // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) + $response = $this->request('/' . $roots[$component], 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); $elements = $response->getElementsByTagName('response'); foreach ($elements as $element) { foreach ($element->getElementsByTagName('prop') as $prop) { $principal_href = $prop->nodeValue; break; } } if ($path && strpos($principal_href, $path) === 0) { $principal_href = substr($principal_href, strlen($path)); } + $homes = [ + 'VEVENT' => 'calendar-home-set', + 'VTODO' => 'calendar-home-set', + 'VCARD' => 'addressbook-home-set', + ]; + + $ns = [ + 'VEVENT' => 'caldav', + 'VTODO' => 'caldav', + 'VCARD' => 'carddav', + ]; + $body = '' - . '' + . '' . '' - . '' + . '' . '' . ''; $response = $this->request($principal_href, 'PROPFIND', $body); $elements = $response->getElementsByTagName('response'); foreach ($elements as $element) { foreach ($element->getElementsByTagName('prop') as $prop) { $root_href = $prop->nodeValue; break; } } if (!empty($root_href)) { if ($path && strpos($root_href, $path) === 0) { $root_href = substr($root_href, strlen($path)); } } else { // Kolab iRony's calendar root $root_href = '/' . $roots[$component] . '/' . rawurlencode($this->user); } + if ($component == 'VCARD') { + $add_ns = ''; + $add_props = ''; + } + else { + $add_ns = ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"'; + $add_props = ''; + } + $body = '' - . '' + . '' . '' . '' . '' + // . '' . '' - . '' - . '' + . $add_props . '' . ''; - $response = $this->request($root_href, 'PROPFIND', $body); + // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) + $response = $this->request($root_href, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $folders = []; - foreach ($response->getElementsByTagName('response') as $element) { $folder = $this->getFolderPropertiesFromResponse($element); - if ($folder['type'] === $component) { + + // Note: Addressbooks don't have 'type' specified + if (($component == 'VCARD' && in_array('addressbook', $folder['resource_type'])) + || $folder['type'] === $component + ) { $folders[] = $folder; } } return $folders; } /** * Create a DAV object in a folder */ - public function create($location, $content) + public function create($location, $content, $component = 'VEVENT') { - $response = $this->request($location, 'PUT', $content, ['Content-Type' => 'text/calendar; charset=utf-8']); + $ctype = [ + 'VEVENT' => 'text/calendar', + 'VTODO' => 'text/calendar', + 'VCARD' => 'text/vcard', + ]; + + $headers = ['Content-Type' => $ctype[$component] . '; charset=utf-8']; + + $response = $this->request($location, 'PUT', $content, $headers); if ($response !== false) { $etag = $this->responseHeaders['etag']; if (preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } return $etag; } return false; } /** * Update a DAV object in a folder */ - public function update($location, $content) + public function update($location, $content, $component = 'VEVENT') { - return $this->create($location, $content); + return $this->create($location, $content, $component); } /** * Delete a DAV object from a folder */ public function delete($location) { $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']); return $response !== false; } /** * Fetch DAV objects metadata (ETag, href) a folder */ public function getIndex($location, $component = 'VEVENT') { $queries = [ 'VEVENT' => 'calendar-query', 'VTODO' => 'calendar-query', 'VCARD' => 'addressbook-query', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $filter = ''; if ($component != 'VCARD') { $filter = '' . '' . ''; } $body = '' .' ' . '' . '' . '' . ($filter ? "$filter" : '') . ''; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->getObjectPropertiesFromResponse($element); } return $objects; } /** * Fetch DAV objects data from a folder */ public function getData($location, $component = 'VEVENT', $hrefs = []) { if (empty($hrefs)) { return []; } $body = ''; foreach ($hrefs as $href) { $body .= '' . $href . ''; } $queries = [ 'VEVENT' => 'calendar-multiget', 'VTODO' => 'calendar-multiget', 'VCARD' => 'addressbook-multiget', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $types = [ 'VEVENT' => 'calendar-data', 'VTODO' => 'calendar-data', 'VCARD' => 'address-data', ]; $body = '' .' ' . '' . '' . '' . '' . $body . ''; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->getObjectPropertiesFromResponse($element); } return $objects; } /** * Parse XML content */ protected function parseXML($xml) { $doc = new DOMDocument('1.0', 'UTF-8'); if (stripos($xml, 'loadXML($xml)) { throw new Exception("Failed to parse XML"); } $doc->formatOutput = true; } return $doc; } /** * Parse request/response body for debug purposes */ protected function debugBody($body, $headers) { $head = ''; foreach ($headers as $header_name => $header_value) { $head .= "{$header_name}: {$header_value}\n"; } if (stripos($body, 'formatOutput = true; $doc->preserveWhiteSpace = false; if (!$doc->loadXML($body)) { throw new Exception("Failed to parse XML"); } $body = $doc->saveXML(); } return $head . "\n" . rtrim($body); } /** * Extract folder properties from a server 'response' element */ protected function getFolderPropertiesFromResponse(DOMNode $element) { if ($href = $element->getElementsByTagName('href')->item(0)) { $href = $href->nodeValue; /* $path = parse_url($this->url, PHP_URL_PATH); if ($path && strpos($href, $path) === 0) { $href = substr($href, strlen($path)); } */ } if ($color = $element->getElementsByTagName('calendar-color')->item(0)) { if (preg_match('/^#[0-9A-F]{8}$/', $color->nodeValue)) { $color = substr($color->nodeValue, 1, -2); } else { $color = null; } } if ($name = $element->getElementsByTagName('displayname')->item(0)) { $name = $name->nodeValue; } if ($ctag = $element->getElementsByTagName('getctag')->item(0)) { $ctag = $ctag->nodeValue; } $component = null; if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) { if ($comp_element = $set_element->getElementsByTagName('comp')->item(0)) { $component = $comp_element->attributes->getNamedItem('name')->nodeValue; } } $types = []; if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) { foreach ($type_element->childNodes as $node) { $_type = explode(':', $node->nodeName); $types[] = count($_type) > 1 ? $_type[1] : $_type[0]; } } return [ 'href' => $href, 'name' => $name, 'ctag' => $ctag, 'color' => $color, 'type' => $component, 'resource_type' => $types, ]; } /** * Extract object properties from a server 'response' element */ protected function getObjectPropertiesFromResponse(DOMNode $element) { $uid = null; if ($href = $element->getElementsByTagName('href')->item(0)) { $href = $href->nodeValue; /* $path = parse_url($this->url, PHP_URL_PATH); if ($path && strpos($href, $path) === 0) { $href = substr($href, strlen($path)); } */ // Extract UID from the URL $href_parts = explode('/', $href); $uid = preg_replace('/\.[a-z]+$/', '', $href_parts[count($href_parts)-1]); } if ($data = $element->getElementsByTagName('calendar-data')->item(0)) { $data = $data->nodeValue; } else if ($data = $element->getElementsByTagName('address-data')->item(0)) { $data = $data->nodeValue; } if ($etag = $element->getElementsByTagName('getetag')->item(0)) { $etag = $etag->nodeValue; if (preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } } return [ 'href' => $href, 'data' => $data, 'etag' => $etag, 'uid' => $uid, ]; } /** * Initialize HTTP request object */ protected function initRequest($url = '', $method = 'GET', $config = array()) { $rcube = rcube::get_instance(); $http_config = (array) $rcube->config->get('kolab_http_request'); // deprecated configuration options if (empty($http_config)) { foreach (array('ssl_verify_peer', 'ssl_verify_host') as $option) { $value = $rcube->config->get('kolab_' . $option, true); if (is_bool($value)) { $http_config[$option] = $value; } } } if (!empty($config)) { $http_config = array_merge($http_config, $config); } // load HTTP_Request2 (support both composer-installed and system-installed package) if (!class_exists('HTTP_Request2')) { require_once 'HTTP/Request2.php'; } try { $request = new HTTP_Request2(); $request->setConfig($http_config); // proxy User-Agent string $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); // cleanup $request->setBody(''); $request->setUrl($url); $request->setMethod($method); return $request; } catch (Exception $e) { rcube::raise_error($e, true, true); } } } diff --git a/plugins/libkolab/lib/kolab_storage_dav.php b/plugins/libkolab/lib/kolab_storage_dav.php index ce064178..15295cfb 100644 --- a/plugins/libkolab/lib/kolab_storage_dav.php +++ b/plugins/libkolab/lib/kolab_storage_dav.php @@ -1,488 +1,492 @@ * * Copyright (C) 2022, Apheleia IT AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_dav { const ERROR_DAV_CONN = 1; const ERROR_CACHE_DB = 2; const ERROR_NO_PERMISSION = 3; const ERROR_INVALID_FOLDER = 4; protected $dav; protected $url; /** * Object constructor */ public function __construct($url) { $this->url = $url; $this->setup(); } /** * Setup the environment */ public function setup() { $rcmail = rcube::get_instance(); $this->config = $rcmail->config; $this->dav = new kolab_dav_client($this->url); } /** * Get a list of storage folders for the given data type * * @param string Data type to list folders for (contact,distribution-list,event,task,note) * * @return array List of kolab_storage_dav_folder objects */ public function get_folders($type) { $davTypes = [ 'event' => 'VEVENT', 'task' => 'VTODO', 'contact' => 'VCARD', ]; // TODO: This should be cached $folders = $this->dav->discover($davTypes[$type]); if (is_array($folders)) { foreach ($folders as $idx => $folder) { // Exclude some special folders if (in_array('schedule-inbox', $folder['resource_type']) || in_array('schedule-outbox', $folder['resource_type'])) { unset($folders[$idx]); continue; } $folders[$idx] = new kolab_storage_dav_folder($this->dav, $folder, $type); } } return $folders ?: []; } /** * Getter for the storage folder for the given type * * @param string Data type to list folders for (contact,distribution-list,event,task,note) * * @return object kolab_storage_dav_folder The folder object */ public function get_default_folder($type) { // TODO: Not used } /** * Getter for a specific storage folder * - * @param string Folder to access - * @param string Expected folder type + * @param string $id Folder to access + * @param string $type Expected folder type * - * @return object kolab_storage_folder The folder object + * @return ?object kolab_storage_folder The folder object */ - public function get_folder($folder, $type = null) + public function get_folder($id, $type = null) { - // TODO + foreach ($this->get_folders($type) as $folder) { + if ($folder->id == $id) { + return $folder; + } + } } /** * Getter for a single Kolab object, identified by its UID. * This will search all folders storing objects of the given type. * * @param string Object UID * @param string Object type (contact,event,task,journal,file,note,configuration) * * @return array The Kolab object represented as hash array or false if not found */ public function get_object($uid, $type) { // TODO return false; } /** * Execute cross-folder searches with the given query. * * @param array Pseudo-SQL query as list of filter parameter triplets * @param string Folder type (contact,event,task,journal,file,note,configuration) * @param int Expected number of records or limit (for performance reasons) * * @return array List of Kolab data objects (each represented as hash array) */ public function select($query, $type, $limit = null) { $result = []; foreach ($this->get_folders($type) as $folder) { if ($limit) { $folder->set_order_and_limit(null, $limit); } foreach ($folder->select($query) as $object) { $result[] = $object; } } return $result; } /** * Compose an URL to query the free/busy status for the given user * * @param string Email address of the user to get free/busy data for * @param object DateTime Start of the query range (optional) * @param object DateTime End of the query range (optional) * * @return string Fully qualified URL to query free/busy data */ public static function get_freebusy_url($email, $start = null, $end = null) { return kolab_storage::get_freebusy_url($email, $start, $end); } /** * Deletes a folder * * @param string $name Folder name * * @return bool True on success, false on failure */ public function folder_delete($name) { // TODO } /** * Creates a folder * * @param string $name Folder name (UTF7-IMAP) * @param string $type Folder type * @param bool $subscribed Sets folder subscription * @param bool $active Sets folder state (client-side subscription) * * @return bool True on success, false on failure */ public function folder_create($name, $type = null, $subscribed = false, $active = false) { // TODO } /** * Renames DAV folder * * @param string $oldname Old folder name (UTF7-IMAP) * @param string $newname New folder name (UTF7-IMAP) * * @return bool True on success, false on failure */ public function folder_rename($oldname, $newname) { // TODO } /** * Rename or Create a new folder. * * Does additional checks for permissions and folder name restrictions * * @param array &$prop Hash array with folder properties and metadata * - name: Folder name * - oldname: Old folder name when changed * - parent: Parent folder to create the new one in * - type: Folder type to create * - subscribed: Subscribed flag (IMAP subscription) * - active: Activation flag (client-side subscription) * * @return string|false New folder name or False on failure */ public function folder_update(&$prop) { // TODO } /** * Getter for human-readable name of a folder * * @param string $folder Folder name (UTF7-IMAP) * @param string $folder_ns Will be set to namespace name of the folder * * @return string Name of the folder-object */ public static function object_name($folder, &$folder_ns = null) { // TODO: Shared folders $folder_ns = 'personal'; return $folder; } /** * Creates a SELECT field with folders list * * @param string $type Folder type * @param array $attrs SELECT field attributes (e.g. name) * @param string $current The name of current folder (to skip it) * * @return html_select SELECT object */ public function folder_selector($type, $attrs, $current = '') { // TODO } /** * Returns a list of folder names * * @param string Optional root folder * @param string Optional name pattern * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param bool Enable to return subscribed folders only (null to use configured subscription mode) * @param array Will be filled with folder-types data * * @return array List of folders */ public function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = array()) { // TODO } /** * Search for shared or otherwise not listed groupware folders the user has access * * @param string Folder type of folders to search for * @param string Search string * @param array Namespace(s) to exclude results from * * @return array List of matching kolab_storage_folder objects */ public function search_folders($type, $query, $exclude_ns = []) { // TODO return []; } /** * Sort the given list of folders by namespace/name * * @param array List of kolab_storage_dav_folder objects * * @return array Sorted list of folders */ public static function sort_folders($folders) { // TODO return $folders; } /** * Returns folder types indexed by folder name * * @param string $prefix Folder prefix (Default '*' for all folders) * * @return array|bool List of folders, False on failure */ public function folders_typedata($prefix = '*') { // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation return []; } /** * Returns type of a DAV folder * * @param string $folder Folder name (UTF7-IMAP) * * @return string Folder type */ public function folder_type($folder) { // TODO: Used by kolab_folders, kolab_activesync, kolab_delegation return 'event'; } /** * Sets folder content-type. * * @param string $folder Folder name * @param string $type Content type * * @return bool True on success, False otherwise */ public function set_folder_type($folder, $type = 'mail') { // NOP: Used by kolab_folders, kolab_activesync, kolab_delegation return false; } /** * Check subscription status of this folder * * @param string $folder Folder name * @param bool $temp Include temporary/session subscriptions * * @return bool True if subscribed, false if not */ public function folder_is_subscribed($folder, $temp = false) { // NOP return true; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param bool $temp Only subscribe temporarily for the current session * * @return True on success, false on error */ public function folder_subscribe($folder, $temp = false) { // NOP return true; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param bool $temp Only remove temporary subscription * * @return True on success, false on error */ public function folder_unsubscribe($folder, $temp = false) { // NOP return false; } /** * Check activation status of this folder * * @param string $folder Folder name * * @return bool True if active, false if not */ public function folder_is_active($folder) { // TODO return true; } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public function folder_activate($folder) { return true; } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public function folder_deactivate($folder) { return false; } /** * Creates default folder of specified type * To be run when none of subscribed folders (of specified type) is found * * @param string $type Folder type * @param string $props Folder properties (color, etc) * * @return string Folder name */ public function create_default_folder($type, $props = []) { // TODO: For kolab_addressbook?? return ''; } /** * Returns a list of IMAP folders shared by the given user * * @param array User entry from LDAP * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param int 1 - subscribed folders only, 0 - all folders, 2 - all non-active * @param array Will be filled with folder-types data * * @return array List of folders */ public function list_user_folders($user, $type, $subscribed = 0, &$folderdata = []) { // TODO return []; } /** * Get a list of (virtual) top-level folders from the other users namespace * * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param bool Enable to return subscribed folders only (null to use configured subscription mode) * * @return array List of kolab_storage_folder_user objects */ public function get_user_folders($type, $subscribed) { // TODO return []; } /** * Handler for user_delete plugin hooks * * Remove all cache data from the local database related to the given user. */ public static function delete_user_folders($args) { $db = rcmail::get_instance()->get_dbh(); $table = $db->table_name('kolab_folders', true); $prefix = 'dav://' . urlencode($args['username']) . '@' . $args['host'] . '/%'; $db->query("DELETE FROM $table WHERE `resource` LIKE ?", $prefix); } /** * Get folder METADATA for all supported keys * Do this in one go for better caching performance */ public function folder_metadata($folder) { // TODO ? return []; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache.php b/plugins/libkolab/lib/kolab_storage_dav_cache.php index 837783b5..b089b170 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php @@ -1,625 +1,632 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2013, Kolab Systems AG * Copyright (C) 2022, Apheleia IT AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_dav_cache extends kolab_storage_cache { /** * Factory constructor */ public static function factory(kolab_storage_folder $storage_folder) { $subclass = 'kolab_storage_dav_cache_' . $storage_folder->type; if (class_exists($subclass)) { return new $subclass($storage_folder); } rcube::raise_error( ['code' => 900, 'message' => "No {$subclass} class found for folder '{$storage_folder->name}'"], true ); return new kolab_storage_dav_cache($storage_folder); } /** * Connect cache with a storage folder * * @param kolab_storage_folder The storage folder instance to connect with */ public function set_folder(kolab_storage_folder $storage_folder) { $this->folder = $storage_folder; if (!$this->folder->valid) { $this->ready = false; return; } // compose fully qualified ressource uri for this instance $this->resource_uri = $this->folder->get_resource_uri(); $this->cache_table = $this->db->table_name('kolab_cache_dav_' . $this->folder->type); $this->ready = true; } /** * Synchronize local cache data with remote */ public function synchronize() { // only sync once per request cycle if ($this->synched) { return; } $this->sync_start = time(); // read cached folder metadata $this->_read_folder_data(); $ctag = $this->folder->get_ctag(); // check cache status ($this->metadata is set in _read_folder_data()) if ( empty($this->metadata['ctag']) || empty($this->metadata['changed']) || $this->metadata['ctag'] !== $ctag ) { // lock synchronization for this folder and wait if already locked $this->_sync_lock(); $result = $this->synchronize_worker(); // update ctag value (will be written to database in _sync_unlock()) if ($result) { $this->metadata['ctag'] = $ctag; $this->metadata['changed'] = date(self::DB_DATE_FORMAT, time()); } // remove lock $this->_sync_unlock(); } $this->synched = time(); } /** * Perform cache synchronization */ protected function synchronize_worker() { // get effective time limit we have for synchronization (~70% of the execution time) $time_limit = $this->_max_sync_lock_time() * 0.7; if (time() - $this->sync_start > $time_limit) { return false; } // TODO: Implement synchronization with use of WebDAV-Sync (RFC 6578) // Get the objects from the DAV server $dav_index = $this->folder->dav->getIndex($this->folder->href, $this->folder->get_dav_type()); if (!is_array($dav_index)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to sync the kolab cache for {$this->folder->href}" ], true); return false; } // WARNING: For now we assume object's href is /.ics, // which would mean there are no duplicates (objects with the same uid). // With DAV protocol we can't get UID without fetching the whole object. // Also the folder_id + uid is a unique index in the database. // In the future we maybe should store the href in database. // Determine objects to fetch or delete $new_index = []; $update_index = []; $old_index = $this->folder_index(); // uid -> etag $chunk_size = 20; // max numer of objects per DAV request foreach ($dav_index as $object) { $uid = $object['uid']; if (isset($old_index[$uid])) { $old_etag = $old_index[$uid]; $old_index[$uid] = null; if ($old_etag === $object['etag']) { // the object didn't change continue; } $update_index[$uid] = $object['href']; } else { $new_index[$uid] = $object['href']; } } // Fetch new objects and store in DB if (!empty($new_index)) { foreach (array_chunk($new_index, $chunk_size, true) as $chunk) { $objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to sync the kolab cache for {$this->folder->href}" ], true); return false; } foreach ($objects as $object) { if ($object = $this->folder->from_dav($object)) { $this->_extended_insert(false, $object); } } $this->_extended_insert(true, null); // check time limit and abort sync if running too long if (++$i % 25 == 0 && time() - $this->sync_start > $time_limit) { return false; } } } // Fetch updated objects and store in DB if (!empty($update_index)) { foreach (array_chunk($update_index, $chunk_size, true) as $chunk) { $objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to sync the kolab cache for {$this->folder->href}" ], true); return false; } foreach ($objects as $object) { if ($object = $this->folder->from_dav($object)) { $this->save($object, $object['uid']); } } // check time limit and abort sync if running too long if (++$i % 25 == 0 && time() - $this->sync_start > $time_limit) { return false; } } } // Remove deleted objects $old_index = array_filter($old_index); if (!empty($old_index)) { $quoted_uids = join(',', array_map(array($this->db, 'quote'), $old_index)); $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` IN ($quoted_uids)", $this->folder_id ); } return true; } /** * Return current folder index (uid -> etag) */ protected function folder_index() { // read cache index $sql_result = $this->db->query( "SELECT `uid`, `etag` FROM `{$this->cache_table}` WHERE `folder_id` = ?", $this->folder_id ); $index = []; while ($sql_arr = $this->db->fetch_assoc($sql_result)) { $index[$sql_arr['uid']] = $sql_arr['etag']; } return $index; } /** * Read a single entry from cache or from server directly * * @param string Object UID * @param string Object type to read * @param string Unused (kept for compat. with the parent class) */ public function get($uid, $type = null, $unused = null) { if ($this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT * FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` = ?", $this->folder_id, $uid ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $object = $this->_unserialize($sql_arr); } } // fetch from DAV if not present in cache if (empty($object)) { if ($object = $this->folder->read_object($uid, $type ?: '*')) { $this->save($object); } } return $object ?: null; } /** * Insert/Update a cache entry * * @param string Object UID * @param array|false Hash array with object properties to save or false to delete the cache entry * @param string Unused (kept for compat. with the parent class) */ public function set($uid, $object, $unused = null) { // remove old entry if ($this->ready) { $this->_read_folder_data(); $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` = ?", $this->folder_id, $uid ); } if ($object) { $this->save($object); } } /** * Insert (or update) a cache entry * * @param mixed Hash array with object properties to save or false to delete the cache entry * @param string Optional old message UID (for update) * @param string Unused (kept for compat. with the parent class) */ public function save($object, $olduid = null, $unused = null) { // write to cache if ($this->ready) { $this->_read_folder_data(); $sql_data = $this->_serialize($object); $sql_data['folder_id'] = $this->folder_id; - $sql_data['uid'] = $object['uid']; - $sql_data['etag'] = $object['etag']; + $sql_data['uid'] = rcube_charset::clean($object['uid']); + $sql_data['etag'] = rcube_charset::clean($object['etag']); $args = []; $cols = ['folder_id', 'uid', 'etag', 'changed', 'data', 'tags', 'words']; $cols = array_merge($cols, $this->extra_cols); foreach ($cols as $idx => $col) { $cols[$idx] = $this->db->quote_identifier($col); $args[] = $sql_data[$col]; } if ($olduid) { foreach ($cols as $idx => $col) { $cols[$idx] = "$col = ?"; } $query = "UPDATE `{$this->cache_table}` SET " . implode(', ', $cols) . " WHERE `folder_id` = ? AND `uid` = ?"; $args[] = $this->folder_id; $args[] = $olduid; } else { $query = "INSERT INTO `{$this->cache_table}` (`created`, " . implode(', ', $cols) . ") VALUES (" . $this->db->now() . str_repeat(', ?', count($cols)) . ")"; } $result = $this->db->query($query, $args); if (!$this->db->affected_rows($result)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to write to kolab cache" ], true); } } } /** * Move an existing cache entry to a new resource * * @param string Entry's UID * @param kolab_storage_folder Target storage folder instance * @param string Unused (kept for compat. with the parent class) * @param string Unused (kept for compat. with the parent class) */ public function move($uid, $target, $unused1 = null, $unused2 = null) { // TODO } /** * Update resource URI for existing folder * * @param string Target DAV folder to move it to */ public function rename($new_folder) { // TODO } /** * Select Kolab objects filtered by the given query * * @param array Pseudo-SQL query as list of filter parameter triplets * triplet: ['', '', ''] * @param bool Set true to only return UIDs instead of complete objects * @param bool Use fast mode to fetch only minimal set of information * (no xml fetching and parsing, etc.) * * @return array|null|kolab_storage_dataset List of Kolab data objects (each represented as hash array) or UIDs */ public function select($query = [], $uids = false, $fast = false) { $result = $uids ? [] : new kolab_storage_dataset($this); $this->_read_folder_data(); // fetch full object data on one query if a small result set is expected $fetchall = !$uids && ($this->limit ? $this->limit[0] : ($count = $this->count($query))) < self::MAX_RECORDS; // skip SELECT if we know it will return nothing if ($count === 0) { return $result; } $sql_query = "SELECT " . ($fetchall ? '*' : "`uid`") . " FROM `{$this->cache_table}` WHERE `folder_id` = ?" . $this->_sql_where($query) . (!empty($this->order_by) ? " ORDER BY " . $this->order_by : ''); $sql_result = $this->limit ? $this->db->limitquery($sql_query, $this->limit[1], $this->limit[0], $this->folder_id) : $this->db->query($sql_query, $this->folder_id); if ($this->db->is_error($sql_result)) { if ($uids) { return null; } $result->set_error(true); return $result; } while ($sql_arr = $this->db->fetch_assoc($sql_result)) { if ($fast) { $sql_arr['fast-mode'] = true; } if ($uids) { $result[] = $sql_arr['uid']; } else if ($fetchall && ($object = $this->_unserialize($sql_arr))) { $result[] = $object; } else if (!$fetchall) { $result[] = $sql_arr; } } return $result; } /** * Get number of objects mathing the given query * * @param array $query Pseudo-SQL query as list of filter parameter triplets * * @return int The number of objects of the given type */ public function count($query = []) { // read from local cache DB (assume it to be synchronized) $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` ". "WHERE `folder_id` = ?" . $this->_sql_where($query), $this->folder_id ); if ($this->db->is_error($sql_result)) { return null; } $sql_arr = $this->db->fetch_assoc($sql_result); $count = intval($sql_arr['numrows']); return $count; } /** * Getter for a single Kolab object identified by its UID * * @param string $uid Object UID * * @return array|null The Kolab object represented as hash array */ public function get_by_uid($uid) { $old_limit = $this->limit; // set limit to skip count query $this->limit = [1, 0]; $list = $this->select([['uid', '=', $uid]]); // set the limit back to defined value $this->limit = $old_limit; if (!empty($list) && !empty($list[0])) { return $list[0]; } } /** * Check DAV connection error state */ protected function check_error() { // TODO ? } /** * Write records into cache using extended inserts to reduce the number of queries to be executed * * @param bool Set to false to commit buffered insert, true to force an insert * @param array Kolab object to cache */ protected function _extended_insert($force, $object) { static $buffer = ''; $line = ''; $cols = ['folder_id', 'uid', 'etag', 'created', 'changed', 'data', 'tags', 'words']; if ($this->extra_cols) { $cols = array_merge($cols, $this->extra_cols); } if ($object) { $sql_data = $this->_serialize($object); // Skip multi-folder insert for all databases but MySQL // In Oracle we can't put long data inline, others we don't support yet if (strpos($this->db->db_provider, 'mysql') !== 0) { $extra_args = []; - $params = [$this->folder_id, $object['uid'], $object['etag'], $sql_data['changed'], - $sql_data['data'], $sql_data['tags'], $sql_data['words']]; + $params = [ + $this->folder_id, + rcube_charset::clean($object['uid']), + rcube_charset::clean($object['etag']), + $sql_data['changed'], + $sql_data['data'], + $sql_data['tags'], + $sql_data['words'] + ]; foreach ($this->extra_cols as $col) { $params[] = $sql_data[$col]; $extra_args[] = '?'; } $cols = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols)); $extra_args = count($extra_args) ? ', ' . implode(', ', $extra_args) : ''; $result = $this->db->query( "INSERT INTO `{$this->cache_table}` ($cols)" . " VALUES (?, ?, " . $this->db->now() . ", ?, ?, ?, ?$extra_args)", $params ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to write to kolab cache" ), true); } return; } $values = array( $this->db->quote($this->folder_id), - $this->db->quote($object['uid']), - $this->db->quote($object['etag']), + $this->db->quote(rcube_charset::clean($object['uid'])), + $this->db->quote(rcube_charset::clean($object['etag'])), $this->db->now(), $this->db->quote($sql_data['changed']), $this->db->quote($sql_data['data']), $this->db->quote($sql_data['tags']), $this->db->quote($sql_data['words']), ); foreach ($this->extra_cols as $col) { $values[] = $this->db->quote($sql_data[$col]); } $line = '(' . join(',', $values) . ')'; } if ($buffer && ($force || (strlen($buffer) + strlen($line) > $this->max_sql_packet()))) { $columns = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols)); $update = implode(', ', array_map(function($i) { return "`{$i}` = VALUES(`{$i}`)"; }, array_slice($cols, 2))); $result = $this->db->query( "INSERT INTO `{$this->cache_table}` ($columns) VALUES $buffer" . " ON DUPLICATE KEY UPDATE $update" ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to write to kolab cache" ), true); } $buffer = ''; } $buffer .= ($buffer ? ',' : '') . $line; } /** * Helper method to turn stored cache data into a valid storage object */ protected function _unserialize($sql_arr) { if ($sql_arr['fast-mode'] && !empty($sql_arr['data']) && ($object = json_decode($sql_arr['data'], true))) { foreach ($this->data_props as $prop) { if (isset($object[$prop]) && is_array($object[$prop]) && $object[$prop]['cl'] == 'DateTime') { $object[$prop] = new DateTime($object[$prop]['dt'], new DateTimeZone($object[$prop]['tz'])); } else if (!isset($object[$prop]) && isset($sql_arr[$prop])) { $object[$prop] = $sql_arr[$prop]; } } if ($sql_arr['created'] && empty($object['created'])) { $object['created'] = new DateTime($sql_arr['created']); } if ($sql_arr['changed'] && empty($object['changed'])) { $object['changed'] = new DateTime($sql_arr['changed']); } $object['_type'] = $sql_arr['type'] ?: $this->folder->type; $object['uid'] = $sql_arr['uid']; $object['etag'] = $sql_arr['etag']; } // Fetch a complete object from the server else { // TODO: Fetching objects one-by-one from DAV server is slow $object = $this->folder->read_object($sql_arr['uid'], '*'); } return $object; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php b/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php index 66d2b830..0ca118dd 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache_contact.php @@ -1,116 +1,113 @@ * * Copyright (C) 2013-2022, Apheleia IT AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -class kolab_storage_cache_contact extends kolab_storage_cache +class kolab_storage_dav_cache_contact extends kolab_storage_dav_cache { protected $extra_cols_max = 255; protected $extra_cols = ['type', 'name', 'firstname', 'surname', 'email']; protected $data_props = ['type', 'name', 'firstname', 'middlename', 'prefix', 'suffix', 'surname', 'email', 'organization', 'member']; - protected $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email:address']; + protected $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email']; /** * Helper method to convert the given Kolab object into a dataset to be written to cache * * @override */ protected function _serialize($object) { $sql_data = parent::_serialize($object); - $sql_data['type'] = $object['_type']; + $sql_data['type'] = $object['_type'] ?: 'contact'; // columns for sorting $sql_data['name'] = rcube_charset::clean($object['name'] . $object['prefix']); $sql_data['firstname'] = rcube_charset::clean($object['firstname'] . $object['middlename'] . $object['surname']); $sql_data['surname'] = rcube_charset::clean($object['surname'] . $object['firstname'] . $object['middlename']); - $sql_data['email'] = rcube_charset::clean(is_array($object['email']) ? $object['email'][0] : $object['email']); + $sql_data['email'] = ''; - if (is_array($sql_data['email'])) { - $sql_data['email'] = $sql_data['email']['address']; - } - // avoid value being null - if (empty($sql_data['email'])) { - $sql_data['email'] = ''; + foreach ($object as $colname => $value) { + list($col, $field) = explode(':', $colname); + if ($col == 'email' && !empty($value)) { + $sql_data['email'] = is_array($value) ? $value[0] : $value; + break; + } } // use organization if name is empty if (empty($sql_data['name']) && !empty($object['organization'])) { $sql_data['name'] = rcube_charset::clean($object['organization']); } // make sure some data is not longer that database limit (#5291) foreach ($this->extra_cols as $col) { if (strlen($sql_data[$col]) > $this->extra_cols_max) { $sql_data[$col] = rcube_charset::clean(substr($sql_data[$col], 0, $this->extra_cols_max)); } } $sql_data['tags'] = ' ' . join(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search $sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' '; return $sql_data; } /** * Callback to get words to index for fulltext search * * @return array List of words to save in cache */ public function get_words($object) { $data = ''; - foreach ($this->fulltext_cols as $colname) { + + foreach ($object as $colname => $value) { list($col, $field) = explode(':', $colname); - if ($field) { - $a = []; - foreach ((array)$object[$col] as $attr) - $a[] = $attr[$field]; - $val = join(' ', $a); - } - else { - $val = is_array($object[$col]) ? join(' ', $object[$col]) : $object[$col]; + $val = ''; + if (in_array($col, $this->fulltext_cols)) { + $val = is_array($value) ? join(' ', $value) : $value; } - if (strlen($val)) + if (strlen($val)) { $data .= $val . ' '; + } } return array_unique(rcube_utils::normalize_string($data, true)); } /** * Callback to get object specific tags to cache * * @return array List of tags to save in cache */ public function get_tags($object) { $tags = []; if (!empty($object['birthday'])) { $tags[] = 'x-has-birthday'; } return $tags; } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php index 32ecdd5d..c7a46f47 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -1,546 +1,587 @@ * * Copyright (C) 2014-2022, Apheleia IT AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_dav_folder extends kolab_storage_folder { public $dav; public $href; public $attributes; /** * Object constructor */ public function __construct($dav, $attributes, $type_annotation = '') { $this->attributes = $attributes; - $this->href = $this->attributes['href']; - // Here we assume the last element of the folder path is the folder ID - // if that's not the case, we should consider generating an ID - $href = explode('/', unslashify($this->href)); - $this->id = $href[count($href) - 1]; + $this->href = $this->attributes['href']; + $this->id = md5($this->href); $this->dav = $dav; $this->valid = true; list($this->type, $suffix) = explode('.', $type_annotation); $this->default = $suffix == 'default'; $this->subtype = $this->default ? '' : $suffix; // Init cache $this->cache = kolab_storage_dav_cache::factory($this); } /** * Returns the owner of the folder. * * @param bool Return a fully qualified owner name (i.e. including domain for shared folders) * * @return string The owner of this folder. */ public function get_owner($fully_qualified = false) { // return cached value if (isset($this->owner)) { return $this->owner; } $rcube = rcube::get_instance(); $this->owner = $rcube->get_user_name(); $this->valid = true; // TODO: Support shared folders return $this->owner; } /** * Get a folder Etag identifier */ public function get_ctag() { return $this->attributes['ctag']; } /** * Getter for the name of the namespace to which the folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { // TODO: Support shared folders return 'personal'; } /** * Get the display name value of this folder * * @return string Folder name */ public function get_name() { return kolab_storage_dav::object_name($this->attributes['name']); } /** * Getter for the top-end folder name (not the entire path) * * @return string Name of this folder */ public function get_foldername() { return $this->attributes['name']; } public function get_folder_info() { return []; // todo ? } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { // TODO return ''; } /** * Compose a unique resource URI for this folder */ public function get_resource_uri() { if (!empty($this->resource_uri)) { return $this->resource_uri; } // compose fully qualified ressource uri for this instance $host = preg_replace('|^https?://|', 'dav://' . urlencode($this->get_owner(true)) . '@', $this->dav->url); $path = $this->href[0] == '/' ? $this->href : "/{$this->href}"; $host_path = parse_url($host, PHP_URL_PATH); if ($host_path && strpos($path, $host_path) === 0) { $path = substr($path, strlen($host_path)); } $this->resource_uri = unslashify($host) . $path; return $this->resource_uri; } /** * Getter for the Cyrus mailbox identifier corresponding to this folder * (e.g. user/john.doe/Calendar/Personal@example.org) * * @return string Mailbox ID */ public function get_mailbox_id() { // TODO: This is used with Bonnie related features return ''; } /** * Get the color value stored in metadata * * @param string Default color value to return if not set * * @return mixed Color value from the folder metadata or $default if not set */ public function get_color($default = null) { return !empty($this->attributes['color']) ? $this->attributes['color'] : $default; } /** * Get ACL information for this folder * * @return string Permissions as string */ public function get_myrights() { // TODO return ''; } /** * Helper method to extract folder UID * * @return string Folder's UID */ public function get_uid() { // TODO ??? return ''; } /** * Check activation status of this folder * * @return bool True if enabled, false if not */ public function is_active() { // TODO return true; } /** * Change activation status of this folder * * @param bool The desired subscription status: true = active, false = not active * * @return bool True on success, false on error */ public function activate($active) { // TODO return true; } /** * Check subscription status of this folder * * @return bool True if subscribed, false if not */ public function is_subscribed() { // TODO return true; } /** * Change subscription status of this folder * * @param bool The desired subscription status: true = subscribed, false = not subscribed * * @return True on success, false on error */ public function subscribe($subscribed) { // TODO return true; } /** * Delete the specified object from this folder. * * @param array|string $object The Kolab object to delete or object UID * @param bool $expunge Should the folder be expunged? * * @return bool True if successful, false on error */ public function delete($object, $expunge = true) { if (!$this->valid) { return false; } $uid = is_array($object) ? $object['uid'] : $object; $success = $this->dav->delete($this->object_location($uid), $content); if ($success) { $this->cache->set($uid, false); } return $success; } /** * */ public function delete_all() { if (!$this->valid) { return false; } // TODO: This method is used by kolab_addressbook plugin only $this->cache->purge(); return false; } /** * Restore a previously deleted object * * @param string $uid Object UID * * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } // TODO return false; } /** * Move a Kolab object message to another IMAP folder * * @param string Object UID * @param string IMAP folder to move object to * * @return bool True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } // TODO return false; } /** * Save an object in this folder. * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param string $uid The UID of the old object if it existed before * * @return mixed False on error or object UID on success */ public function save(&$object, $type = null, $uid = null) { if (!$this->valid || empty($object)) { return false; } if (!$type) { $type = $this->type; } /* // copy attachments from old message $copyfrom = $object['_copyfrom'] ?: $object['_msguid']; if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) { foreach ((array)$old['_attachments'] as $key => $att) { if (!isset($object['_attachments'][$key])) { $object['_attachments'][$key] = $old['_attachments'][$key]; } // unset deleted attachment entries if ($object['_attachments'][$key] == false) { unset($object['_attachments'][$key]); } // load photo.attachment from old Kolab2 format to be directly embedded in xcard block else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) { if (!isset($object['photo'])) $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']); unset($object['_attachments'][$key]); } } } // process attachments if (is_array($object['_attachments'])) { $numatt = count($object['_attachments']); foreach ($object['_attachments'] as $key => $attachment) { // FIXME: kolab_storage and Roundcube attachment hooks use different fields! if (empty($attachment['content']) && !empty($attachment['data'])) { $attachment['content'] = $attachment['data']; unset($attachment['data'], $object['_attachments'][$key]['data']); } // make sure size is set, so object saved in cache contains this info if (!isset($attachment['size'])) { if (!empty($attachment['content'])) { if (is_resource($attachment['content'])) { // this need to be a seekable resource, otherwise // fstat() failes and we're unable to determine size // here nor in rcube_imap_generic before IMAP APPEND $stat = fstat($attachment['content']); $attachment['size'] = $stat ? $stat['size'] : 0; } else { $attachment['size'] = strlen($attachment['content']); } } else if (!empty($attachment['path'])) { $attachment['size'] = filesize($attachment['path']); } $object['_attachments'][$key] = $attachment; } // generate unique keys (used as content-id) for attachments if (is_numeric($key) && $key < $numatt) { // derrive content-id from attachment file name $ext = preg_match('/(\.[a-z0-9]{1,6})$/i', $attachment['name'], $m) ? $m[1] : null; $basename = preg_replace('/[^a-z0-9_.-]/i', '', basename($attachment['name'], $ext)); // to 7bit ascii if (!$basename) $basename = 'noname'; $cid = $basename . '.' . microtime(true) . $key . $ext; $object['_attachments'][$cid] = $attachment; unset($object['_attachments'][$key]); } } } */ $rcmail = rcube::get_instance(); $result = false; // generate and save object message if ($content = $this->to_dav($object)) { - $method = $uid ? 'update' : 'create'; - $result = $this->dav->{$method}($this->object_location($object['uid']), $content); + $method = $uid ? 'update' : 'create'; + $dav_type = $this->get_dav_type(); + $result = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type); // Note: $result can be NULL if the request was successful, but ETag wasn't returned if ($result !== false) { // insert/update object in the cache $object['etag'] = $result; $this->cache->save($object, $uid); $result = true; } } return $result; } /** * Fetch the object the DAV server and convert to internal format * * @param string The object UID to fetch * @param string The object type expected (use wildcard '*' to accept all types) * @param string Unused (kept for compat. with the parent class) * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($uid, $type = null, $folder = null) { if (!$this->valid) { return false; } $href = $this->object_location($uid); $objects = $this->dav->getData($this->href, $this->get_dav_type(), [$href]); if (!is_array($objects) || count($objects) != 1) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch {$href}" ], true); return false; } return $this->from_dav($objects[0]); } /** * Convert DAV object into PHP array * * @param array Object data in kolab_dav_client::fetchData() format * * @return array Object properties */ public function from_dav($object) { if ($this->type == 'event') { $ical = libcalendaring::get_ical(); $events = $ical->import($object['data']); if (!count($events) || empty($events[0]['uid'])) { return false; } $result = $events[0]; } + else if ($this->type == 'contact') { + if (stripos($object['data'], 'BEGIN:VCARD') !== 0) { + return false; + } + + $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false); + + if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) { + $result = $vcard->get_assoc(); + } + else { + return false; + } + } $result['etag'] = $object['etag']; $result['href'] = $object['href']; - $result['uid'] = $object['uid'] ?: $result['uid']; + $result['uid'] = $object['uid'] ?: $result['uid']; return $result; } /** * Convert Kolab object into DAV format (iCalendar) */ public function to_dav($object) { $result = ''; if ($this->type == 'event') { $ical = libcalendaring::get_ical(); if (!empty($object['exceptions'])) { $object['recurrence']['EXCEPTIONS'] = $object['exceptions']; } $result = $ical->export([$object]); } + else if ($this->type == 'contact') { + // copy values into vcard object + $vcard = new rcube_vcard('', RCUBE_CHARSET, false, ['uid' => 'UID']); + + $vcard->set('groups', null); + + foreach ($object as $key => $values) { + list($field, $section) = rcube_utils::explode(':', $key); + + // avoid casting DateTime objects to array + if (is_object($values) && is_a($values, 'DateTime')) { + $values = [$values]; + } + + foreach ((array) $values as $value) { + if (isset($value)) { + $vcard->set($field, $value, $section); + } + } + } + + $result = $vcard->export(false); + } + + if ($result) { + // The content must be UTF-8, otherwise if we try to fetch the object + // from server XML parsing would fail. + $result = rcube_charset::clean($result); + } return $result; } protected function object_location($uid) { return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext(); } /** * Get a folder DAV content type */ public function get_dav_type() { $types = [ 'event' => 'VEVENT', 'task' => 'VTODO', 'contact' => 'VCARD', ]; return $types[$this->type]; } /** * Get a DAV file extension for specified Kolab type */ public function get_dav_ext() { $types = [ 'event' => 'ics', 'task' => 'ics', 'contact' => 'vcf', ]; return $types[$this->type]; } /** * Return folder name as string representation of this object * * @return string Full IMAP folder name */ public function __toString() { return $this->attributes['name']; } }