diff --git a/lib/drivers/kolab/plugins/kolab_auth/composer.json b/lib/drivers/kolab/plugins/kolab_auth/composer.json index 3e7012f..5fec104 100644 --- a/lib/drivers/kolab/plugins/kolab_auth/composer.json +++ b/lib/drivers/kolab/plugins/kolab_auth/composer.json @@ -1,30 +1,30 @@ { "name": "kolab/kolab_auth", "type": "roundcube-plugin", "description": "Kolab authentication", - "homepage": "http://git.kolab.org/roundcubemail-plugins-kolab/", + "homepage": "https://git.kolab.org/diffusion/RPK/", "license": "AGPLv3", - "version": "3.2.2", + "version": "3.2.8", "authors": [ { "name": "Thomas Bruederli", "email": "bruederli@kolabsys.com", "role": "Lead" }, { "name": "Aleksander Machniak", "email": "machniak@kolabsys.com", "role": "Lead" } ], "repositories": [ { "type": "composer", "url": "http://plugins.roundcube.net" } ], "require": { "php": ">=5.3.0", "roundcube/plugin-installer": ">=0.1.3" } } diff --git a/lib/drivers/kolab/plugins/kolab_auth/kolab_auth.php b/lib/drivers/kolab/plugins/kolab_auth/kolab_auth.php index 033d5b1..926c506 100644 --- a/lib/drivers/kolab/plugins/kolab_auth/kolab_auth.php +++ b/lib/drivers/kolab/plugins/kolab_auth/kolab_auth.php @@ -1,788 +1,788 @@ * * Copyright (C) 2011-2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_auth extends rcube_plugin { static $ldap; private $username; private $data = array(); public function init() { $rcmail = rcube::get_instance(); $this->load_config(); $this->add_hook('authenticate', array($this, 'authenticate')); $this->add_hook('startup', array($this, 'startup')); $this->add_hook('user_create', array($this, 'user_create')); // Hook for password change $this->add_hook('password_ldap_bind', array($this, 'password_ldap_bind')); // Hooks related to "Login As" feature $this->add_hook('template_object_loginform', array($this, 'login_form')); $this->add_hook('storage_connect', array($this, 'imap_connect')); $this->add_hook('managesieve_connect', array($this, 'imap_connect')); $this->add_hook('smtp_connect', array($this, 'smtp_connect')); $this->add_hook('identity_form', array($this, 'identity_form')); // Hook to modify some configuration, e.g. ldap $this->add_hook('config_get', array($this, 'config_get')); // Hook to modify logging directory $this->add_hook('write_log', array($this, 'write_log')); $this->username = $_SESSION['username']; // Enable debug logs (per-user), when logged as another user if (!empty($_SESSION['kolab_auth_admin']) && $rcmail->config->get('kolab_auth_auditlog')) { $rcmail->config->set('debug_level', 1); $rcmail->config->set('devel_mode', true); $rcmail->config->set('smtp_log', true); $rcmail->config->set('log_logins', true); $rcmail->config->set('log_session', true); $rcmail->config->set('memcache_debug', true); $rcmail->config->set('imap_debug', true); $rcmail->config->set('ldap_debug', true); $rcmail->config->set('smtp_debug', true); $rcmail->config->set('sql_debug', true); // SQL debug need to be set directly on DB object // setting config variable will not work here because // the object is already initialized/configured if ($db = $rcmail->get_dbh()) { $db->set_debug(true); } } } /** * Startup hook handler */ public function startup($args) { // Check access rights when logged in as another user if (!empty($_SESSION['kolab_auth_admin']) && $args['task'] != 'login' && $args['task'] != 'logout') { // access to specified task is forbidden, // redirect to the first task on the list if (!empty($_SESSION['kolab_auth_allowed_tasks'])) { $tasks = (array)$_SESSION['kolab_auth_allowed_tasks']; if (!in_array($args['task'], $tasks) && !in_array('*', $tasks)) { header('Location: ?_task=' . array_shift($tasks)); die; } // add script that will remove disabled taskbar buttons if (!in_array('*', $tasks)) { $this->add_hook('render_page', array($this, 'render_page')); } } } // load per-user settings $this->load_user_role_plugins_and_settings(); return $args; } /** * Modify some configuration according to LDAP user record */ public function config_get($args) { // Replaces ldap_vars (%dc, etc) in public kolab ldap addressbooks // config based on the users base_dn. (for multi domain support) if ($args['name'] == 'ldap_public' && !empty($args['result'])) { $rcmail = rcube::get_instance(); $kolab_books = (array) $rcmail->config->get('kolab_auth_ldap_addressbooks'); foreach ($args['result'] as $name => $config) { if (in_array($name, $kolab_books) || in_array('*', $kolab_books)) { $args['result'][$name] = $this->patch_ldap_config($config); } } } else if ($args['name'] == 'kolab_users_directory' && !empty($args['result'])) { $args['result'] = $this->patch_ldap_config($args['result']); } return $args; } /** * Helper method to patch the given LDAP directory config with user-specific values */ protected function patch_ldap_config($config) { if (is_array($config)) { $config['base_dn'] = self::parse_ldap_vars($config['base_dn']); $config['search_base_dn'] = self::parse_ldap_vars($config['search_base_dn']); $config['bind_dn'] = str_replace('%dn', $_SESSION['kolab_dn'], $config['bind_dn']); if (!empty($config['groups'])) { $config['groups']['base_dn'] = self::parse_ldap_vars($config['groups']['base_dn']); } } return $config; } /** * Modifies list of plugins and settings according to * specified LDAP roles */ public function load_user_role_plugins_and_settings() { if (empty($_SESSION['user_roledns'])) { return; } $rcmail = rcube::get_instance(); // Example 'kolab_auth_role_plugins' = // // Array( // '' => Array('plugin1', 'plugin2'), // ); // // NOTE that may in fact be something like: 'cn=role,%dc' $role_plugins = $rcmail->config->get('kolab_auth_role_plugins'); // Example $rcmail_config['kolab_auth_role_settings'] = // // Array( // '' => Array( // '$setting' => Array( // 'mode' => '(override|merge)', (default: override) // 'value' => <>, // 'allow_override' => (true|false) (default: false) // ), // ), // ); // // NOTE that may in fact be something like: 'cn=role,%dc' $role_settings = $rcmail->config->get('kolab_auth_role_settings'); if (!empty($role_plugins)) { foreach ($role_plugins as $role_dn => $plugins) { $role_dn = self::parse_ldap_vars($role_dn); if (!empty($role_plugins[$role_dn])) { $role_plugins[$role_dn] = array_unique(array_merge((array)$role_plugins[$role_dn], $plugins)); } else { $role_plugins[$role_dn] = $plugins; } } } if (!empty($role_settings)) { foreach ($role_settings as $role_dn => $settings) { $role_dn = self::parse_ldap_vars($role_dn); if (!empty($role_settings[$role_dn])) { $role_settings[$role_dn] = array_merge((array)$role_settings[$role_dn], $settings); } else { $role_settings[$role_dn] = $settings; } } } foreach ($_SESSION['user_roledns'] as $role_dn) { if (!empty($role_settings[$role_dn]) && is_array($role_settings[$role_dn])) { foreach ($role_settings[$role_dn] as $setting_name => $setting) { if (!isset($setting['mode'])) { $setting['mode'] = 'override'; } if ($setting['mode'] == "override") { $rcmail->config->set($setting_name, $setting['value']); } elseif ($setting['mode'] == "merge") { $orig_setting = $rcmail->config->get($setting_name); if (!empty($orig_setting)) { if (is_array($orig_setting)) { $rcmail->config->set($setting_name, array_merge($orig_setting, $setting['value'])); } } else { $rcmail->config->set($setting_name, $setting['value']); } } $dont_override = (array) $rcmail->config->get('dont_override'); if (empty($setting['allow_override'])) { $rcmail->config->set('dont_override', array_merge($dont_override, array($setting_name))); } else { if (in_array($setting_name, $dont_override)) { $_dont_override = array(); foreach ($dont_override as $_setting) { if ($_setting != $setting_name) { $_dont_override[] = $_setting; } } $rcmail->config->set('dont_override', $_dont_override); } } if ($setting_name == 'skin') { if ($rcmail->output->type == 'html') { $rcmail->output->set_skin($setting['value']); $rcmail->output->set_env('skin', $setting['value']); } } } } if (!empty($role_plugins[$role_dn])) { foreach ((array)$role_plugins[$role_dn] as $plugin) { $this->api->load_plugin($plugin); } } } } /** * Logging method replacement to print debug/errors into * a separate (sub)folder for each user */ public function write_log($args) { $rcmail = rcube::get_instance(); if ($rcmail->config->get('log_driver') == 'syslog') { return $args; } // log_driver == 'file' is assumed here $log_dir = $rcmail->config->get('log_dir', RCUBE_INSTALL_PATH . 'logs'); // Append original username + target username for audit-logging if ($rcmail->config->get('kolab_auth_auditlog') && !empty($_SESSION['kolab_auth_admin'])) { $args['dir'] = $log_dir . '/' . strtolower($_SESSION['kolab_auth_admin']) . '/' . strtolower($this->username); // Attempt to create the directory if (!is_dir($args['dir'])) { @mkdir($args['dir'], 0750, true); } } // Define the user log directory if a username is provided else if ($rcmail->config->get('per_user_logging') && !empty($this->username)) { $user_log_dir = $log_dir . '/' . strtolower($this->username); if (is_writable($user_log_dir)) { $args['dir'] = $user_log_dir; } else if ($args['name'] != 'errors') { $args['abort'] = true; // don't log if unauthenticed } } return $args; } /** * Sets defaults for new user. */ public function user_create($args) { if (!empty($this->data['user_email'])) { // addresses list is supported if (array_key_exists('email_list', $args)) { $email_list = array_unique($this->data['user_email']); // add organization to the list if (!empty($this->data['user_organization'])) { foreach ($email_list as $idx => $email) { $email_list[$idx] = array( 'organization' => $this->data['user_organization'], 'email' => $email, ); } } $args['email_list'] = $email_list; } else { $args['user_email'] = $this->data['user_email'][0]; } } if (!empty($this->data['user_name'])) { $args['user_name'] = $this->data['user_name']; } return $args; } /** * Modifies login form adding additional "Login As" field */ public function login_form($args) { $this->add_texts('localization/'); $rcmail = rcube::get_instance(); $admin_login = $rcmail->config->get('kolab_auth_admin_login'); $group = $rcmail->config->get('kolab_auth_group'); $role_attr = $rcmail->config->get('kolab_auth_role'); // Show "Login As" input if (empty($admin_login) || (empty($group) && empty($role_attr))) { return $args; } $input = new html_inputfield(array('name' => '_loginas', 'id' => 'rcmloginas', 'type' => 'text', 'autocomplete' => 'off')); $row = html::tag('tr', null, - html::tag('td', 'title', html::label('rcmloginas', Q($this->gettext('loginas')))) + html::tag('td', 'title', html::label('rcmloginas', rcube::Q($this->gettext('loginas')))) . html::tag('td', 'input', $input->show(trim(rcube_utils::get_input_value('_loginas', rcube_utils::INPUT_POST)))) ); $args['content'] = preg_replace('/<\/tbody>/i', $row . '', $args['content']); return $args; } /** * Find user credentials In LDAP. */ public function authenticate($args) { // get username and host $host = $args['host']; $user = $args['user']; $pass = $args['pass']; $loginas = trim(rcube_utils::get_input_value('_loginas', rcube_utils::INPUT_POST)); - if (empty($user) || empty($pass)) { + if (empty($user) || (empty($pass) && empty($_SERVER['REMOTE_USER']))) { $args['abort'] = true; return $args; } // temporarily set the current username to the one submitted $this->username = $user; $ldap = self::ldap(); if (!$ldap || !$ldap->ready) { $args['abort'] = true; $args['kolab_ldap_error'] = true; $message = sprintf( 'Login failure for user %s from %s in session %s (error %s)', $user, rcube_utils::remote_ip(), session_id(), "LDAP not ready" ); rcube::write_log('userlogins', $message); return $args; } // Find user record in LDAP $record = $ldap->get_user_record($user, $host); if (empty($record)) { $args['abort'] = true; $message = sprintf( 'Login failure for user %s from %s in session %s (error %s)', $user, rcube_utils::remote_ip(), session_id(), "No user record found" ); rcube::write_log('userlogins', $message); return $args; } $rcmail = rcube::get_instance(); $admin_login = $rcmail->config->get('kolab_auth_admin_login'); $admin_pass = $rcmail->config->get('kolab_auth_admin_password'); $login_attr = $rcmail->config->get('kolab_auth_login'); $name_attr = $rcmail->config->get('kolab_auth_name'); $email_attr = $rcmail->config->get('kolab_auth_email'); $org_attr = $rcmail->config->get('kolab_auth_organization'); $role_attr = $rcmail->config->get('kolab_auth_role'); $imap_attr = $rcmail->config->get('kolab_auth_mailhost'); if (!empty($role_attr) && !empty($record[$role_attr])) { $_SESSION['user_roledns'] = (array)($record[$role_attr]); } if (!empty($imap_attr) && !empty($record[$imap_attr])) { $default_host = $rcmail->config->get('default_host'); if (!empty($default_host)) { rcube::write_log("errors", "Both default host and kolab_auth_mailhost set. Incompatible."); } else { $args['host'] = "tls://" . $record[$imap_attr]; } } // Login As... if (!empty($loginas) && $admin_login) { // Authenticate to LDAP $result = $ldap->bind($record['dn'], $pass); if (!$result) { $args['abort'] = true; $message = sprintf( 'Login failure for user %s from %s in session %s (error %s)', $user, rcube_utils::remote_ip(), session_id(), "Unable to bind with '" . $record['dn'] . "'" ); rcube::write_log('userlogins', $message); return $args; } $isadmin = false; $admin_rights = $rcmail->config->get('kolab_auth_admin_rights', array()); // @deprecated: fall-back to the old check if the original user has/belongs to administrative role/group if (empty($admin_rights)) { $group = $rcmail->config->get('kolab_auth_group'); $role_dn = $rcmail->config->get('kolab_auth_role_value'); // check role attribute if (!empty($role_attr) && !empty($role_dn) && !empty($record[$role_attr])) { $role_dn = $ldap->parse_vars($role_dn, $user, $host); if (in_array($role_dn, (array)$record[$role_attr])) { $isadmin = true; } } // check group if (!$isadmin && !empty($group)) { $groups = $ldap->get_user_groups($record['dn'], $user, $host); if (in_array($group, $groups)) { $isadmin = true; } } if ($isadmin) { // user has admin privileges privilage, get "login as" user credentials $target_entry = $ldap->get_user_record($loginas, $host); $allowed_tasks = $rcmail->config->get('kolab_auth_allowed_tasks'); } } else { // get "login as" user credentials $target_entry = $ldap->get_user_record($loginas, $host); if (!empty($target_entry)) { // get effective rights to determine login-as permissions $effective_rights = (array)$ldap->effective_rights($target_entry['dn']); if (!empty($effective_rights)) { $effective_rights['attrib'] = $effective_rights['attributeLevelRights']; $effective_rights['entry'] = $effective_rights['entryLevelRights']; // compare the rights with the permissions mapping $allowed_tasks = array(); foreach ($admin_rights as $task => $perms) { $perms_ = explode(':', $perms); $type = array_shift($perms_); $req = array_pop($perms_); $attrib = array_pop($perms_); if (array_key_exists($type, $effective_rights)) { if ($type == 'entry' && in_array($req, $effective_rights[$type])) { $allowed_tasks[] = $task; } else if ($type == 'attrib' && array_key_exists($attrib, $effective_rights[$type]) && in_array($req, $effective_rights[$type][$attrib])) { $allowed_tasks[] = $task; } } } $isadmin = !empty($allowed_tasks); } } } // Save original user login for log (see below) if ($login_attr) { $origname = is_array($record[$login_attr]) ? $record[$login_attr][0] : $record[$login_attr]; } else { $origname = $user; } if (!$isadmin || empty($target_entry)) { $this->add_texts('localization/'); $args['abort'] = true; $args['error'] = $this->gettext(array( 'name' => 'loginasnotallowed', - 'vars' => array('user' => Q($loginas)), + 'vars' => array('user' => rcube::Q($loginas)), )); $message = sprintf( 'Login failure for user %s (as user %s) from %s in session %s (error %s)', $user, $loginas, rcube_utils::remote_ip(), session_id(), "No privileges to login as '" . $loginas . "'" ); rcube::write_log('userlogins', $message); return $args; } // replace $record with target entry $record = $target_entry; $args['user'] = $this->username = $loginas; // Mark session to use SASL proxy for IMAP authentication $_SESSION['kolab_auth_admin'] = strtolower($origname); $_SESSION['kolab_auth_login'] = $rcmail->encrypt($admin_login); $_SESSION['kolab_auth_password'] = $rcmail->encrypt($admin_pass); $_SESSION['kolab_auth_allowed_tasks'] = $allowed_tasks; } // Store UID and DN of logged user in session for use by other plugins $_SESSION['kolab_uid'] = is_array($record['uid']) ? $record['uid'][0] : $record['uid']; $_SESSION['kolab_dn'] = $record['dn']; // Store LDAP replacement variables used for current user // This improves performance of load_user_role_plugins_and_settings() // which is executed on every request (via startup hook) and where // we don't like to use LDAP (connection + bind + search) $_SESSION['kolab_auth_vars'] = $ldap->get_parse_vars(); // Set user login if ($login_attr) { $this->data['user_login'] = is_array($record[$login_attr]) ? $record[$login_attr][0] : $record[$login_attr]; } if ($this->data['user_login']) { $args['user'] = $this->username = $this->data['user_login']; } // User name for identity (first log in) foreach ((array)$name_attr as $field) { $name = is_array($record[$field]) ? $record[$field][0] : $record[$field]; if (!empty($name)) { $this->data['user_name'] = $name; break; } } // User email(s) for identity (first log in) foreach ((array)$email_attr as $field) { $email = is_array($record[$field]) ? array_filter($record[$field]) : $record[$field]; if (!empty($email)) { $this->data['user_email'] = array_merge((array)$this->data['user_email'], (array)$email); } } // Organization name for identity (first log in) foreach ((array)$org_attr as $field) { $organization = is_array($record[$field]) ? $record[$field][0] : $record[$field]; if (!empty($organization)) { $this->data['user_organization'] = $organization; break; } } // Log "Login As" usage if (!empty($origname)) { rcube::write_log('userlogins', sprintf('Admin login for %s by %s from %s', $args['user'], $origname, rcube_utils::remote_ip())); } // load per-user settings/plugins $this->load_user_role_plugins_and_settings(); return $args; } /** * Set user DN for password change (password plugin with ldap_simple driver) */ public function password_ldap_bind($args) { $args['user_dn'] = $_SESSION['kolab_dn']; $rcmail = rcube::get_instance(); $rcmail->config->set('password_ldap_method', 'user'); return $args; } /** * Sets SASL Proxy login/password for IMAP and Managesieve auth */ public function imap_connect($args) { if (!empty($_SESSION['kolab_auth_admin'])) { $rcmail = rcube::get_instance(); $admin_login = $rcmail->decrypt($_SESSION['kolab_auth_login']); $admin_pass = $rcmail->decrypt($_SESSION['kolab_auth_password']); $args['auth_cid'] = $admin_login; $args['auth_pw'] = $admin_pass; } return $args; } /** * Sets SASL Proxy login/password for SMTP auth */ public function smtp_connect($args) { if (!empty($_SESSION['kolab_auth_admin'])) { $rcmail = rcube::get_instance(); $admin_login = $rcmail->decrypt($_SESSION['kolab_auth_login']); $admin_pass = $rcmail->decrypt($_SESSION['kolab_auth_password']); $args['smtp_auth_cid'] = $admin_login; $args['smtp_auth_pw'] = $admin_pass; } return $args; } /** * Hook to replace the plain text input field for email address by a drop-down list * with all email addresses (including aliases) from this user's LDAP record. */ public function identity_form($args) { $rcmail = rcube::get_instance(); $ident_level = intval($rcmail->config->get('identities_level', 0)); // do nothing if email address modification is disabled if ($ident_level == 1 || $ident_level == 3) { return $args; } $ldap = self::ldap(); if (!$ldap || !$ldap->ready || empty($_SESSION['kolab_dn'])) { return $args; } $emails = array(); $user_record = $ldap->get_record($_SESSION['kolab_dn']); foreach ((array)$rcmail->config->get('kolab_auth_email', array()) as $col) { $values = rcube_addressbook::get_col_values($col, $user_record, true); if (!empty($values)) $emails = array_merge($emails, array_filter($values)); } // kolab_delegation might want to modify this addresses list $plugin = $rcmail->plugins->exec_hook('kolab_auth_emails', array('emails' => $emails)); $emails = $plugin['emails']; if (!empty($emails)) { $args['form']['addressing']['content']['email'] = array( 'type' => 'select', 'options' => array_combine($emails, $emails), ); } return $args; } /** * Action executed before the page is rendered to add an onload script * that will remove all taskbar buttons for disabled tasks */ public function render_page($args) { $rcmail = rcube::get_instance(); $tasks = (array)$_SESSION['kolab_auth_allowed_tasks']; $tasks[] = 'logout'; // disable buttons in taskbar $script = " \$('a').filter(function() { var ev = \$(this).attr('onclick'); return ev && ev.match(/'switch-task','([a-z]+)'/) && \$.inArray(RegExp.\$1, " . json_encode($tasks) . ") < 0; }).remove(); "; $rcmail->output->add_script($script, 'docready'); } /** * Initializes LDAP object and connects to LDAP server */ public static function ldap() { if (self::$ldap) { return self::$ldap; } $rcmail = rcube::get_instance(); $addressbook = $rcmail->config->get('kolab_auth_addressbook'); if (!is_array($addressbook)) { $ldap_config = (array)$rcmail->config->get('ldap_public'); $addressbook = $ldap_config[$addressbook]; } if (empty($addressbook)) { return null; } require_once __DIR__ . '/kolab_auth_ldap.php'; self::$ldap = new kolab_auth_ldap($addressbook); return self::$ldap; } /** * Parses LDAP DN string with replacing supported variables. * See kolab_auth_ldap::parse_vars() * * @param string $str LDAP DN string * * @return string Parsed DN string */ public static function parse_ldap_vars($str) { if (!empty($_SESSION['kolab_auth_vars'])) { $str = strtr($str, $_SESSION['kolab_auth_vars']); } return $str; } } diff --git a/lib/drivers/kolab/plugins/kolab_auth/kolab_auth_ldap.php b/lib/drivers/kolab/plugins/kolab_auth/kolab_auth_ldap.php index 431133b..30c82bf 100644 --- a/lib/drivers/kolab/plugins/kolab_auth/kolab_auth_ldap.php +++ b/lib/drivers/kolab/plugins/kolab_auth/kolab_auth_ldap.php @@ -1,548 +1,480 @@ * * Copyright (C) 2011-2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /** * Wrapper class for rcube_ldap_generic */ class kolab_auth_ldap extends rcube_ldap_generic { - private $icache = array(); - private $conf = array(); + private $conf = array(); private $fieldmap = array(); function __construct($p) { $rcmail = rcube::get_instance(); $this->conf = $p; $this->conf['kolab_auth_user_displayname'] = $rcmail->config->get('kolab_auth_user_displayname', '{name}'); $this->fieldmap = $p['fieldmap']; $this->fieldmap['uid'] = 'uid'; $p['attributes'] = array_values($this->fieldmap); $p['debug'] = (bool) $rcmail->config->get('ldap_debug'); // Connect to the server (with bind) parent::__construct($p); $this->_connect(); $rcmail->add_shutdown_function(array($this, 'close')); } /** * Establish a connection to the LDAP server */ private function _connect() { // try to connect + bind for every host configured // with OpenLDAP 2.x ldap_connect() always succeeds but ldap_bind will fail if host isn't reachable // see http://www.php.net/manual/en/function.ldap-connect.php foreach ((array)$this->config['hosts'] as $host) { // skip host if connection failed if (!$this->connect($host)) { continue; } $bind_pass = $this->config['bind_pass']; $bind_user = $this->config['bind_user']; $bind_dn = $this->config['bind_dn']; if (empty($bind_pass)) { $this->ready = true; } else { if (!empty($bind_dn)) { $this->ready = $this->bind($bind_dn, $bind_pass); } else if (!empty($this->config['auth_cid'])) { $this->ready = $this->sasl_bind($this->config['auth_cid'], $bind_pass, $bind_user); } else { $this->ready = $this->sasl_bind($bind_user, $bind_pass); } } // connection established, we're done here if ($this->ready) { break; } } // end foreach hosts if (!is_resource($this->conn)) { rcube::raise_error(array('code' => 100, 'type' => 'ldap', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not connect to any LDAP server, last tried $host"), true); $this->ready = false; } return $this->ready; } /** * Fetches user data from LDAP addressbook */ function get_user_record($user, $host) { $rcmail = rcube::get_instance(); $filter = $rcmail->config->get('kolab_auth_filter'); $filter = $this->parse_vars($filter, $user, $host); $base_dn = $this->parse_vars($this->config['base_dn'], $user, $host); $scope = $this->config['scope']; // @TODO: print error if filter is empty // get record if ($result = parent::search($base_dn, $filter, $scope, $this->attributes)) { if ($result->count() == 1) { $entries = $result->entries(true); $dn = key($entries); $entry = array_pop($entries); $entry = $this->field_mapping($dn, $entry); return $entry; } } } /** * Fetches user data from LDAP addressbook */ function get_user_groups($dn, $user, $host) { if (empty($dn) || empty($this->config['groups'])) { return array(); } $base_dn = $this->parse_vars($this->config['groups']['base_dn'], $user, $host); $name_attr = $this->config['groups']['name_attr'] ? $this->config['groups']['name_attr'] : 'cn'; $member_attr = $this->get_group_member_attr(); $filter = "(member=$dn)(uniqueMember=$dn)"; if ($member_attr != 'member' && $member_attr != 'uniqueMember') $filter .= "($member_attr=$dn)"; $filter = strtr("(|$filter)", array("\\" => "\\\\")); $result = parent::search($base_dn, $filter, 'sub', array('dn', $name_attr)); if (!$result) { return array(); } $groups = array(); foreach ($result as $entry) { $dn = $entry['dn']; $entry = rcube_ldap_generic::normalize_entry($entry); $groups[$dn] = $entry[$name_attr]; } return $groups; } /** * Get a specific LDAP record * * @param string DN * * @return array Record data */ function get_record($dn) { if (!$this->ready) { return; } if ($rec = $this->get_entry($dn)) { $rec = rcube_ldap_generic::normalize_entry($rec); $rec = $this->field_mapping($dn, $rec); } return $rec; } /** * Replace LDAP record data items * * @param string $dn DN * @param array $entry LDAP entry * * return bool True on success, False on failure */ function replace($dn, $entry) { // fields mapping foreach ($this->fieldmap as $field => $attr) { if (array_key_exists($field, $entry)) { $entry[$attr] = $entry[$field]; if ($attr != $field) { unset($entry[$field]); } } } return $this->mod_replace($dn, $entry); } /** * Search records (simplified version of rcube_ldap::search) * * @param mixed $fields The field name or array of field names to search in - * @param mixed $value Search value (or array of values when $fields is array) + * @param string $value Search value * @param int $mode Matching mode: * 0 - partial (*abc*), * 1 - strict (=), * 2 - prefix (abc*) * @param array $required List of fields that cannot be empty * @param int $limit Number of records * @param int $count Returns the number of records found * * @return array List or false on error */ function dosearch($fields, $value, $mode=1, $required = array(), $limit = 0, &$count = 0) { if (empty($fields)) { return array(); } - $mode = intval($mode); + $mode = intval($mode); - // use AND operator for advanced searches - $filter = is_array($value) ? '(&' : '(|'; + // try to resolve field names into ldap attributes + $fieldmap = $this->fieldmap; + $attrs = array_map(function($f) use ($fieldmap) { + return array_key_exists($f, $fieldmap) ? $fieldmap[$f] : $f; + }, (array)$fields); - // set wildcards - $wp = $ws = ''; - if (!empty($this->config['fuzzy_search']) && $mode != 1) { - $ws = '*'; - if (!$mode) { - $wp = '*'; - } + // compose a full-text-search-like filter + if (count($attrs) > 1 || $mode != 1) { + $filter = self::fulltext_search_filter($value, $attrs, $mode); } - - foreach ((array)$fields as $idx => $field) { - $val = is_array($value) ? $value[$idx] : $value; - $attrs = (array) $this->fieldmap[$field]; - - if (empty($attrs)) { - $filter .= "($field=$wp" . rcube_ldap_generic::quote_string($val) . "$ws)"; - } - else { - if (count($attrs) > 1) - $filter .= '(|'; - foreach ($attrs as $f) - $filter .= "($f=$wp" . rcube_ldap_generic::quote_string($val) . "$ws)"; - if (count($attrs) > 1) - $filter .= ')'; - } + // direct search + else { + $field = $attrs[0]; + $filter = "($field=" . self::quote_string($value) . ")"; } - $filter .= ')'; // add required (non empty) fields filter $req_filter = ''; foreach ((array)$required as $field) { - if (in_array($field, (array)$fields)) // required field is already in search filter - continue; + $attr = array_key_exists($field, $this->fieldmap) ? $this->fieldmap[$field] : $field; - $attrs = (array) $this->fieldmap[$field]; - - if (empty($attrs)) { - $req_filter .= "($field=*)"; - } - else { - if (count($attrs) > 1) - $req_filter .= '(|'; - foreach ($attrs as $f) - $req_filter .= "($f=*)"; - if (count($attrs) > 1) - $req_filter .= ')'; + // only add if required field is not already in search filter + if (!in_array($attr, $attrs)) { + $req_filter .= "($attr=*)"; } } if (!empty($req_filter)) { $filter = '(&' . $req_filter . $filter . ')'; } // avoid double-wildcard if $value is empty $filter = preg_replace('/\*+/', '*', $filter); // add general filter to query if (!empty($this->config['filter'])) { $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->config['filter']) . ')' . $filter . ')'; } $base_dn = $this->parse_vars($this->config['base_dn']); $scope = $this->config['scope']; $attrs = array_values($this->fieldmap); $list = array(); if ($result = $this->search($base_dn, $filter, $scope, $attrs)) { $count = $result->count(); $i = 0; foreach ($result as $entry) { if ($limit && $limit <= $i) { break; } $dn = $entry['dn']; $entry = rcube_ldap_generic::normalize_entry($entry); $list[$dn] = $this->field_mapping($dn, $entry); $i++; } } return $list; } /** * Set filter used in search() */ function set_filter($filter) { $this->config['filter'] = $filter; } /** * Maps LDAP attributes to defined fields */ protected function field_mapping($dn, $entry) { $entry['dn'] = $dn; // fields mapping foreach ($this->fieldmap as $field => $attr) { // $entry might be indexed by lower-case attribute names $attr_lc = strtolower($attr); if (isset($entry[$attr_lc])) { $entry[$field] = $entry[$attr_lc]; } else if (isset($entry[$attr])) { $entry[$field] = $entry[$attr]; } } // compose display name according to config if (empty($this->fieldmap['displayname'])) { $entry['displayname'] = rcube_addressbook::compose_search_name( $entry, $entry['email'], $entry['name'], $this->conf['kolab_auth_user_displayname'] ); } return $entry; } /** * Detects group member attribute name */ private function get_group_member_attr($object_classes = array()) { if (empty($object_classes)) { $object_classes = $this->config['groups']['object_classes']; } if (!empty($object_classes)) { foreach ((array)$object_classes as $oc) { switch (strtolower($oc)) { case 'group': case 'groupofnames': case 'kolabgroupofnames': $member_attr = 'member'; break; case 'groupofuniquenames': case 'kolabgroupofuniquenames': $member_attr = 'uniqueMember'; break; } } } if (!empty($member_attr)) { return $member_attr; } if (!empty($this->config['groups']['member_attr'])) { return $this->config['groups']['member_attr']; } return 'member'; } /** * Prepares filter query for LDAP search */ function parse_vars($str, $user = null, $host = null) { // When authenticating user $user is always set // if not set it means we use this LDAP object for other // purposes, e.g. kolab_delegation, then username with // correct domain is in a session if (!$user) { $user = $_SESSION['username']; } if (isset($this->icache[$user])) { list($user, $dc) = $this->icache[$user]; } else { $orig_user = $user; $rcmail = rcube::get_instance(); // get default domain if ($username_domain = $rcmail->config->get('username_domain')) { if ($host && is_array($username_domain) && isset($username_domain[$host])) { $domain = rcube_utils::parse_host($username_domain[$host], $host); } else if (is_string($username_domain)) { $domain = rcube_utils::parse_host($username_domain, $host); } } // realmed username (with domain) if (strpos($user, '@')) { list($usr, $dom) = explode('@', $user); // unrealm domain, user login can contain a domain alias - if ($dom != $domain && ($dc = $this->find_domain($dom))) { + if ($dom != $domain && ($dc = $this->domain_root_dn($dom))) { // @FIXME: we should replace domain in $user, I suppose } } else if ($domain) { $user .= '@' . $domain; } $this->icache[$orig_user] = array($user, $dc); } // replace variables in filter list($u, $d) = explode('@', $user); // hierarchal domain string if (empty($dc)) { $dc = 'dc=' . strtr($d, array('.' => ',dc=')); } $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u); $this->parse_replaces = $replaces; return strtr($str, $replaces); } - /** - * Find root domain for specified domain - * - * @param string $domain Domain name - * - * @return string Domain DN string - */ - function find_domain($domain) - { - if (empty($domain) || empty($this->config['domain_base_dn']) || empty($this->config['domain_filter'])) { - return null; - } - - $base_dn = $this->config['domain_base_dn']; - $filter = $this->config['domain_filter']; - $name_attr = $this->config['domain_name_attribute']; - - if (empty($name_attr)) { - $name_attr = 'associateddomain'; - } - - $filter = str_replace('%s', rcube_ldap_generic::quote_string($domain), $filter); - $result = parent::search($base_dn, $filter, 'sub', array($name_attr, 'inetdomainbasedn')); - - if (!$result) { - return null; - } - - $entries = $result->entries(true); - $entry_dn = key($entries); - $entry = $entries[$entry_dn]; - - if (is_array($entry)) { - if (!empty($entry['inetdomainbasedn'])) { - return $entry['inetdomainbasedn']; - } - - $domain = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr]; - - return $domain ? 'dc=' . implode(',dc=', explode('.', $domain)) : null; - } - } - /** * Returns variables used for replacement in (last) parse_vars() call * * @return array Variable-value hash array */ public function get_parse_vars() { return $this->parse_replaces; } /** * Register additional fields */ public function extend_fieldmap($map) { foreach ((array)$map as $name => $attr) { if (!in_array($attr, $this->attributes)) { $this->attributes[] = $attr; $this->fieldmap[$name] = $attr; } } } /** * HTML-safe DN string encoding * * @param string $str DN string * * @return string Encoded HTML identifier string */ static function dn_encode($str) { return rtrim(strtr(base64_encode($str), '+/', '-_'), '='); } /** * Decodes DN string encoded with _dn_encode() * * @param string $str Encoded HTML identifier string * * @return string DN string */ static function dn_decode($str) { $str = str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT); return base64_decode($str); } } diff --git a/lib/drivers/kolab/plugins/kolab_auth/localization/de_CH.inc b/lib/drivers/kolab/plugins/kolab_auth/localization/de_CH.inc index 0332070..3918e6e 100644 --- a/lib/drivers/kolab/plugins/kolab_auth/localization/de_CH.inc +++ b/lib/drivers/kolab/plugins/kolab_auth/localization/de_CH.inc @@ -1,10 +1,11 @@ diff --git a/lib/drivers/kolab/plugins/kolab_auth/localization/pl_PL.inc b/lib/drivers/kolab/plugins/kolab_auth/localization/pl_PL.inc index ca67859..ed203e6 100644 --- a/lib/drivers/kolab/plugins/kolab_auth/localization/pl_PL.inc +++ b/lib/drivers/kolab/plugins/kolab_auth/localization/pl_PL.inc @@ -1,10 +1,9 @@ diff --git a/lib/drivers/kolab/plugins/libkolab/SQL/mysql.initial.sql b/lib/drivers/kolab/plugins/libkolab/SQL/mysql.initial.sql index 98e7e78..a1497da 100644 --- a/lib/drivers/kolab/plugins/libkolab/SQL/mysql.initial.sql +++ b/lib/drivers/kolab/plugins/libkolab/SQL/mysql.initial.sql @@ -1,187 +1,191 @@ /** * libkolab database schema * * @version 1.1 * @author Thomas Bruederli * @licence GNU AGPL **/ +/*!40014 SET FOREIGN_KEY_CHECKS=0 */; DROP TABLE IF EXISTS `kolab_folders`; CREATE TABLE `kolab_folders` ( `folder_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `resource` VARCHAR(255) NOT NULL, `type` VARCHAR(32) NOT NULL, `synclock` INT(10) NOT NULL DEFAULT '0', `ctag` VARCHAR(40) DEFAULT NULL, + `changed` DATETIME DEFAULT NULL, + `objectcount` BIGINT DEFAULT NULL, PRIMARY KEY(`folder_id`), INDEX `resource_type` (`resource`, `type`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; DROP TABLE IF EXISTS `kolab_cache`; DROP TABLE IF EXISTS `kolab_cache_contact`; CREATE TABLE `kolab_cache_contact` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(128) CHARACTER SET ascii NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `xml` LONGBLOB NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `type` VARCHAR(32) CHARACTER SET ascii NOT NULL, `name` VARCHAR(255) NOT NULL, `firstname` VARCHAR(255) NOT NULL, `surname` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL, CONSTRAINT `fk_kolab_cache_contact_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `contact_type` (`folder_id`,`type`), INDEX `contact_uid2msguid` (`folder_id`,`uid`,`msguid`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; DROP TABLE IF EXISTS `kolab_cache_event`; CREATE TABLE `kolab_cache_event` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(128) CHARACTER SET ascii NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `xml` LONGBLOB NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `dtstart` DATETIME, `dtend` DATETIME, CONSTRAINT `fk_kolab_cache_event_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `event_uid2msguid` (`folder_id`,`uid`,`msguid`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; DROP TABLE IF EXISTS `kolab_cache_task`; CREATE TABLE `kolab_cache_task` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(128) CHARACTER SET ascii NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `xml` LONGBLOB NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `dtstart` DATETIME, `dtend` DATETIME, CONSTRAINT `fk_kolab_cache_task_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `task_uid2msguid` (`folder_id`,`uid`,`msguid`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; DROP TABLE IF EXISTS `kolab_cache_journal`; CREATE TABLE `kolab_cache_journal` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(128) CHARACTER SET ascii NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `xml` LONGBLOB NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `dtstart` DATETIME, `dtend` DATETIME, CONSTRAINT `fk_kolab_cache_journal_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `journal_uid2msguid` (`folder_id`,`uid`,`msguid`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; DROP TABLE IF EXISTS `kolab_cache_note`; CREATE TABLE `kolab_cache_note` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(128) CHARACTER SET ascii NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `xml` LONGBLOB NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, CONSTRAINT `fk_kolab_cache_note_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `note_uid2msguid` (`folder_id`,`uid`,`msguid`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; DROP TABLE IF EXISTS `kolab_cache_file`; CREATE TABLE `kolab_cache_file` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(128) CHARACTER SET ascii NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `xml` LONGBLOB NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `filename` varchar(255) DEFAULT NULL, CONSTRAINT `fk_kolab_cache_file_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `folder_filename` (`folder_id`, `filename`), INDEX `file_uid2msguid` (`folder_id`,`uid`,`msguid`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; DROP TABLE IF EXISTS `kolab_cache_configuration`; CREATE TABLE `kolab_cache_configuration` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(128) CHARACTER SET ascii NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `xml` LONGBLOB NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `type` VARCHAR(32) CHARACTER SET ascii NOT NULL, CONSTRAINT `fk_kolab_cache_configuration_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `configuration_type` (`folder_id`,`type`), INDEX `configuration_uid2msguid` (`folder_id`,`uid`,`msguid`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; DROP TABLE IF EXISTS `kolab_cache_freebusy`; CREATE TABLE `kolab_cache_freebusy` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(128) CHARACTER SET ascii NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `xml` LONGBLOB NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `dtstart` DATETIME, `dtend` DATETIME, CONSTRAINT `fk_kolab_cache_freebusy_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `freebusy_uid2msguid` (`folder_id`,`uid`,`msguid`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; +/*!40014 SET FOREIGN_KEY_CHECKS=1 */; -INSERT INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2015011600'); +REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2015020600'); diff --git a/lib/drivers/kolab/plugins/libkolab/SQL/mysql/2015020600.sql b/lib/drivers/kolab/plugins/libkolab/SQL/mysql/2015020600.sql new file mode 100644 index 0000000..d9077a0 --- /dev/null +++ b/lib/drivers/kolab/plugins/libkolab/SQL/mysql/2015020600.sql @@ -0,0 +1,4 @@ +-- improve cache synchronization (#3933) +ALTER TABLE `kolab_folders` + ADD `changed` DATETIME DEFAULT NULL, + ADD `objectcount` BIGINT DEFAULT NULL; diff --git a/lib/drivers/kolab/plugins/libkolab/SQL/oracle.initial.sql b/lib/drivers/kolab/plugins/libkolab/SQL/oracle.initial.sql index 8f1ed64..cf1fae5 100644 --- a/lib/drivers/kolab/plugins/libkolab/SQL/oracle.initial.sql +++ b/lib/drivers/kolab/plugins/libkolab/SQL/oracle.initial.sql @@ -1,184 +1,186 @@ /** * libkolab database schema * * @version 1.1 * @author Aleksander Machniak * @licence GNU AGPL **/ CREATE TABLE "kolab_folders" ( "folder_id" number NOT NULL PRIMARY KEY, "resource" VARCHAR(255) NOT NULL, "type" VARCHAR(32) NOT NULL, "synclock" integer DEFAULT 0 NOT NULL, - "ctag" VARCHAR(40) DEFAULT NULL + "ctag" VARCHAR(40) DEFAULT NULL, + "changed" timestamp DEFAULT NULL, + "objectcount" number DEFAULT NULL ); CREATE INDEX "kolab_folders_resource_idx" ON "kolab_folders" ("resource", "type"); CREATE SEQUENCE "kolab_folders_seq" START WITH 1 INCREMENT BY 1 NOMAXVALUE; CREATE TRIGGER "kolab_folders_seq_trig" BEFORE INSERT ON "kolab_folders" FOR EACH ROW BEGIN :NEW."folder_id" := "kolab_folders_seq".nextval; END; / CREATE TABLE "kolab_cache_contact" ( "folder_id" number NOT NULL REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, "msguid" number NOT NULL, "uid" varchar(128) NOT NULL, "created" timestamp DEFAULT NULL, "changed" timestamp DEFAULT NULL, "data" clob NOT NULL, "xml" clob NOT NULL, "tags" clob DEFAULT NULL, "words" clob DEFAULT NULL, "type" varchar(32) NOT NULL, "name" varchar(255) DEFAULT NULL, "firstname" varchar(255) DEFAULT NULL, "surname" varchar(255) DEFAULT NULL, "email" varchar(255) DEFAULT NULL, PRIMARY KEY ("folder_id", "msguid") ); CREATE INDEX "kolab_cache_contact_type_idx" ON "kolab_cache_contact" ("folder_id", "type"); CREATE INDEX "kolab_cache_contact_uid2msguid" ON "kolab_cache_contact" ("folder_id", "uid", "msguid"); CREATE TABLE "kolab_cache_event" ( "folder_id" number NOT NULL REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, "msguid" number NOT NULL, "uid" varchar(128) NOT NULL, "created" timestamp DEFAULT NULL, "changed" timestamp DEFAULT NULL, "data" clob NOT NULL, "xml" clob NOT NULL, "tags" clob DEFAULT NULL, "words" clob DEFAULT NULL, "dtstart" timestamp DEFAULT NULL, "dtend" timestamp DEFAULT NULL, PRIMARY KEY ("folder_id", "msguid") ); CREATE INDEX "kolab_cache_event_uid2msguid" ON "kolab_cache_event" ("folder_id", "uid", "msguid"); CREATE TABLE "kolab_cache_task" ( "folder_id" number NOT NULL REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, "msguid" number NOT NULL, "uid" varchar(128) NOT NULL, "created" timestamp DEFAULT NULL, "changed" timestamp DEFAULT NULL, "data" clob NOT NULL, "xml" clob NOT NULL, "tags" clob DEFAULT NULL, "words" clob DEFAULT NULL, "dtstart" timestamp DEFAULT NULL, "dtend" timestamp DEFAULT NULL, PRIMARY KEY ("folder_id", "msguid") ); CREATE INDEX "kolab_cache_task_uid2msguid" ON "kolab_cache_task" ("folder_id", "uid", "msguid"); CREATE TABLE "kolab_cache_journal" ( "folder_id" number NOT NULL REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, "msguid" number NOT NULL, "uid" varchar(128) NOT NULL, "created" timestamp DEFAULT NULL, "changed" timestamp DEFAULT NULL, "data" clob NOT NULL, "xml" clob NOT NULL, "tags" clob DEFAULT NULL, "words" clob DEFAULT NULL, "dtstart" timestamp DEFAULT NULL, "dtend" timestamp DEFAULT NULL, PRIMARY KEY ("folder_id", "msguid") ); CREATE INDEX "kolab_cache_journal_uid2msguid" ON "kolab_cache_journal" ("folder_id", "uid", "msguid"); CREATE TABLE "kolab_cache_note" ( "folder_id" number NOT NULL REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, "msguid" number NOT NULL, "uid" varchar(128) NOT NULL, "created" timestamp DEFAULT NULL, "changed" timestamp DEFAULT NULL, "data" clob NOT NULL, "xml" clob NOT NULL, "tags" clob DEFAULT NULL, "words" clob DEFAULT NULL, PRIMARY KEY ("folder_id", "msguid") ); CREATE INDEX "kolab_cache_note_uid2msguid" ON "kolab_cache_note" ("folder_id", "uid", "msguid"); CREATE TABLE "kolab_cache_file" ( "folder_id" number NOT NULL REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, "msguid" number NOT NULL, "uid" varchar(128) NOT NULL, "created" timestamp DEFAULT NULL, "changed" timestamp DEFAULT NULL, "data" clob NOT NULL, "xml" clob NOT NULL, "tags" clob DEFAULT NULL, "words" clob DEFAULT NULL, "filename" varchar(255) DEFAULT NULL, PRIMARY KEY ("folder_id", "msguid") ); CREATE INDEX "kolab_cache_file_filename" ON "kolab_cache_file" ("folder_id", "filename"); CREATE INDEX "kolab_cache_file_uid2msguid" ON "kolab_cache_file" ("folder_id", "uid", "msguid"); CREATE TABLE "kolab_cache_configuration" ( "folder_id" number NOT NULL REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, "msguid" number NOT NULL, "uid" varchar(128) NOT NULL, "created" timestamp DEFAULT NULL, "changed" timestamp DEFAULT NULL, "data" clob NOT NULL, "xml" clob NOT NULL, "tags" clob DEFAULT NULL, "words" clob DEFAULT NULL, "type" varchar(32) NOT NULL, PRIMARY KEY ("folder_id", "msguid") ); CREATE INDEX "kolab_cache_config_type" ON "kolab_cache_configuration" ("folder_id", "type"); CREATE INDEX "kolab_cache_config_uid2msguid" ON "kolab_cache_configuration" ("folder_id", "uid", "msguid"); CREATE TABLE "kolab_cache_freebusy" ( "folder_id" number NOT NULL REFERENCES "kolab_folders" ("folder_id") ON DELETE CASCADE, "msguid" number NOT NULL, "uid" varchar(128) NOT NULL, "created" timestamp DEFAULT NULL, "changed" timestamp DEFAULT NULL, "data" clob NOT NULL, "xml" clob NOT NULL, "tags" clob DEFAULT NULL, "words" clob DEFAULT NULL, "dtstart" timestamp DEFAULT NULL, "dtend" timestamp DEFAULT NULL, PRIMARY KEY("folder_id", "msguid") ); CREATE INDEX "kolab_cache_fb_uid2msguid" ON "kolab_cache_freebusy" ("folder_id", "uid", "msguid"); -INSERT INTO "system" ("name", "value") VALUES ('libkolab-version', '2015011600'); +INSERT INTO "system" ("name", "value") VALUES ('libkolab-version', '2015020600'); diff --git a/lib/drivers/kolab/plugins/libkolab/SQL/oracle/2015020600.sql b/lib/drivers/kolab/plugins/libkolab/SQL/oracle/2015020600.sql new file mode 100644 index 0000000..a605649 --- /dev/null +++ b/lib/drivers/kolab/plugins/libkolab/SQL/oracle/2015020600.sql @@ -0,0 +1,4 @@ +-- improve cache synchronization (#3933) +ALTER TABLE "kolab_folders" + ADD "changed" timestamp DEFAULT NULL, + ADD "objectcount" number DEFAULT NULL; diff --git a/lib/drivers/kolab/plugins/libkolab/SQL/sqlite.initial.sql b/lib/drivers/kolab/plugins/libkolab/SQL/sqlite.initial.sql new file mode 100644 index 0000000..6f6f3c9 --- /dev/null +++ b/lib/drivers/kolab/plugins/libkolab/SQL/sqlite.initial.sql @@ -0,0 +1,159 @@ +/** + * libkolab database schema + * + * @version 1.1 + * @author Thomas Bruederli + * @licence GNU AGPL + **/ + +CREATE TABLE kolab_folders ( + folder_id INTEGER NOT NULL PRIMARY KEY, + resource VARCHAR(255) NOT NULL, + type VARCHAR(32) NOT NULL, + synclock INTEGER NOT NULL DEFAULT '0', + ctag VARCHAR(40) DEFAULT NULL, + changed DATETIME DEFAULT NULL, + objectcount INTEGER DEFAULT NULL +); + +CREATE INDEX ix_resource_type ON kolab_folders(resource, type); + +CREATE TABLE kolab_cache_contact ( + folder_id INTEGER NOT NULL, + msguid INTEGER NOT NULL, + uid VARCHAR(128) NOT NULL, + created DATETIME DEFAULT NULL, + changed DATETIME DEFAULT NULL, + data TEXT NOT NULL, + xml TEXT NOT NULL, + tags TEXT NOT NULL, + words TEXT NOT NULL, + type VARCHAR(32) NOT NULL, + name VARCHAR(255) NOT NULL, + firstname VARCHAR(255) NOT NULL, + surname VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + PRIMARY KEY(folder_id,msguid) +); + +CREATE INDEX ix_contact_type ON kolab_cache_contact(folder_id,type); +CREATE INDEX ix_contact_uid2msguid ON kolab_cache_contact(folder_id,uid,msguid); + +CREATE TABLE kolab_cache_event ( + folder_id INTEGER NOT NULL, + msguid INTEGER NOT NULL, + uid VARCHAR(128) NOT NULL, + created DATETIME DEFAULT NULL, + changed DATETIME DEFAULT NULL, + data TEXT NOT NULL, + xml TEXT NOT NULL, + tags TEXT NOT NULL, + words TEXT NOT NULL, + dtstart DATETIME, + dtend DATETIME, + PRIMARY KEY(folder_id,msguid) +); + +CREATE INDEX ix_event_uid2msguid ON kolab_cache_event(folder_id,uid,msguid); + +CREATE TABLE kolab_cache_task ( + folder_id INTEGER NOT NULL, + msguid INTEGER NOT NULL, + uid VARCHAR(128) NOT NULL, + created DATETIME DEFAULT NULL, + changed DATETIME DEFAULT NULL, + data TEXT NOT NULL, + xml TEXT NOT NULL, + tags TEXT NOT NULL, + words TEXT NOT NULL, + dtstart DATETIME, + dtend DATETIME, + PRIMARY KEY(folder_id,msguid) +); + +CREATE INDEX ix_task_uid2msguid ON kolab_cache_task(folder_id,uid,msguid); + +CREATE TABLE kolab_cache_journal ( + folder_id INTEGER NOT NULL, + msguid INTEGER NOT NULL, + uid VARCHAR(128) NOT NULL, + created DATETIME DEFAULT NULL, + changed DATETIME DEFAULT NULL, + data TEXT NOT NULL, + xml TEXT NOT NULL, + tags TEXT NOT NULL, + words TEXT NOT NULL, + dtstart DATETIME, + dtend DATETIME, + PRIMARY KEY(folder_id,msguid) +); + +CREATE INDEX ix_journal_uid2msguid ON kolab_cache_journal(folder_id,uid,msguid); + +CREATE TABLE kolab_cache_note ( + folder_id INTEGER NOT NULL, + msguid INTEGER NOT NULL, + uid VARCHAR(128) NOT NULL, + created DATETIME DEFAULT NULL, + changed DATETIME DEFAULT NULL, + data TEXT NOT NULL, + xml TEXT NOT NULL, + tags TEXT NOT NULL, + words TEXT NOT NULL, + PRIMARY KEY(folder_id,msguid) +); + +CREATE INDEX ix_note_uid2msguid ON kolab_cache_note(folder_id,uid,msguid); + +CREATE TABLE kolab_cache_file ( + folder_id INTEGER NOT NULL, + msguid INTEGER NOT NULL, + uid VARCHAR(128) NOT NULL, + created DATETIME DEFAULT NULL, + changed DATETIME DEFAULT NULL, + data TEXT NOT NULL, + xml TEXT NOT NULL, + tags TEXT NOT NULL, + words TEXT NOT NULL, + filename varchar(255) DEFAULT NULL, + PRIMARY KEY(folder_id,msguid) +); + +CREATE INDEX ix_folder_filename ON kolab_cache_file(folder_id,filename); +CREATE INDEX ix_file_uid2msguid ON kolab_cache_file(folder_id,uid,msguid); + +CREATE TABLE kolab_cache_configuration ( + folder_id INTEGER NOT NULL, + msguid INTEGER NOT NULL, + uid VARCHAR(128) NOT NULL, + created DATETIME DEFAULT NULL, + changed DATETIME DEFAULT NULL, + data TEXT NOT NULL, + xml TEXT NOT NULL, + tags TEXT NOT NULL, + words TEXT NOT NULL, + type VARCHAR(32) NOT NULL, + PRIMARY KEY(folder_id,msguid) +); + +CREATE INDEX ix_configuration_type ON kolab_cache_configuration(folder_id,type); +CREATE INDEX ix_configuration_uid2msguid ON kolab_cache_configuration(folder_id,uid,msguid); + +CREATE TABLE kolab_cache_freebusy ( + folder_id INTEGER NOT NULL, + msguid INTEGER NOT NULL, + uid VARCHAR(128) NOT NULL, + created DATETIME DEFAULT NULL, + changed DATETIME DEFAULT NULL, + data TEXT NOT NULL, + xml TEXT NOT NULL, + tags TEXT NOT NULL, + words TEXT NOT NULL, + dtstart DATETIME, + dtend DATETIME, + PRIMARY KEY(folder_id,msguid) +); + +CREATE INDEX ix_freebusy_uid2msguid ON kolab_cache_freebusy(folder_id,uid,msguid); + +INSERT INTO system (name, value) VALUES ('libkolab-version', '2015020600'); diff --git a/lib/drivers/kolab/plugins/libkolab/bin/modcache.sh b/lib/drivers/kolab/plugins/libkolab/bin/modcache.sh index 533fefd..8208b6c 100755 --- a/lib/drivers/kolab/plugins/libkolab/bin/modcache.sh +++ b/lib/drivers/kolab/plugins/libkolab/bin/modcache.sh @@ -1,235 +1,238 @@ #!/usr/bin/env php * * Copyright (C) 2012-2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ define('INSTALL_PATH', realpath('.') . '/' ); ini_set('display_errors', 1); if (!file_exists(INSTALL_PATH . 'program/include/clisetup.php')) die("Execute this from the Roundcube installation dir!\n\n"); require_once INSTALL_PATH . 'program/include/clisetup.php'; function print_usage() { - print "Usage: modcache.sh [OPTIONS] ACTION [USERNAME ARGS ...]\n"; + print "Usage: modcache.sh ACTION [OPTIONS] [USERNAME ARGS ...]\n"; print "Possible actions are: expunge, clear, prewarm\n"; print "-a, --all Clear/expunge all caches\n"; print "-h, --host IMAP host name\n"; print "-u, --user IMAP user name to authenticate\n"; print "-t, --type Object types to clear/expunge cache\n"; print "-l, --limit Limit the number of records to be expunged\n"; } // read arguments -$opts = get_opt(array( +$opts = rcube_utils::get_opt(array( 'a' => 'all', 'h' => 'host', 'u' => 'user', 'p' => 'password', 't' => 'type', 'l' => 'limit', 'v' => 'verbose', )); $opts['username'] = !empty($opts[1]) ? $opts[1] : $opts['user']; $action = $opts[0]; $rcmail = rcube::get_instance(rcube::INIT_WITH_DB | rcube::INIT_WITH_PLUGINS); // connect to database $db = $rcmail->get_dbh(); $db->db_connect('w'); if (!$db->is_connected() || $db->is_error()) die("No DB connection\n"); ini_set('display_errors', 1); +// All supported object types +$all_types = array('contact','configuration','event','file','journal','note','task'); + /* * Script controller */ switch (strtolower($action)) { /* * Clear/expunge all cache records */ case 'expunge': - $folder_types = $opts['type'] ? explode(',', $opts['type']) : array('contact','configuration','event','file','journal','note','task'); + $folder_types = $opts['type'] ? explode(',', $opts['type']) : $all_types; $folder_types_db = array_map(array($db, 'quote'), $folder_types); $expire = strtotime(!empty($opts[2]) ? $opts[2] : 'now - 10 days'); $sql_where = "type IN (" . join(',', $folder_types_db) . ")"; if ($opts['username']) { $sql_where .= ' AND resource LIKE ?'; } $sql_query = "DELETE FROM %s WHERE folder_id IN (SELECT folder_id FROM kolab_folders WHERE $sql_where) AND created <= " . $db->quote(date('Y-m-d 00:00:00', $expire)); if ($opts['limit']) { - $sql_query = ' LIMIT ' . intval($opts['limit']); + $sql_query .= ' LIMIT ' . intval($opts['limit']); } foreach ($folder_types as $type) { $table_name = 'kolab_cache_' . $type; $db->query(sprintf($sql_query, $table_name), resource_prefix($opts).'%'); echo $db->affected_rows() . " records deleted from '$table_name'\n"; } $db->query("UPDATE kolab_folders SET ctag='' WHERE $sql_where", resource_prefix($opts).'%'); break; case 'clear': - $folder_types = $opts['type'] ? explode(',', $opts['type']) : array('contact','configuration','event','file','journal','note','task'); + $folder_types = $opts['type'] ? explode(',', $opts['type']) : $all_types; $folder_types_db = array_map(array($db, 'quote'), $folder_types); if ($opts['all']) { $sql_query = "DELETE FROM kolab_folders WHERE 1"; } else if ($opts['username']) { $sql_query = "DELETE FROM kolab_folders WHERE type IN (" . join(',', $folder_types_db) . ") AND resource LIKE ?"; } if ($sql_query) { $db->query($sql_query, resource_prefix($opts).'%'); echo $db->affected_rows() . " records deleted from 'kolab_folders'\n"; } break; /* * Prewarm cache by synchronizing objects for the given user */ case 'prewarm': // make sure libkolab classes are loaded $rcmail->plugins->load_plugin('libkolab'); if (authenticate($opts)) { - $folder_types = $opts['type'] ? explode(',', $opts['type']) : array('contact','configuration','event','file','task'); + $folder_types = $opts['type'] ? explode(',', $opts['type']) : $all_types; foreach ($folder_types as $type) { // sync every folder of the given type foreach (kolab_storage::get_folders($type) as $folder) { echo "Synching " . $folder->name . " ($type) ... "; echo $folder->count($type) . "\n"; // also sync distribution lists in contact folders if ($type == 'contact') { echo "Synching " . $folder->name . " (distribution-list) ... "; echo $folder->count('distribution-list') . "\n"; } } } } else die("Authentication failed for " . $opts['user']); break; /** * Update the cache meta columns from the serialized/xml data * (might be run after a schema update) */ case 'update': // make sure libkolab classes are loaded $rcmail->plugins->load_plugin('libkolab'); - $folder_types = $opts['type'] ? explode(',', $opts['type']) : array('contact','configuration','event','file','task'); + $folder_types = $opts['type'] ? explode(',', $opts['type']) : $all_types; foreach ($folder_types as $type) { $class = 'kolab_storage_cache_' . $type; $sql_result = $db->query("SELECT folder_id FROM kolab_folders WHERE type=? AND synclock = 0", $type); while ($sql_result && ($sql_arr = $db->fetch_assoc($sql_result))) { $folder = new $class; $folder->select_by_id($sql_arr['folder_id']); echo "Updating " . $sql_arr['folder_id'] . " ($type) "; foreach ($folder->select() as $object) { $object['_formatobj']->to_array(); // load data $folder->save($object['_msguid'], $object, $object['_msguid']); echo "."; } echo "done.\n"; } } break; /* * Unknown action => show usage */ default: print_usage(); exit; } /** * Compose cache resource URI prefix for the given user credentials */ function resource_prefix($opts) { return 'imap://' . str_replace('%', '\\%', urlencode($opts['username'])) . '@' . $opts['host'] . '/'; } /** * Authenticate to the IMAP server with the given user credentials */ function authenticate(&$opts) { global $rcmail; // prompt for password if (empty($opts['password']) && ($opts['username'] || $opts['user'])) { $opts['password'] = prompt_silent("Password: "); } // simulate "login as" feature if ($opts['user'] && $opts['user'] != $opts['username']) $_POST['_loginas'] = $opts['username']; else if (empty($opts['user'])) $opts['user'] = $opts['username']; // let the kolab_auth plugin do its magic $auth = $rcmail->plugins->exec_hook('authenticate', array( 'host' => trim($opts['host']), 'user' => trim($opts['user']), 'pass' => $opts['password'], 'cookiecheck' => false, 'valid' => !empty($opts['user']) && !empty($opts['host']), )); if ($auth['valid']) { $storage = $rcmail->get_storage(); if ($storage->connect($auth['host'], $auth['user'], $auth['pass'], 143, false)) { if ($opts['verbose']) echo "IMAP login succeeded.\n"; if (($user = rcube_user::query($opts['username'], $auth['host'])) && $user->ID) $rcmail->user = $user; } else die("Login to IMAP server failed!\n"); } else { die("Invalid login credentials!\n"); } return $auth['valid']; } diff --git a/lib/drivers/kolab/plugins/libkolab/bin/randomcontacts.sh b/lib/drivers/kolab/plugins/libkolab/bin/randomcontacts.sh index e4a820c..e6154bb 100755 --- a/lib/drivers/kolab/plugins/libkolab/bin/randomcontacts.sh +++ b/lib/drivers/kolab/plugins/libkolab/bin/randomcontacts.sh @@ -1,181 +1,181 @@ #!/usr/bin/env php * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ define('INSTALL_PATH', realpath('.') . '/' ); ini_set('display_errors', 1); if (!file_exists(INSTALL_PATH . 'program/include/clisetup.php')) die("Execute this from the Roundcube installation dir!\n\n"); require_once INSTALL_PATH . 'program/include/clisetup.php'; function print_usage() { print "Usage: randomcontacts.sh [OPTIONS] USERNAME FOLDER\n"; print "Create random contact that for then given user in the specified folder.\n"; print "-n, --num Number of contacts to be created, defaults to 50\n"; print "-h, --host IMAP host name\n"; print "-p, --password IMAP user password\n"; } // read arguments -$opts = get_opt(array( +$opts = rcube_utils::get_opt(array( 'n' => 'num', 'h' => 'host', 'u' => 'user', 'p' => 'pass', 'v' => 'verbose', )); $opts['username'] = !empty($opts[0]) ? $opts[0] : $opts['user']; $opts['folder'] = $opts[1]; $rcmail = rcube::get_instance(rcube::INIT_WITH_DB | rcube::INIT_WITH_PLUGINS); $rcmail->plugins->load_plugins(array('libkolab')); ini_set('display_errors', 1); if (empty($opts['host'])) { $opts['host'] = $rcmail->config->get('default_host'); if (is_array($opts['host'])) // not unique $opts['host'] = null; } if (empty($opts['username']) || empty($opts['folder']) || empty($opts['host'])) { print_usage(); exit; } // prompt for password if (empty($opts['pass'])) { $opts['pass'] = rcube_utils::prompt_silent("Password: "); } // parse $host URL $a_host = parse_url($opts['host']); if ($a_host['host']) { $host = $a_host['host']; $imap_ssl = (isset($a_host['scheme']) && in_array($a_host['scheme'], array('ssl','imaps','tls'))) ? TRUE : FALSE; $imap_port = isset($a_host['port']) ? $a_host['port'] : ($imap_ssl ? 993 : 143); } else { $host = $opts['host']; $imap_port = 143; } // instantiate IMAP class $IMAP = $rcmail->get_storage(); // try to connect to IMAP server if ($IMAP->connect($host, $opts['username'], $opts['pass'], $imap_port, $imap_ssl)) { print "IMAP login successful.\n"; $user = rcube_user::query($opts['username'], $host); $rcmail->user = $user ?: new rcube_user(null, array('username' => $opts['username'], 'host' => $host)); } else { die("IMAP login failed for user " . $opts['username'] . " @ $host\n"); } // get contacts folder $folder = kolab_storage::get_folder($opts['folder']); if (!$folder || empty($folder->type)) { die("Invalid Address Book " . $opts['folder'] . "\n"); } $format = new kolab_format_contact; $num = $opts['num'] ? intval($opts['num']) : 50; echo "Creating $num contacts in " . $folder->get_resource_uri() . "\n"; for ($i=0; $i < $num; $i++) { // generate random names $contact = array( 'surname' => random_string(rand(1,2)), 'firstname' => random_string(rand(1,2)), 'organization' => random_string(rand(0,2)), 'profession' => random_string(rand(1,2)), 'email' => array(), 'phone' => array(), 'address' => array(), 'notes' => random_string(rand(10,200)), ); // randomly add email addresses $em = rand(1,3); for ($e=0; $e < $em; $e++) { $type = array_rand($format->emailtypes); $contact['email'][] = array( 'address' => strtolower(random_string(1) . '@' . random_string(1) . '.tld'), 'type' => $type, ); } // randomly add phone numbers $ph = rand(1,4); for ($p=0; $p < $ph; $p++) { $type = array_rand($format->phonetypes); $contact['phone'][] = array( 'number' => '+'.rand(2,8).rand(1,9).rand(1,9).rand(0,9).rand(0,9).rand(0,9).rand(0,9).rand(0,9).rand(0,9).rand(0,9).rand(0,9), 'type' => $type, ); } // randomly add addresses $ad = rand(0,2); for ($a=0; $a < $ad; $a++) { $type = array_rand($format->addresstypes); $contact['address'][] = array( 'street' => random_string(rand(1,3)), 'locality' => random_string(rand(1,2)), 'code' => rand(1000, 89999), 'country' => random_string(1), 'type' => $type, ); } $contact['name'] = $contact['firstname'] . ' ' . $contact['surname']; if ($folder->save($contact, 'contact')) { echo "."; } else { echo "x"; break; // abort on error } } echo " done.\n"; function random_string($len) { $words = explode(" ", "The Hough transform is named after Paul Hough who patented the method in 1962. It is a technique which can be used to isolate features of a particular shape within an image. Because it requires that the desired features be specified in some parametric form, the classical Hough transform is most commonly used for the de- tection of regular curves such as lines, circles, ellipses, etc. A generalized Hough transform can be employed in applications where a simple analytic description of a features is not possible. Due to the computational complexity of the generalized Hough algorithm, we restrict the main focus of this discussion to the classical Hough transform. Despite its domain restrictions, the classical Hough transform hereafter referred to without the classical prefix retains many applications, as most manufac- tured parts and many anatomical parts investigated in medical imagery contain feature boundaries which can be described by regular curves. The main advantage of the Hough transform technique is that it is tolerant of gaps in feature boundary descriptions and is relatively unaffected by image noise."); for ($i = 0; $i < $len; $i++) { $str .= $words[rand(0,count($words)-1)] . " "; } return rtrim($str); } diff --git a/lib/drivers/kolab/plugins/libkolab/bin/readcache.sh b/lib/drivers/kolab/plugins/libkolab/bin/readcache.sh index 7e6a3a3..238741f 100755 --- a/lib/drivers/kolab/plugins/libkolab/bin/readcache.sh +++ b/lib/drivers/kolab/plugins/libkolab/bin/readcache.sh @@ -1,150 +1,150 @@ #!/usr/bin/env php * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ define('INSTALL_PATH', realpath('.') . '/' ); ini_set('display_errors', 1); libxml_use_internal_errors(true); if (!file_exists(INSTALL_PATH . 'program/include/clisetup.php')) die("Execute this from the Roundcube installation dir!\n\n"); require_once INSTALL_PATH . 'program/include/clisetup.php'; function print_usage() { print "Usage: readcache.sh [OPTIONS] FOLDER\n"; print "-h, --host IMAP host name\n"; print "-l, --limit Limit the number of records to be listed\n"; } // read arguments -$opts = get_opt(array( +$opts = rcube_utils::get_opt(array( 'h' => 'host', 'l' => 'limit', 'v' => 'verbose', )); $folder = $opts[0]; $imap_host = $opts['host']; $rcmail = rcube::get_instance(rcube::INIT_WITH_DB | rcube::INIT_WITH_PLUGINS); if (empty($imap_host)) { $default_host = $rcmail->config->get('default_host'); if (is_array($default_host)) { list($k,$v) = each($default_host); $imap_host = is_numeric($k) ? $v : $k; } else { $imap_host = $default_host; } // strip protocol prefix $imap_host = preg_replace('!^[a-z]+://!', '', $imap_host); } if (empty($folder) || empty($imap_host)) { print_usage(); exit; } // connect to database $db = $rcmail->get_dbh(); $db->db_connect('r'); if (!$db->is_connected() || $db->is_error()) die("No DB connection\n"); // resolve folder_id if (!is_numeric($folder)) { if (strpos($folder, '@')) { list($mailbox, $domain) = explode('@', $folder); list($username, $subpath) = explode('/', preg_replace('!^user/!', '', $mailbox), 2); $folder_uri = 'imap://' . urlencode($username.'@'.$domain) . '@' . $imap_host . '/' . $subpath; } else { die("Invalid mailbox identifier! Example: user/john.doe/Calendar@example.org\n"); } print "Resolving folder $folder_uri..."; $sql_result = $db->query('SELECT * FROM `kolab_folders` WHERE `resource`=?', $folder_uri); if ($sql_result && ($folder_data = $db->fetch_assoc($sql_result))) { $folder_id = $folder_data['folder_id']; print $folder_id; } print "\n"; } else { $folder_id = intval($folder); $sql_result = $db->query('SELECT * FROM `kolab_folders` WHERE `folder_id`=?', $folder_id); if ($sql_result) { $folder_data = $db->fetch_assoc($sql_result); } } if (empty($folder_data)) { die("Can't find cache mailbox for '$folder'\n"); } print "Querying cache for folder $folder_id ($folder_data[type])...\n"; $extra_cols = array( 'event' => array('dtstart','dtend'), 'contact' => array('type'), ); $cache_table = $db->table_name('kolab_cache_' . $folder_data['type']); $extra_cols_ = $extra_cols[$folder_data['type']] ?: array(); $sql_arr = $db->fetch_assoc($db->query("SELECT COUNT(*) as cnt FROM `$cache_table` WHERE `folder_id`=?", intval($folder_id))); print "CTag = " . $folder_data['ctag'] . "\n"; print "Lock = " . $folder_data['synclock'] . "\n"; print "Count = " . $sql_arr['cnt'] . "\n"; print "----------------------------------------------------------------------------------\n"; print "\t\t\t\t\t"; print join("\t", array_map(function($c) { return '<' . strtoupper($c) . '>'; }, $extra_cols_)); print "\n----------------------------------------------------------------------------------\n"; $result = $db->limitquery("SELECT * FROM `$cache_table` WHERE `folder_id`=?", 0, $opts['limit'], intval($folder_id)); while ($result && ($sql_arr = $db->fetch_assoc($result))) { print $sql_arr['msguid'] . "\t" . $sql_arr['uid'] . "\t" . $sql_arr['changed']; // try to unserialize data block $object = @unserialize(@base64_decode($sql_arr['data'])); print "\t" . ($object === false ? 'FAIL!' : ($object['uid'] == $sql_arr['uid'] ? 'OK' : '!!!')); // check XML validity $xml = simplexml_load_string($sql_arr['xml']); print "\t" . ($xml === false ? 'FAIL!' : 'OK'); // print extra cols array_walk($extra_cols_, function($c) use ($sql_arr) { print "\t" . $sql_arr[$c]; }); print "\n"; } print "----------------------------------------------------------------------------------\n"; echo "Done.\n"; diff --git a/lib/drivers/kolab/plugins/libkolab/composer.json b/lib/drivers/kolab/plugins/libkolab/composer.json index b458df6..41b70ea 100644 --- a/lib/drivers/kolab/plugins/libkolab/composer.json +++ b/lib/drivers/kolab/plugins/libkolab/composer.json @@ -1,30 +1,31 @@ { "name": "kolab/libkolab", "type": "roundcube-plugin", "description": "Plugin to setup a basic environment for the interaction with a Kolab server.", - "homepage": "http://git.kolab.org/roundcubemail-plugins-kolab/", + "homepage": "https://git.kolab.org/diffusion/RPK/", "license": "AGPLv3", - "version": "3.2.3", + "version": "3.2.8", "authors": [ { "name": "Thomas Bruederli", "email": "bruederli@kolabsys.com", "role": "Lead" }, { - "name": "Alensader Machniak", + "name": "Aleksander Machniak", "email": "machniak@kolabsys.com", "role": "Developer" } ], "repositories": [ { "type": "composer", "url": "http://plugins.roundcube.net" } ], "require": { "php": ">=5.3.0", - "roundcube/plugin-installer": ">=0.1.3" + "roundcube/plugin-installer": ">=0.1.3", + "caxy/php-htmldiff": "dev-master" } } diff --git a/lib/drivers/kolab/plugins/libkolab/config.inc.php.dist b/lib/drivers/kolab/plugins/libkolab/config.inc.php.dist index 7efa8d1..a2c15e8 100644 --- a/lib/drivers/kolab/plugins/libkolab/config.inc.php.dist +++ b/lib/drivers/kolab/plugins/libkolab/config.inc.php.dist @@ -1,62 +1,75 @@ /freebusy +// Defaults to /freebusy or https:///freebusy $config['kolab_freebusy_server'] = null; // Enables listing of only subscribed folders. This e.g. will limit // folders in calendar view or available addressbooks $config['kolab_use_subscriptions'] = false; // List any of 'personal','shared','other' namespaces to be excluded from groupware folder listing // example: array('other'); $config['kolab_skip_namespace'] = null; // Enables the use of displayname folder annotations as introduced in KEP:? // for displaying resource folder names (experimental!) $config['kolab_custom_display_names'] = false; // Configuration of HTTP requests. // See http://pear.php.net/manual/en/package.http.http-request2.config.php // for list of supported configuration options (array keys) $config['kolab_http_request'] = array(); // When kolab_cache is enabled Roundcube's messages cache will be redundant // when working on kolab folders. Here we can: // 2 - bypass messages/indexes cache completely // 1 - bypass only messages, but use index cache $config['kolab_messages_cache_bypass'] = 0; +// These event properties contribute to a significant revision to the calendar component +// and if changed will increment the sequence number relevant for scheduling according to RFC 5545 +$config['kolab_event_scheduling_properties'] = array('start', 'end', 'allday', 'recurrence', 'location', 'status', 'cancelled'); + +// These task properties contribute to a significant revision to the calendar component +// and if changed will increment the sequence number relevant for scheduling according to RFC 5545 +$config['kolab_task_scheduling_properties'] = array('start', 'due', 'summary', 'status'); + // LDAP directory to find avilable users for folder sharing. // Either contains an array with LDAP addressbook configuration or refers to entry in $config['ldap_public']. // If not specified, the configuraton from 'kolab_auth_addressbook' will be used. // Should be provided for multi-domain setups with placeholders like %dc, %d, %u, %fu or %dn. $config['kolab_users_directory'] = null; // Filter to be used for resolving user folders in LDAP. // Defaults to the 'kolab_auth_filter' configuration option. $config['kolab_users_filter'] = '(&(objectclass=kolabInetOrgPerson)(|(uid=%u)(mail=%fu)))'; // Which property of the LDAP user record to use for user folder mapping in IMAP. // Defaults to the 'kolab_auth_login' configuration option. $config['kolab_users_id_attrib'] = null; // Use these attributes when searching users in LDAP $config['kolab_users_search_attrib'] = array('cn','mail','alias'); // JSON-RPC endpoint configuration of the Bonnie web service providing historic data for groupware objects $config['kolab_bonnie_api'] = array( 'uri' => 'https://:8080/api/rpc', 'user' => 'webclient', 'pass' => 'Welcome2KolabSystems', 'secret' => '8431f191707fffffff00000000cccc', 'debug' => true, // logs requests/responses to /bonnie + 'timeout' => 30, ); diff --git a/lib/drivers/kolab/plugins/libkolab/js/audittrail.js b/lib/drivers/kolab/plugins/libkolab/js/audittrail.js new file mode 100644 index 0000000..84fdf61 --- /dev/null +++ b/lib/drivers/kolab/plugins/libkolab/js/audittrail.js @@ -0,0 +1,269 @@ +/** + * Kolab groupware audit trail utilities + * + * @author Thomas Bruederli + * + * @licstart The following is the entire license notice for the + * JavaScript code in this file. + * + * Copyright (C) 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 . + * + * @licend The above is the entire license notice + * for the JavaScript code in this file. + */ + +var libkolab_audittrail = {} + +libkolab_audittrail.quote_html = function(str) +{ + return String(str).replace(//g, '>').replace(/"/g, '"'); +}; + + +// show object changelog in a dialog +libkolab_audittrail.object_history_dialog = function(p) +{ + // render dialog + var $dialog = $(p.container); + + // close show dialog first + if ($dialog.is(':ui-dialog')) + $dialog.dialog('close'); + + // hide and reset changelog table + $dialog.find('div.notfound-message').remove(); + $dialog.find('.changelog-table').show().children('tbody') + .html('' + rcmail.gettext('loading') + ''); + + // open jquery UI dialog + $dialog.dialog({ + modal: false, + resizable: true, + closeOnEscape: true, + title: p.title, + open: function() { + $dialog.attr('aria-hidden', 'false'); + }, + close: function() { + $dialog.dialog('destroy').attr('aria-hidden', 'true').hide(); + }, + buttons: [ + { + text: rcmail.gettext('close'), + click: function() { $dialog.dialog('close'); }, + autofocus: true + } + ], + minWidth: 450, + width: 650, + height: 350, + minHeight: 200 + }) + .show().children('.compare-button').hide(); + + // initialize event handlers for history dialog UI elements + if (!$dialog.data('initialized')) { + // compare button + $dialog.find('.compare-button input').click(function(e) { + var rev1 = $dialog.find('.changelog-table input.diff-rev1:checked').val(), + rev2 = $dialog.find('.changelog-table input.diff-rev2:checked').val(); + + if (rev1 && rev2 && rev1 != rev2) { + // swap revisions if the user got it wrong + if (rev1 > rev2) { + var tmp = rev2; + rev2 = rev1; + rev1 = tmp; + } + + if (p.comparefunc) { + p.comparefunc(rev1, rev2); + } + } + else { + alert('Invalid selection!') + } + + if (!rcube_event.is_keyboard(e) && this.blur) { + this.blur(); + } + return false; + }); + + // delegate handlers for list actions + $dialog.find('.changelog-table tbody').on('click', 'td.actions a', function(e) { + var link = $(this), + action = link.hasClass('restore') ? 'restore' : 'show', + event = $('#eventhistory').data('event'), + rev = link.attr('data-rev'); + + // ignore clicks on first row (current revision) + if (link.closest('tr').hasClass('first')) { + return false; + } + + // let the user confirm the restore action + if (action == 'restore' && !confirm(rcmail.gettext('revisionrestoreconfirm', p.module).replace('$rev', rev))) { + return false; + } + + if (p.listfunc) { + p.listfunc(action, rev); + } + + if (!rcube_event.is_keyboard(e) && this.blur) { + this.blur(); + } + return false; + }) + .on('click', 'input.diff-rev1', function(e) { + if (!this.checked) return true; + + var rev1 = this.value, selection_valid = false; + $dialog.find('.changelog-table input.diff-rev2').each(function(i, elem) { + $(elem).prop('disabled', elem.value <= rev1); + if (elem.checked && elem.value > rev1) { + selection_valid = true; + } + }); + if (!selection_valid) { + $dialog.find('.changelog-table input.diff-rev2:not([disabled])').last().prop('checked', true); + } + }); + + $dialog.addClass('changelog-dialog').data('initialized', true); + } + + return $dialog; +}; + +// callback from server with changelog data +libkolab_audittrail.render_changelog = function(data, object, folder) +{ + var Q = libkolab_audittrail.quote_html; + + var $dialog = $('.changelog-dialog') + if (data === false || !data.length) { + return false; + } + + var i, change, accessible, op_append, + first = data.length - 1, last = 0, + is_writeable = !!folder.editable, + op_labels = { + RECEIVE: 'actionreceive', + APPEND: 'actionappend', + MOVE: 'actionmove', + DELETE: 'actiondelete', + READ: 'actionread', + FLAGSET: 'actionflagset', + FLAGCLEAR: 'actionflagclear' + }, + actions = ' ' + + (is_writeable ? '' : ''), + tbody = $dialog.find('.changelog-table tbody').html(''); + + for (i=first; i >= 0; i--) { + change = data[i]; + accessible = change.date && change.user; + + if (change.op == 'MOVE' && change.mailbox) { + op_append = ' ⇢ ' + change.mailbox; + } + else if ((change.op == 'FLAGSET' || change.op == 'FLAGCLEAR') && change.flags) { + op_append = ': ' + change.flags; + } + else { + op_append = ''; + } + + $('') + .append('' + (accessible && change.op != 'DELETE' ? + ' '+ + '' + : '')) + .append('' + Q(i+1) + '') + .append('' + Q(change.date || '') + '') + .append('' + Q(change.user || 'undisclosed') + '') + .append('' + Q(rcmail.gettext(op_labels[change.op] || '', 'libkolab') + op_append) + '') + .append('' + (accessible && change.op != 'DELETE' ? actions.replace(/\{rev\}/g, change.rev) : '') + '') + .appendTo(tbody); + } + + if (first > 0) { + $dialog.find('.compare-button').fadeIn(200); + $dialog.find('.changelog-table tr.last input.diff-rev1').click(); + } + + // set dialog size according to content + libkolab_audittrail.dialog_resize($dialog.get(0), $dialog.height() + 15, 600); + + return $dialog; +}; + +// resize and reposition (center) the dialog window +libkolab_audittrail.dialog_resize = function(id, height, width) +{ + var win = $(window), w = win.width(), h = win.height(); + $(id).dialog('option', { height: Math.min(h-20, height+130), width: Math.min(w-20, width+50) }) + .dialog('option', 'position', ['center', 'center']); // only works in a separate call (!?) +}; + + +// register handlers for mail message history +window.rcmail && rcmail.addEventListener('init', function(e) { + var loading_lock; + + if (rcmail.env.task == 'mail') { + rcmail.register_command('kolab-mail-history', function() { + var dialog, uid = rcmail.get_single_uid(), rec = { uid: uid, mbox: rcmail.get_message_mailbox(uid) }; + if (!uid || !window.libkolab_audittrail) { + return false; + } + + // render dialog + $dialog = libkolab_audittrail.object_history_dialog({ + module: 'libkolab', + container: '#mailmessagehistory', + title: rcmail.gettext('objectchangelog','libkolab') + }); + + $dialog.data('rec', rec); + + // fetch changelog data + loading_lock = rcmail.set_busy(true, 'loading', loading_lock); + rcmail.http_post('plugin.message-changelog', { _uid: rec.uid, _mbox: rec.mbox }, loading_lock); + + }, rcmail.env.action == 'show'); + + rcmail.addEventListener('plugin.message_render_changelog', function(data) { + var $dialog = $('#mailmessagehistory'), + rec = $dialog.data('rec'); + + if (data === false || !data.length || !rec) { + // display 'unavailable' message + $('
' + rcmail.gettext('objectchangelognotavailable','libkolab') + '
') + .insertBefore($dialog.find('.changelog-table').hide()); + return; + } + + data.module = 'libkolab'; + libkolab_audittrail.render_changelog(data, rec, {}); + }); + + rcmail.env.message_commands.push('kolab-mail-history'); + } +}); diff --git a/lib/drivers/kolab/plugins/libkolab/js/folderlist.js b/lib/drivers/kolab/plugins/libkolab/js/folderlist.js index 62a60ef..64f8a35 100644 --- a/lib/drivers/kolab/plugins/libkolab/js/folderlist.js +++ b/lib/drivers/kolab/plugins/libkolab/js/folderlist.js @@ -1,350 +1,353 @@ /** * Kolab groupware folders treelist widget * * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ function kolab_folderlist(node, p) { // extends treelist.js rcube_treelist_widget.call(this, node, p); // private vars var me = this; var search_results; var search_results_widget; var search_results_container; var listsearch_request; var search_messagebox; var Q = rcmail.quote_html; // render the results for folderlist search function render_search_results(results) { if (results.length) { // create treelist widget to present the search results if (!search_results_widget) { var list_id = (me.container.attr('id') || p.id_prefix || '0') search_results_container = $('
') .html(p.search_title ? '

' + p.search_title + '

' : '') .insertAfter(me.container); search_results_widget = new rcube_treelist_widget('
    ', { id_prefix: p.id_prefix, id_encode: p.id_encode, id_decode: p.id_decode, selectable: false }); // copy classes from main list search_results_widget.container.addClass(me.container.attr('class')).attr('aria-labelledby', 'st:' + list_id); // register click handler on search result's checkboxes to select the given item for listing search_results_widget.container .appendTo(search_results_container) .on('click', 'input[type=checkbox], a.subscribed, span.subscribed', function(e) { var node, has_children, li = $(this).closest('li'), id = li.attr('id').replace(new RegExp('^'+p.id_prefix), ''); if (p.id_decode) id = p.id_decode(id); node = search_results_widget.get_node(id); has_children = node.children && node.children.length; e.stopPropagation(); e.bubbles = false; // activate + subscribe if ($(e.target).hasClass('subscribed')) { search_results[id].subscribed = true; $(e.target).attr('aria-checked', 'true'); li.children().first() .toggleClass('subscribed') .find('input[type=checkbox]').get(0).checked = true; if (has_children && search_results[id].group == 'other user') { li.find('ul li > div').addClass('subscribed') .find('a.subscribed').attr('aria-checked', 'true');; } } else if (!this.checked) { return; } // copy item to the main list add_result2list(id, li, true); if (has_children) { li.find('input[type=checkbox]').first().prop('disabled', true).prop('checked', true); li.find('a.subscribed, span.subscribed').first().hide(); } else { li.remove(); } // set partial subscription status if (search_results[id].subscribed && search_results[id].parent && search_results[id].group == 'other') { parent_subscription_status($(me.get_item(id, true))); } // set focus to cloned checkbox if (rcube_event.is_keyboard(e)) { $(me.get_item(id, true)).find('input[type=checkbox]').first().focus(); } }) .on('click', function(e) { var prop, id = String($(e.target).closest('li').attr('id')).replace(new RegExp('^'+p.id_prefix), ''); if (p.id_decode) id = p.id_decode(id); + if (!rcube_event.is_keyboard(e) && e.target.blur) + e.target.blur(); + // forward event if (prop = search_results[id]) { e.data = prop; if (me.triggerEvent('click-item', e) === false) { e.stopPropagation(); return false; } } }); } // add results to list for (var prop, item, i=0; i < results.length; i++) { prop = results[i]; item = $(prop.html); search_results[prop.id] = prop; search_results_widget.insert({ id: prop.id, classes: [ prop.group || '' ], html: item, collapsed: true, virtual: prop.virtual }, prop.parent); // disable checkbox if item already exists in main list if (me.get_node(prop.id) && !me.get_node(prop.id).virtual) { item.find('input[type=checkbox]').first().prop('disabled', true).prop('checked', true); item.find('a.subscribed, span.subscribed').hide(); } } search_results_container.show(); } } // helper method to (recursively) add a search result item to the main list widget function add_result2list(id, li, active) { var node = search_results_widget.get_node(id), prop = search_results[id], parent_id = prop.parent || null, has_children = node.children && node.children.length, dom_node = has_children ? li.children().first().clone(true, true) : li.children().first(), childs = []; // find parent node and insert at the right place if (parent_id && me.get_node(parent_id)) { dom_node.children('span,a').first().html(Q(prop.editname || prop.listname)); } else if (parent_id && search_results[parent_id]) { // copy parent tree from search results add_result2list(parent_id, $(search_results_widget.get_item(parent_id)), false); } else if (parent_id) { // use full name for list display dom_node.children('span,a').first().html(Q(prop.name)); } // replace virtual node with a real one if (me.get_node(id)) { $(me.get_item(id, true)).children().first() .replaceWith(dom_node) .removeClass('virtual'); } else { // copy childs, too if (has_children && prop.group == 'other user') { for (var cid, j=0; j < node.children.length; j++) { if ((cid = node.children[j].id) && search_results[cid]) { childs.push(search_results_widget.get_node(cid)); } } } // move this result item to the main list widget me.insert({ id: id, classes: [ prop.group || '' ], virtual: prop.virtual, html: dom_node, level: node.level, collapsed: true, children: childs }, parent_id, prop.group); } delete prop.html; prop.active = active; me.triggerEvent('insert-item', { id: id, data: prop, item: li }); // register childs, too if (childs.length) { for (var cid, j=0; j < node.children.length; j++) { if ((cid = node.children[j].id) && search_results[cid]) { prop = search_results[cid]; delete prop.html; prop.active = false; me.triggerEvent('insert-item', { id: cid, data: prop }); } } } } // update the given item's parent's (partial) subscription state function parent_subscription_status(li) { var top_li = li.closest(me.container.children('li')), all_childs = $('li > div:not(.treetoggle)', top_li), subscribed = all_childs.filter('.subscribed').length; if (subscribed == 0) { top_li.children('div:first').removeClass('subscribed partial'); } else { top_li.children('div:first') .addClass('subscribed')[subscribed < all_childs.length ? 'addClass' : 'removeClass']('partial'); } } // do some magic when search is performed on the widget this.addEventListener('search', function(search) { // hide search results if (search_results_widget) { search_results_container.hide(); search_results_widget.reset(); } search_results = {}; if (search_messagebox) rcmail.hide_message(search_messagebox); // send search request(s) to server if (search.query && search.execute) { // require a minimum length for the search string if (rcmail.env.autocomplete_min_length && search.query.length < rcmail.env.autocomplete_min_length && search.query != '*') { search_messagebox = rcmail.display_message( rcmail.get_label('autocompletechars').replace('$min', rcmail.env.autocomplete_min_length)); return; } if (listsearch_request) { // ignore, let the currently running request finish if (listsearch_request.query == search.query) { return; } else { // cancel previous search request rcmail.multi_thread_request_abort(listsearch_request.id); listsearch_request = null; } } var sources = p.search_sources || [ 'folders' ]; var reqid = rcmail.multi_thread_http_request({ items: sources, threads: rcmail.env.autocomplete_threads || 1, action: p.search_action || 'listsearch', postdata: { action:'search', q:search.query, source:'%s' }, lock: rcmail.display_message(rcmail.get_label('searching'), 'loading'), onresponse: render_search_results, whendone: function(data){ listsearch_request = null; me.triggerEvent('search-complete', data); } }); listsearch_request = { id:reqid, query:search.query }; } else if (!search.query && listsearch_request) { rcmail.multi_thread_request_abort(listsearch_request.id); listsearch_request = null; } }); this.container.on('click', 'a.subscribed, span.subscribed', function(e) { var li = $(this).closest('li'), id = li.attr('id').replace(new RegExp('^'+p.id_prefix), ''), div = li.children().first(), is_subscribed; if (me.is_search()) { id = id.replace(/--xsR$/, ''); li = $(me.get_item(id, true)); div = $(div).add(li.children().first()); } if (p.id_decode) id = p.id_decode(id); div.toggleClass('subscribed'); is_subscribed = div.hasClass('subscribed'); $(this).attr('aria-checked', is_subscribed ? 'true' : 'false'); me.triggerEvent('subscribe', { id: id, subscribed: is_subscribed, item: li }); // update subscribe state of all 'virtual user' child folders if (li.hasClass('other user')) { $('ul li > div', li).each(function() { $(this)[is_subscribed ? 'addClass' : 'removeClass']('subscribed'); $('.subscribed', div).attr('aria-checked', is_subscribed ? 'true' : 'false'); }); div.removeClass('partial'); } // propagate subscription state to parent 'virtual user' folder else if (li.closest('li.other.user').length) { parent_subscription_status(li); } e.stopPropagation(); return false; }); this.container.on('click', 'a.remove', function(e) { var li = $(this).closest('li'), id = li.attr('id').replace(new RegExp('^'+p.id_prefix), ''); if (me.is_search()) { id = id.replace(/--xsR$/, ''); li = $(me.get_item(id, true)); } if (p.id_decode) id = p.id_decode(id); me.triggerEvent('remove', { id: id, item: li }); e.stopPropagation(); return false; }); } // link prototype from base class kolab_folderlist.prototype = rcube_treelist_widget.prototype; diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_bonnie_api.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_bonnie_api.php index e8ac131..6905dca 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_bonnie_api.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_bonnie_api.php @@ -1,82 +1,97 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_bonnie_api { public $ready = false; private $config = array(); private $client = null; /** * Default constructor */ public function __construct($config) { $this->config = $config; - $this->client = new kolab_bonnie_api_client($config['uri'], $config['timeout'] ?: 5, (bool)$config['debug']); + $this->client = new kolab_bonnie_api_client($config['uri'], $config['timeout'] ?: 30, (bool)$config['debug']); $this->client->set_secret($config['secret']); $this->client->set_authentication($config['user'], $config['pass']); $this->client->set_request_user(rcube::get_instance()->get_user_name()); $this->ready = !empty($config['secret']) && !empty($config['user']) && !empty($config['pass']); } /** * Wrapper function for .changelog() API call */ - public function changelog($type, $uid, $mailbox=null) + public function changelog($type, $uid, $mailbox, $msguid=null) { - return $this->client->execute($type.'.changelog', array('uid' => $uid, 'mailbox' => $mailbox)); + return $this->client->execute($type.'.changelog', array('uid' => $uid, 'mailbox' => $mailbox, 'msguid' => $msguid)); } /** * Wrapper function for .diff() API call */ - public function diff($type, $uid, $rev, $mailbox=null) + public function diff($type, $uid, $rev1, $rev2, $mailbox, $msguid=null, $instance=null) { - return $this->client->execute($type.'.diff', array('uid' => $uid, 'rev' => $rev, 'mailbox' => $mailbox)); + return $this->client->execute($type.'.diff', array( + 'uid' => $uid, + 'rev1' => $rev1, + 'rev2' => $rev2, + 'mailbox' => $mailbox, + 'msguid' => $msguid, + 'instance' => $instance, + )); } /** * Wrapper function for .get() API call */ - public function get($type, $uid, $rev, $mailbox=null) + public function get($type, $uid, $rev, $mailbox, $msguid=null) { - return $this->client->execute($type.'.get', array('uid' => $uid, 'rev' => intval($rev), 'mailbox' => $mailbox)); + return $this->client->execute($type.'.get', array('uid' => $uid, 'rev' => $rev, 'mailbox' => $mailbox, 'msguid' => $msguid)); + } + + /** + * Wrapper function for .rawdata() API call + */ + public function rawdata($type, $uid, $rev, $mailbox, $msguid=null) + { + return $this->client->execute($type.'.rawdata', array('uid' => $uid, 'rev' => $rev, 'mailbox' => $mailbox, 'msguid' => $msguid)); } /** * Generic wrapper for direct API calls */ public function _execute($method, $params = array()) { return $this->client->execute($method, $params); } } \ No newline at end of file diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_date_recurrence.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_date_recurrence.php index 06dd331..f2483c9 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_date_recurrence.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_date_recurrence.php @@ -1,135 +1,139 @@ * * 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_date_recurrence { private /* EventCal */ $engine; private /* kolab_format_xcal */ $object; private /* DateTime */ $start; private /* DateTime */ $next; private /* cDateTime */ $cnext; private /* DateInterval */ $duration; /** * Default constructor * * @param array The Kolab object to operate on */ function __construct($object) { $data = $object->to_array(); $this->object = $object; $this->engine = $object->to_libcal(); $this->start = $this->next = $data['start']; $this->cnext = kolab_format::get_datetime($this->next); if (is_object($data['start']) && is_object($data['end'])) $this->duration = $data['start']->diff($data['end']); else $this->duration = new DateInterval('PT' . ($data['end'] - $data['start']) . 'S'); } /** * Get date/time of the next occurence of this event * * @param boolean Return a Unix timestamp instead of a DateTime object * @return mixed DateTime object/unix timestamp or False if recurrence ended */ public function next_start($timestamp = false) { $time = false; if ($this->engine && $this->next) { if (($cnext = new cDateTime($this->engine->getNextOccurence($this->cnext))) && $cnext->isValid()) { $next = kolab_format::php_datetime($cnext); $time = $timestamp ? $next->format('U') : $next; $this->cnext = $cnext; $this->next = $next; } } return $time; } /** * Get the next recurring instance of this event * * @return mixed Array with event properties or False if recurrence ended */ public function next_instance() { if ($next_start = $this->next_start()) { $next_end = clone $next_start; $next_end->add($this->duration); $next = $this->object->to_array(); - $next['recurrence_id'] = $next_start->format('Y-m-d'); $next['start'] = $next_start; $next['end'] = $next_end; + + $recurrence_id_format = libkolab::recurrence_id_format($next); + $next['recurrence_date'] = clone $next_start; + $next['_instance'] = $next_start->format($recurrence_id_format); + unset($next['_formatobj']); return $next; } return false; } /** * Get the end date of the occurence of this recurrence cycle * * @return DateTime|bool End datetime of the last event or False if recurrence exceeds limit */ public function end() { $event = $this->object->to_array(); // recurrence end date is given if ($event['recurrence']['UNTIL'] instanceof DateTime) { return $event['recurrence']['UNTIL']; } // let libkolab do the work if ($this->engine && ($cend = $this->engine->getLastOccurrence()) && ($end_dt = kolab_format::php_datetime(new cDateTime($cend)))) { return $end_dt; } // determine a reasonable end date if none given if (!$event['recurrence']['COUNT'] && $event['end'] instanceof DateTime) { switch ($event['recurrence']['FREQ']) { case 'YEARLY': $intvl = 'P100Y'; break; case 'MONTHLY': $intvl = 'P20Y'; break; default: $intvl = 'P10Y'; break; } $end_dt = clone $event['end']; $end_dt->add(new DateInterval($intvl)); return $end_dt; } return false; } } diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_format.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_format.php index 625483b..5041dd3 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_format.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_format.php @@ -1,699 +1,707 @@ * * 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 . */ abstract class kolab_format { public static $timezone; public /*abstract*/ $CTYPE; public /*abstract*/ $CTYPEv2; protected /*abstract*/ $objclass; protected /*abstract*/ $read_func; protected /*abstract*/ $write_func; protected $obj; protected $data; protected $xmldata; protected $xmlobject; protected $formaterror; protected $loaded = false; protected $version = '3.0'; const KTYPE_PREFIX = 'application/x-vnd.kolab.'; const PRODUCT_ID = 'Roundcube-libkolab-1.1'; // mapping table for valid PHP timezones not supported by libkolabxml // basically the entire list of ftp://ftp.iana.org/tz/data/backward protected static $timezone_map = array( 'Africa/Asmera' => 'Africa/Asmara', 'Africa/Timbuktu' => 'Africa/Abidjan', 'America/Argentina/ComodRivadavia' => 'America/Argentina/Catamarca', 'America/Atka' => 'America/Adak', 'America/Buenos_Aires' => 'America/Argentina/Buenos_Aires', 'America/Catamarca' => 'America/Argentina/Catamarca', 'America/Coral_Harbour' => 'America/Atikokan', 'America/Cordoba' => 'America/Argentina/Cordoba', 'America/Ensenada' => 'America/Tijuana', 'America/Fort_Wayne' => 'America/Indiana/Indianapolis', 'America/Indianapolis' => 'America/Indiana/Indianapolis', 'America/Jujuy' => 'America/Argentina/Jujuy', 'America/Knox_IN' => 'America/Indiana/Knox', 'America/Louisville' => 'America/Kentucky/Louisville', 'America/Mendoza' => 'America/Argentina/Mendoza', 'America/Porto_Acre' => 'America/Rio_Branco', 'America/Rosario' => 'America/Argentina/Cordoba', 'America/Virgin' => 'America/Port_of_Spain', 'Asia/Ashkhabad' => 'Asia/Ashgabat', 'Asia/Calcutta' => 'Asia/Kolkata', 'Asia/Chungking' => 'Asia/Shanghai', 'Asia/Dacca' => 'Asia/Dhaka', 'Asia/Katmandu' => 'Asia/Kathmandu', 'Asia/Macao' => 'Asia/Macau', 'Asia/Saigon' => 'Asia/Ho_Chi_Minh', 'Asia/Tel_Aviv' => 'Asia/Jerusalem', 'Asia/Thimbu' => 'Asia/Thimphu', 'Asia/Ujung_Pandang' => 'Asia/Makassar', 'Asia/Ulan_Bator' => 'Asia/Ulaanbaatar', 'Atlantic/Faeroe' => 'Atlantic/Faroe', 'Atlantic/Jan_Mayen' => 'Europe/Oslo', 'Australia/ACT' => 'Australia/Sydney', 'Australia/Canberra' => 'Australia/Sydney', 'Australia/LHI' => 'Australia/Lord_Howe', 'Australia/NSW' => 'Australia/Sydney', 'Australia/North' => 'Australia/Darwin', 'Australia/Queensland' => 'Australia/Brisbane', 'Australia/South' => 'Australia/Adelaide', 'Australia/Tasmania' => 'Australia/Hobart', 'Australia/Victoria' => 'Australia/Melbourne', 'Australia/West' => 'Australia/Perth', 'Australia/Yancowinna' => 'Australia/Broken_Hill', 'Brazil/Acre' => 'America/Rio_Branco', 'Brazil/DeNoronha' => 'America/Noronha', 'Brazil/East' => 'America/Sao_Paulo', 'Brazil/West' => 'America/Manaus', 'Canada/Atlantic' => 'America/Halifax', 'Canada/Central' => 'America/Winnipeg', 'Canada/East-Saskatchewan' => 'America/Regina', 'Canada/Eastern' => 'America/Toronto', 'Canada/Mountain' => 'America/Edmonton', 'Canada/Newfoundland' => 'America/St_Johns', 'Canada/Pacific' => 'America/Vancouver', 'Canada/Saskatchewan' => 'America/Regina', 'Canada/Yukon' => 'America/Whitehorse', 'Chile/Continental' => 'America/Santiago', 'Chile/EasterIsland' => 'Pacific/Easter', 'Cuba' => 'America/Havana', 'Egypt' => 'Africa/Cairo', 'Eire' => 'Europe/Dublin', 'Europe/Belfast' => 'Europe/London', 'Europe/Tiraspol' => 'Europe/Chisinau', 'GB' => 'Europe/London', 'GB-Eire' => 'Europe/London', 'Greenwich' => 'Etc/GMT', 'Hongkong' => 'Asia/Hong_Kong', 'Iceland' => 'Atlantic/Reykjavik', 'Iran' => 'Asia/Tehran', 'Israel' => 'Asia/Jerusalem', 'Jamaica' => 'America/Jamaica', 'Japan' => 'Asia/Tokyo', 'Kwajalein' => 'Pacific/Kwajalein', 'Libya' => 'Africa/Tripoli', 'Mexico/BajaNorte' => 'America/Tijuana', 'Mexico/BajaSur' => 'America/Mazatlan', 'Mexico/General' => 'America/Mexico_City', 'NZ' => 'Pacific/Auckland', 'NZ-CHAT' => 'Pacific/Chatham', 'Navajo' => 'America/Denver', 'PRC' => 'Asia/Shanghai', 'Pacific/Ponape' => 'Pacific/Pohnpei', 'Pacific/Samoa' => 'Pacific/Pago_Pago', 'Pacific/Truk' => 'Pacific/Chuuk', 'Pacific/Yap' => 'Pacific/Chuuk', 'Poland' => 'Europe/Warsaw', 'Portugal' => 'Europe/Lisbon', 'ROC' => 'Asia/Taipei', 'ROK' => 'Asia/Seoul', 'Singapore' => 'Asia/Singapore', 'Turkey' => 'Europe/Istanbul', 'UCT' => 'Etc/UCT', 'US/Alaska' => 'America/Anchorage', 'US/Aleutian' => 'America/Adak', 'US/Arizona' => 'America/Phoenix', 'US/Central' => 'America/Chicago', 'US/East-Indiana' => 'America/Indiana/Indianapolis', 'US/Eastern' => 'America/New_York', 'US/Hawaii' => 'Pacific/Honolulu', 'US/Indiana-Starke' => 'America/Indiana/Knox', 'US/Michigan' => 'America/Detroit', 'US/Mountain' => 'America/Denver', 'US/Pacific' => 'America/Los_Angeles', 'US/Samoa' => 'Pacific/Pago_Pago', 'Universal' => 'Etc/UTC', 'W-SU' => 'Europe/Moscow', 'Zulu' => 'Etc/UTC', ); /** * Factory method to instantiate a kolab_format object of the given type and version * * @param string Object type to instantiate * @param float Format version * @param string Cached xml data to initialize with * @return object kolab_format */ public static function factory($type, $version = '3.0', $xmldata = null) { if (!isset(self::$timezone)) self::$timezone = new DateTimeZone('UTC'); if (!self::supports($version)) return PEAR::raiseError("No support for Kolab format version " . $version); $type = preg_replace('/configuration\.[a-z._]+$/', 'configuration', $type); $suffix = preg_replace('/[^a-z]+/', '', $type); $classname = 'kolab_format_' . $suffix; if (class_exists($classname)) return new $classname($xmldata, $version); return PEAR::raiseError("Failed to load Kolab Format wrapper for type " . $type); } /** * Determine support for the given format version * * @param float Format version to check * @return boolean True if supported, False otherwise */ public static function supports($version) { if ($version == '2.0') return class_exists('kolabobject'); // default is version 3 return class_exists('kolabformat'); } /** * Convert the given date/time value into a cDateTime object * * @param mixed Date/Time value either as unix timestamp, date string or PHP DateTime object * @param DateTimeZone The timezone the date/time is in. Use global default if Null, local time if False * @param boolean True of the given date has no time component * @return object The libkolabxml date/time object */ public static function get_datetime($datetime, $tz = null, $dateonly = false) { // use timezone information from datetime of global setting if (!$tz && $tz !== false) { if ($datetime instanceof DateTime) $tz = $datetime->getTimezone(); if (!$tz) $tz = self::$timezone; } $result = new cDateTime(); try { // got a unix timestamp (in UTC) if (is_numeric($datetime)) { $datetime = new DateTime('@'.$datetime, new DateTimeZone('UTC')); if ($tz) $datetime->setTimezone($tz); } else if (is_string($datetime) && strlen($datetime)) { $datetime = $tz ? new DateTime($datetime, $tz) : new DateTime($datetime); } } catch (Exception $e) {} if ($datetime instanceof DateTime) { $result->setDate($datetime->format('Y'), $datetime->format('n'), $datetime->format('j')); if (!$dateonly) $result->setTime($datetime->format('G'), $datetime->format('i'), $datetime->format('s')); if ($tz && in_array($tz->getName(), array('UTC', 'GMT', '+00:00', 'Z'))) { $result->setUTC(true); } else if ($tz !== false) { $tzid = $tz->getName(); if (array_key_exists($tzid, self::$timezone_map)) $tzid = self::$timezone_map[$tzid]; $result->setTimezone($tzid); } } return $result; } /** * Convert the given cDateTime into a PHP DateTime object * * @param object cDateTime The libkolabxml datetime object * @return object DateTime PHP datetime instance */ public static function php_datetime($cdt) { if (!is_object($cdt) || !$cdt->isValid()) return null; $d = new DateTime; $d->setTimezone(self::$timezone); try { if ($tzs = $cdt->timezone()) { $tz = new DateTimeZone($tzs); $d->setTimezone($tz); } else if ($cdt->isUTC()) { $d->setTimezone(new DateTimeZone('UTC')); } } catch (Exception $e) { } $d->setDate($cdt->year(), $cdt->month(), $cdt->day()); if ($cdt->isDateOnly()) { $d->_dateonly = true; $d->setTime(12, 0, 0); // set time to noon to avoid timezone troubles } else { $d->setTime($cdt->hour(), $cdt->minute(), $cdt->second()); } return $d; } /** * Convert a libkolabxml vector to a PHP array * * @param object vector Object * @return array Indexed array containing vector elements */ public static function vector2array($vec, $max = PHP_INT_MAX) { $arr = array(); for ($i=0; $i < $vec->size() && $i < $max; $i++) $arr[] = $vec->get($i); return $arr; } /** * Build a libkolabxml vector (string) from a PHP array * * @param array Array with vector elements * @return object vectors */ public static function array2vector($arr) { $vec = new vectors; foreach ((array)$arr as $val) { if (strlen($val)) $vec->push($val); } return $vec; } /** * Parse the X-Kolab-Type header from MIME messages and return the object type in short form * * @param string X-Kolab-Type header value * @return string Kolab object type (contact,event,task,note,etc.) */ public static function mime2object_type($x_kolab_type) { return preg_replace( array('/dictionary.[a-z.]+$/', '/contact.distlist$/'), array( 'dictionary', 'distribution-list'), substr($x_kolab_type, strlen(self::KTYPE_PREFIX)) ); } /** * Default constructor of all kolab_format_* objects */ public function __construct($xmldata = null, $version = null) { $this->obj = new $this->objclass; $this->xmldata = $xmldata; if ($version) $this->version = $version; // use libkolab module if available if (class_exists('kolabobject')) $this->xmlobject = new XMLObject(); } /** * Check for format errors after calling kolabformat::write*() * * @return boolean True if there were errors, False if OK */ protected function format_errors() { $ret = $log = false; switch (kolabformat::error()) { case kolabformat::NoError: $ret = false; break; case kolabformat::Warning: $ret = false; $uid = is_object($this->obj) ? $this->obj->uid() : $this->data['uid']; $log = "Warning @ $uid"; break; default: $ret = true; $log = "Error"; } if ($log && !isset($this->formaterror)) { rcube::raise_error(array( 'code' => 660, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "kolabformat $log: " . kolabformat::errorMessage(), ), true); $this->formaterror = $ret; } return $ret; } /** * Save the last generated UID to the object properties. * Should be called after kolabformat::writeXXXX(); */ protected function update_uid() { // get generated UID if (!$this->data['uid']) { if ($this->xmlobject) { $this->data['uid'] = $this->xmlobject->getSerializedUID(); } if (empty($this->data['uid'])) { $this->data['uid'] = kolabformat::getSerializedUID(); } $this->obj->setUid($this->data['uid']); } } /** * Initialize libkolabxml object with cached xml data */ protected function init() { if (!$this->loaded) { if ($this->xmldata) { $this->load($this->xmldata); $this->xmldata = null; } $this->loaded = true; } } /** * Get constant value for libkolab's version parameter * * @param float Version value to convert * @return int Constant value of either kolabobject::KolabV2 or kolabobject::KolabV3 or false if kolabobject module isn't available */ protected function libversion($v = null) { if (class_exists('kolabobject')) { $version = $v ?: $this->version; if ($version <= '2.0') return kolabobject::KolabV2; else return kolabobject::KolabV3; } return false; } /** * Determine the correct libkolab(xml) wrapper function for the given call * depending on the available PHP modules */ protected function libfunc($func) { if (is_array($func) || strpos($func, '::')) return $func; else if (class_exists('kolabobject')) return array($this->xmlobject, $func); else return 'kolabformat::' . $func; } /** * Direct getter for object properties */ public function __get($var) { return $this->data[$var]; } /** * Load Kolab object data from the given XML block * * @param string XML data * @return boolean True on success, False on failure */ public function load($xml) { $this->formaterror = null; $read_func = $this->libfunc($this->read_func); if (is_array($read_func)) $r = call_user_func($read_func, $xml, $this->libversion()); else $r = call_user_func($read_func, $xml, false); if (is_resource($r)) $this->obj = new $this->objclass($r); else if (is_a($r, $this->objclass)) $this->obj = $r; $this->loaded = !$this->format_errors(); } /** * Write object data to XML format * * @param float Format version to write * @return string XML data */ public function write($version = null) { $this->formaterror = null; $this->init(); $write_func = $this->libfunc($this->write_func); if (is_array($write_func)) $this->xmldata = call_user_func($write_func, $this->obj, $this->libversion($version), self::PRODUCT_ID); else $this->xmldata = call_user_func($write_func, $this->obj, self::PRODUCT_ID); if (!$this->format_errors()) $this->update_uid(); else $this->xmldata = null; return $this->xmldata; } /** * Set properties to the kolabformat object * * @param array Object data as hash array */ public function set(&$object) { $this->init(); if (!empty($object['uid'])) $this->obj->setUid($object['uid']); // set some automatic values if missing if (empty($object['created']) && method_exists($this->obj, 'setCreated')) { $cdt = $this->obj->created(); $object['created'] = $cdt && $cdt->isValid() ? self::php_datetime($cdt) : new DateTime('now', new DateTimeZone('UTC')); if (!$cdt || !$cdt->isValid()) $this->obj->setCreated(self::get_datetime($object['created'])); } $object['changed'] = new DateTime('now', new DateTimeZone('UTC')); $this->obj->setLastModified(self::get_datetime($object['changed'])); // Save custom properties of the given object if (isset($object['x-custom']) && method_exists($this->obj, 'setCustomProperties')) { $vcustom = new vectorcs; foreach ((array)$object['x-custom'] as $cp) { if (is_array($cp)) $vcustom->push(new CustomProperty($cp[0], $cp[1])); } $this->obj->setCustomProperties($vcustom); } // load custom properties from XML for caching (#2238) if method exists (#3125) else if (method_exists($this->obj, 'customProperties')) { $object['x-custom'] = array(); $vcustom = $this->obj->customProperties(); for ($i=0; $i < $vcustom->size(); $i++) { $cp = $vcustom->get($i); $object['x-custom'][] = array($cp->identifier, $cp->value); } } } /** * Convert the Kolab object into a hash array data structure * * @param array Additional data for merge * * @return array Kolab object data as hash array */ public function to_array($data = array()) { $this->init(); // read object properties into local data object $object = array( 'uid' => $this->obj->uid(), 'changed' => self::php_datetime($this->obj->lastModified()), ); // not all container support the created property if (method_exists($this->obj, 'created')) { $object['created'] = self::php_datetime($this->obj->created()); } // read custom properties if (method_exists($this->obj, 'customProperties')) { $vcustom = $this->obj->customProperties(); for ($i=0; $i < $vcustom->size(); $i++) { $cp = $vcustom->get($i); $object['x-custom'][] = array($cp->identifier, $cp->value); } } // merge with additional data, e.g. attachments from the message if ($data) { foreach ($data as $idx => $value) { if (is_array($value)) { $object[$idx] = array_merge((array)$object[$idx], $value); } else { $object[$idx] = $value; } } } return $object; } /** * Object validation method to be implemented by derived classes */ abstract public function is_valid(); /** * Callback for kolab_storage_cache to get object specific tags to cache * * @return array List of tags to save in cache */ public function get_tags() { return array(); } /** * Callback for kolab_storage_cache to get words to index for fulltext search * * @return array List of words to save in cache */ public function get_words() { return array(); } /** * Utility function to extract object attachment data * * @param array Hash array reference to append attachment data into */ - public function get_attachments(&$object) + public function get_attachments(&$object, $all = false) { $this->init(); // handle attachments $vattach = $this->obj->attachments(); for ($i=0; $i < $vattach->size(); $i++) { $attach = $vattach->get($i); // skip cid: attachments which are mime message parts handled by kolab_storage_folder if (substr($attach->uri(), 0, 4) != 'cid:' && $attach->label()) { $name = $attach->label(); $key = $name . (isset($object['_attachments'][$name]) ? '.'.$i : ''); $content = $attach->data(); $object['_attachments'][$key] = array( 'id' => 'i:'.$i, 'name' => $name, 'mimetype' => $attach->mimetype(), 'size' => strlen($content), 'content' => $content, ); } + else if ($all && substr($attach->uri(), 0, 4) == 'cid:') { + $key = $attach->uri(); + $object['_attachments'][$key] = array( + 'id' => $key, + 'name' => $attach->label(), + 'mimetype' => $attach->mimetype(), + ); + } else if (in_array(substr($attach->uri(), 0, 4), array('http','imap'))) { $object['links'][] = $attach->uri(); } } } /** * Utility function to set attachment properties to the kolabformat object * * @param array Object data as hash array * @param boolean True to always overwrite attachment information */ protected function set_attachments($object, $write = true) { // save attachments $vattach = new vectorattachment; foreach ((array) $object['_attachments'] as $cid => $attr) { if (empty($attr)) continue; $attach = new Attachment; $attach->setLabel((string)$attr['name']); $attach->setUri('cid:' . $cid, $attr['mimetype'] ?: 'application/octet-stream'); if ($attach->isValid()) { $vattach->push($attach); $write = true; } else { rcube::raise_error(array( 'code' => 660, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Invalid attributes for attachment $cid: " . var_export($attr, true), ), true); } } foreach ((array) $object['links'] as $link) { $attach = new Attachment; $attach->setUri($link, 'unknown'); $vattach->push($attach); $write = true; } if ($write) { $this->obj->setAttachments($vattach); } } } diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_event.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_event.php index 8cad89a..feea60d 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_event.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_event.php @@ -1,244 +1,337 @@ * * 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_format_event extends kolab_format_xcal { public $CTYPEv2 = 'application/x-vnd.kolab.event'; - public $scheduling_properties = array('start', 'end', 'allday', 'location', 'status', 'cancelled'); + public static $scheduling_properties = array('start', 'end', 'allday', 'recurrence', 'location', 'status', 'cancelled'); protected $objclass = 'Event'; protected $read_func = 'readEvent'; protected $write_func = 'writeEvent'; /** * Default constructor */ function __construct($data = null, $version = 3.0) { parent::__construct(is_string($data) ? $data : null, $version); // got an Event object as argument if (is_object($data) && is_a($data, $this->objclass)) { $this->obj = $data; $this->loaded = true; } + + // copy static property overriden by this class + $this->_scheduling_properties = self::$scheduling_properties; } /** * Clones into an instance of libcalendaring's extended EventCal class * * @return mixed EventCal object or false on failure */ public function to_libcal() { static $error_logged = false; if (class_exists('kolabcalendaring')) { return new EventCal($this->obj); } else if (!$error_logged) { $error_logged = true; rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "required kolabcalendaring module not found" ), true); } return false; } /** * Set event properties to the kolabformat object * * @param array Event data as hash array */ public function set(&$object) { // set common xcal properties parent::set($object); // do the hard work of setting object values $this->obj->setStart(self::get_datetime($object['start'], null, $object['allday'])); $this->obj->setEnd(self::get_datetime($object['end'], null, $object['allday'])); $this->obj->setTransparency($object['free_busy'] == 'free'); $status = kolabformat::StatusUndefined; if ($object['free_busy'] == 'tentative') $status = kolabformat::StatusTentative; if ($object['cancelled']) $status = kolabformat::StatusCancelled; else if ($object['status'] && array_key_exists($object['status'], $this->status_map)) $status = $this->status_map[$object['status']]; $this->obj->setStatus($status); - // save recurrence exceptions - if (is_array($object['recurrence']) && $object['recurrence']['EXCEPTIONS']) { + // save (recurrence) exceptions + if (is_array($object['recurrence']) && is_array($object['recurrence']['EXCEPTIONS']) && !isset($object['exceptions'])) { + $object['exceptions'] = $object['recurrence']['EXCEPTIONS']; + } + + if (is_array($object['exceptions'])) { + $recurrence_id_format = libkolab::recurrence_id_format($object); $vexceptions = new vectorevent; - foreach((array)$object['recurrence']['EXCEPTIONS'] as $i => $exception) { + foreach ($object['exceptions'] as $i => $exception) { $exevent = new kolab_format_event; $exevent->set(($compacted = $this->compact_exception($exception, $object))); // only save differing values - $exevent->obj->setRecurrenceID(self::get_datetime($exception['start'], null, true), (bool)$exception['thisandfuture']); + + // get value for recurrence-id + $recurrence_id = null; + if (!empty($exception['recurrence_date']) && is_a($exception['recurrence_date'], 'DateTime')) { + $recurrence_id = $exception['recurrence_date']; + $compacted['_instance'] = $recurrence_id->format($recurrence_id_format); + } + else if (!empty($exception['_instance']) && strlen($exception['_instance']) > 4) { + $recurrence_id = rcube_utils::anytodatetime($exception['_instance'], $object['start']->getTimezone()); + $compacted['recurrence_date'] = $recurrence_id; + } + + $exevent->obj->setRecurrenceID(self::get_datetime($recurrence_id ?: $exception['start'], null, $object['allday']), (bool)$exception['thisandfuture']); + $vexceptions->push($exevent->obj); + // write cleaned-up exception data back to memory/cache - $object['recurrence']['EXCEPTIONS'][$i] = $this->expand_exception($compacted, $object); + $object['exceptions'][$i] = $this->expand_exception($exevent->data, $object); } $this->obj->setExceptions($vexceptions); + + // link with recurrence.EXCEPTIONS for compatibility + if (is_array($object['recurrence'])) { + $object['recurrence']['EXCEPTIONS'] = &$object['exceptions']; + } + } + + if ($object['recurrence_date'] && $object['recurrence_date'] instanceof DateTime) { + if ($object['recurrence']) { + // unset recurrence_date for master events with rrule + $object['recurrence_date'] = null; + } + $this->obj->setRecurrenceID(self::get_datetime($object['recurrence_date'], null, $object['allday']), (bool)$object['thisandfuture']); } // cache this data $this->data = $object; unset($this->data['_formatobj']); } /** * */ public function is_valid() { return !$this->formaterror && (($this->data && !empty($this->data['start']) && !empty($this->data['end'])) || (is_object($this->obj) && $this->obj->isValid() && $this->obj->uid())); } /** * Convert the Event object into a hash array data structure * * @param array Additional data for merge * * @return array Event data as hash array */ public function to_array($data = array()) { // return cached result if (!empty($this->data)) return $this->data; // read common xcal props $object = parent::to_array($data); // read object properties $object += array( 'end' => self::php_datetime($this->obj->end()), 'allday' => $this->obj->start()->isDateOnly(), 'free_busy' => $this->obj->transparency() ? 'free' : 'busy', // TODO: transparency is only boolean 'attendees' => array(), ); // derive event end from duration (#1916) if (!$object['end'] && $object['start'] && ($duration = $this->obj->duration()) && $duration->isValid()) { $interval = new DateInterval('PT0S'); $interval->d = $duration->weeks() * 7 + $duration->days(); $interval->h = $duration->hours(); $interval->i = $duration->minutes(); $interval->s = $duration->seconds(); $object['end'] = clone $object['start']; $object['end']->add($interval); } // organizer is part of the attendees list in Roundcube if ($object['organizer']) { $object['organizer']['role'] = 'ORGANIZER'; array_unshift($object['attendees'], $object['organizer']); } // status defines different event properties... $status = $this->obj->status(); if ($status == kolabformat::StatusTentative) $object['free_busy'] = 'tentative'; else if ($status == kolabformat::StatusCancelled) $object['cancelled'] = true; // this is an exception object if ($this->obj->recurrenceID()->isValid()) { $object['thisandfuture'] = $this->obj->thisAndFuture(); + $object['recurrence_date'] = self::php_datetime($this->obj->recurrenceID()); } // read exception event objects - else if (($exceptions = $this->obj->exceptions()) && is_object($exceptions) && $exceptions->size()) { + if (($exceptions = $this->obj->exceptions()) && is_object($exceptions) && $exceptions->size()) { $recurrence_exceptions = array(); + $recurrence_id_format = libkolab::recurrence_id_format($object); for ($i=0; $i < $exceptions->size(); $i++) { if (($exobj = $exceptions->get($i))) { $exception = new kolab_format_event($exobj); if ($exception->is_valid()) { - $recurrence_exceptions[] = $this->expand_exception($exception->to_array(), $object); + $exdata = $exception->to_array(); + + // fix date-only recurrence ID saved by old versions + if ($exdata['recurrence_date'] && $exdata['recurrence_date']->_dateonly && !$object['allday']) { + $exdata['recurrence_date']->setTimezone($object['start']->getTimezone()); + $exdata['recurrence_date']->setTime($object['start']->format('G'), intval($object['start']->format('i')), intval($object['start']->format('s'))); + } + + $recurrence_id = $exdata['recurrence_date'] ?: $exdata['start']; + $exdata['_instance'] = $recurrence_id->format($recurrence_id_format); + $recurrence_exceptions[] = $this->expand_exception($exdata, $object); } } } - $object['recurrence']['EXCEPTIONS'] = $recurrence_exceptions; + $object['exceptions'] = $recurrence_exceptions; + + // also link with recurrence.EXCEPTIONS for compatibility + if (is_array($object['recurrence'])) { + $object['recurrence']['EXCEPTIONS'] = &$object['exceptions']; + } } return $this->data = $object; } + /** + * Getter for a single instance from a recurrence series or stored subcomponents + * + * @param mixed The recurrence-id of the requested instance, either as string or a DateTime object + * @return array Event data as hash array or null if not found + */ + public function get_instance($recurrence_id) + { + $result = null; + $object = $this->to_array(); + + $recurrence_id_format = libkolab::recurrence_id_format($object); + $instance_id = $recurrence_id instanceof DateTime ? $recurrence_id->format($recurrence_id_format) : strval($recurrence_id); + + if ($object['recurrence_date'] instanceof DateTime) { + if ($object['recurrence_date']->format($recurrence_id_format) == $instance_id) { + $result = $object; + } + } + + if (!$result && is_array($object['exceptions'])) { + foreach ($object['exceptions'] as $exception) { + if ($exception['_instance'] == $instance_id) { + $result = $exception; + $result['isexception'] = 1; + break; + } + } + } + + // TODO: compute instances from recurrence rule and return the matching instance + // clone from plugins/calendar/drivers/kolab/kolab_calendar::get_recurring_events() + + return $result; + } + /** * Callback for kolab_storage_cache to get object specific tags to cache * * @return array List of tags to save in cache */ - public function get_tags() + public function get_tags($obj = null) { - $tags = parent::get_tags(); + $tags = parent::get_tags($obj); + $object = $obj ?: $this->data; - foreach ((array)$this->data['categories'] as $cat) { + foreach ((array)$object['categories'] as $cat) { $tags[] = rcube_utils::normalize_string($cat); } - return $tags; + return array_unique($tags); } /** * Remove some attributes from the exception container */ private function compact_exception($exception, $master) { - $forbidden = array('recurrence','organizer','attendees','sequence'); + $forbidden = array('recurrence','exceptions','organizer','_attachments'); - foreach ($forbidden as $prop) { - if (array_key_exists($prop, $exception)) { - unset($exception[$prop]); + foreach ($forbidden as $prop) { + if (array_key_exists($prop, $exception)) { + unset($exception[$prop]); + } } - } - foreach ($master as $prop => $value) { - if (isset($exception[$prop]) && gettype($exception[$prop]) == gettype($value) && $exception[$prop] == $value) { - unset($exception[$prop]); - } - } + // preserve this property for date serialization + $exception['allday'] = $master['allday']; - return $exception; + return $exception; } /** * Copy attributes not specified by the exception from the master event */ private function expand_exception($exception, $master) { - foreach ($master as $prop => $value) { - if (empty($exception[$prop]) && !empty($value)) - $exception[$prop] = $value; - } + $is_recurring = !empty($master['recurrence']); + + foreach ($master as $prop => $value) { + if (empty($exception[$prop]) && !empty($value) && $prop != 'exceptions' && $prop[0] != '_' + && ($is_recurring || in_array($prop, array('uid','organizer','_attachments')))) { + $exception[$prop] = $value; + if ($prop == 'recurrence') { + unset($exception[$prop]['EXCEPTIONS']); + } + } + } - return $exception; + return $exception; } } diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_task.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_task.php index 52744d4..cb35f98 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_task.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_task.php @@ -1,129 +1,155 @@ * * 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_format_task extends kolab_format_xcal { public $CTYPEv2 = 'application/x-vnd.kolab.task'; - public $scheduling_properties = array('start', 'due', 'summary', 'status'); + public static $scheduling_properties = array('start', 'due', 'summary', 'status'); protected $objclass = 'Todo'; protected $read_func = 'readTodo'; protected $write_func = 'writeTodo'; + /** + * Default constructor + */ + function __construct($data = null, $version = 3.0) + { + parent::__construct(is_string($data) ? $data : null, $version); + + // copy static property overriden by this class + $this->_scheduling_properties = self::$scheduling_properties; + } /** * Set properties to the kolabformat object * * @param array Object data as hash array */ public function set(&$object) { // set common xcal properties parent::set($object); $this->obj->setPercentComplete(intval($object['complete'])); $status = kolabformat::StatusUndefined; if ($object['complete'] == 100 && !array_key_exists('status', $object)) $status = kolabformat::StatusCompleted; else if ($object['status'] && array_key_exists($object['status'], $this->status_map)) $status = $this->status_map[$object['status']]; $this->obj->setStatus($status); $this->obj->setStart(self::get_datetime($object['start'], null, $object['start']->_dateonly)); $this->obj->setDue(self::get_datetime($object['due'], null, $object['due']->_dateonly)); $related = new vectors; if (!empty($object['parent_id'])) $related->push($object['parent_id']); $this->obj->setRelatedTo($related); // cache this data $this->data = $object; unset($this->data['_formatobj']); } /** * */ public function is_valid() { return !$this->formaterror && ($this->data || (is_object($this->obj) && $this->obj->isValid())); } /** * Convert the Configuration object into a hash array data structure * * @param array Additional data for merge * * @return array Config object data as hash array */ public function to_array($data = array()) { // return cached result if (!empty($this->data)) return $this->data; // read common xcal props $object = parent::to_array($data); $object['complete'] = intval($this->obj->percentComplete()); // if due date is set if ($due = $this->obj->due()) $object['due'] = self::php_datetime($due); // related-to points to parent task; we only support one relation $related = self::vector2array($this->obj->relatedTo()); if (count($related)) $object['parent_id'] = $related[0]; // TODO: map more properties $this->data = $object; return $this->data; } + /** + * Return the reference date for recurrence and alarms + * + * @return mixed DateTime instance of null if no refdate is available + */ + public function get_reference_date() + { + if ($this->data['due'] && $this->data['due'] instanceof DateTime) { + return $this->data['due']; + } + + return self::php_datetime($this->obj->due()) ?: parent::get_reference_date(); + } + /** * Callback for kolab_storage_cache to get object specific tags to cache * * @return array List of tags to save in cache */ - public function get_tags() + public function get_tags($obj = null) { - $tags = parent::get_tags(); + $tags = parent::get_tags($obj); + $object = $obj ?: $this->data; - if ($this->data['status'] == 'COMPLETED' || ($this->data['complete'] == 100 && empty($this->data['status']))) + if ($object['status'] == 'COMPLETED' || ($object['complete'] == 100 && empty($object['status']))) $tags[] = 'x-complete'; - if ($this->data['priority'] == 1) + if ($object['priority'] == 1) $tags[] = 'x-flagged'; - if ($this->data['parent_id']) - $tags[] = 'x-parent:' . $this->data['parent_id']; + if ($object['parent_id']) + $tags[] = 'x-parent:' . $object['parent_id']; - return $tags; + return array_unique($tags); } + } diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_xcal.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_xcal.php index ad54505..a9dd70c 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_xcal.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_format_xcal.php @@ -1,630 +1,714 @@ * * 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 . */ abstract class kolab_format_xcal extends kolab_format { public $CTYPE = 'application/calendar+xml'; public static $fulltext_cols = array('title', 'description', 'location', 'attendees:name', 'attendees:email', 'categories'); - public $scheduling_properties = array('start', 'end', 'location'); + public static $scheduling_properties = array('start', 'end', 'location'); + + protected $_scheduling_properties = null; protected $sensitivity_map = array( 'public' => kolabformat::ClassPublic, 'private' => kolabformat::ClassPrivate, 'confidential' => kolabformat::ClassConfidential, ); protected $role_map = array( 'REQ-PARTICIPANT' => kolabformat::Required, 'OPT-PARTICIPANT' => kolabformat::Optional, 'NON-PARTICIPANT' => kolabformat::NonParticipant, 'CHAIR' => kolabformat::Chair, ); protected $cutype_map = array( 'INDIVIDUAL' => kolabformat::CutypeIndividual, 'GROUP' => kolabformat::CutypeGroup, 'ROOM' => kolabformat::CutypeRoom, 'RESOURCE' => kolabformat::CutypeResource, 'UNKNOWN' => kolabformat::CutypeUnknown, ); protected $rrule_type_map = array( 'MINUTELY' => RecurrenceRule::Minutely, 'HOURLY' => RecurrenceRule::Hourly, 'DAILY' => RecurrenceRule::Daily, 'WEEKLY' => RecurrenceRule::Weekly, 'MONTHLY' => RecurrenceRule::Monthly, 'YEARLY' => RecurrenceRule::Yearly, ); protected $weekday_map = array( 'MO' => kolabformat::Monday, 'TU' => kolabformat::Tuesday, 'WE' => kolabformat::Wednesday, 'TH' => kolabformat::Thursday, 'FR' => kolabformat::Friday, 'SA' => kolabformat::Saturday, 'SU' => kolabformat::Sunday, ); protected $alarm_type_map = array( 'DISPLAY' => Alarm::DisplayAlarm, 'EMAIL' => Alarm::EMailAlarm, 'AUDIO' => Alarm::AudioAlarm, ); protected $status_map = array( 'NEEDS-ACTION' => kolabformat::StatusNeedsAction, 'IN-PROCESS' => kolabformat::StatusInProcess, 'COMPLETED' => kolabformat::StatusCompleted, 'CANCELLED' => kolabformat::StatusCancelled, 'TENTATIVE' => kolabformat::StatusTentative, 'CONFIRMED' => kolabformat::StatusConfirmed, 'DRAFT' => kolabformat::StatusDraft, 'FINAL' => kolabformat::StatusFinal, ); protected $part_status_map = array( 'UNKNOWN' => kolabformat::PartNeedsAction, 'NEEDS-ACTION' => kolabformat::PartNeedsAction, 'TENTATIVE' => kolabformat::PartTentative, 'ACCEPTED' => kolabformat::PartAccepted, 'DECLINED' => kolabformat::PartDeclined, 'DELEGATED' => kolabformat::PartDelegated, ); /** * Convert common xcard properties into a hash array data structure * * @param array Additional data for merge * * @return array Object data as hash array */ public function to_array($data = array()) { // read common object props $object = parent::to_array($data); $status_map = array_flip($this->status_map); $sensitivity_map = array_flip($this->sensitivity_map); $object += array( 'sequence' => intval($this->obj->sequence()), 'title' => $this->obj->summary(), 'location' => $this->obj->location(), 'description' => $this->obj->description(), 'url' => $this->obj->url(), 'status' => $status_map[$this->obj->status()], 'sensitivity' => $sensitivity_map[$this->obj->classification()], 'priority' => $this->obj->priority(), 'categories' => self::vector2array($this->obj->categories()), 'start' => self::php_datetime($this->obj->start()), ); if (method_exists($this->obj, 'comment')) { $object['comment'] = $this->obj->comment(); } // read organizer and attendees if (($organizer = $this->obj->organizer()) && ($organizer->email() || $organizer->name())) { $object['organizer'] = array( 'email' => $organizer->email(), 'name' => $organizer->name(), ); } $role_map = array_flip($this->role_map); $cutype_map = array_flip($this->cutype_map); $part_status_map = array_flip($this->part_status_map); $attvec = $this->obj->attendees(); for ($i=0; $i < $attvec->size(); $i++) { $attendee = $attvec->get($i); $cr = $attendee->contact(); if ($cr->email() != $object['organizer']['email']) { $delegators = $delegatees = array(); $vdelegators = $attendee->delegatedFrom(); for ($j=0; $j < $vdelegators->size(); $j++) { $delegators[] = $vdelegators->get($j)->email(); } $vdelegatees = $attendee->delegatedTo(); for ($j=0; $j < $vdelegatees->size(); $j++) { $delegatees[] = $vdelegatees->get($j)->email(); } $object['attendees'][] = array( 'role' => $role_map[$attendee->role()], 'cutype' => $cutype_map[$attendee->cutype()], 'status' => $part_status_map[$attendee->partStat()], 'rsvp' => $attendee->rsvp(), 'email' => $cr->email(), 'name' => $cr->name(), 'delegated-from' => $delegators, 'delegated-to' => $delegatees, ); } } // read recurrence rule if (($rr = $this->obj->recurrenceRule()) && $rr->isValid()) { $rrule_type_map = array_flip($this->rrule_type_map); $object['recurrence'] = array('FREQ' => $rrule_type_map[$rr->frequency()]); if ($intvl = $rr->interval()) $object['recurrence']['INTERVAL'] = $intvl; if (($count = $rr->count()) && $count > 0) { $object['recurrence']['COUNT'] = $count; } else if ($until = self::php_datetime($rr->end())) { - $until->setTime($object['start']->format('G'), $object['start']->format('i'), 0); + $refdate = $this->get_reference_date(); + if ($refdate && $refdate instanceof DateTime && !$refdate->_dateonly) { + $until->setTime($refdate->format('G'), $refdate->format('i'), 0); + } $object['recurrence']['UNTIL'] = $until; } if (($byday = $rr->byday()) && $byday->size()) { $weekday_map = array_flip($this->weekday_map); $weekdays = array(); for ($i=0; $i < $byday->size(); $i++) { $daypos = $byday->get($i); $prefix = $daypos->occurence(); $weekdays[] = ($prefix ? $prefix : '') . $weekday_map[$daypos->weekday()]; } $object['recurrence']['BYDAY'] = join(',', $weekdays); } if (($bymday = $rr->bymonthday()) && $bymday->size()) { $object['recurrence']['BYMONTHDAY'] = join(',', self::vector2array($bymday)); } if (($bymonth = $rr->bymonth()) && $bymonth->size()) { $object['recurrence']['BYMONTH'] = join(',', self::vector2array($bymonth)); } if ($exdates = $this->obj->exceptionDates()) { for ($i=0; $i < $exdates->size(); $i++) { if ($exdate = self::php_datetime($exdates->get($i))) $object['recurrence']['EXDATE'][] = $exdate; } } } if ($rdates = $this->obj->recurrenceDates()) { for ($i=0; $i < $rdates->size(); $i++) { if ($rdate = self::php_datetime($rdates->get($i))) $object['recurrence']['RDATE'][] = $rdate; } } // read alarm $valarms = $this->obj->alarms(); $alarm_types = array_flip($this->alarm_type_map); $object['valarms'] = array(); for ($i=0; $i < $valarms->size(); $i++) { $alarm = $valarms->get($i); - $type = $alarm_types[$alarm->type()]; + $type = $alarm_types[$alarm->type()]; if ($type == 'DISPLAY' || $type == 'EMAIL' || $type == 'AUDIO') { // only some alarms are supported $valarm = array( - 'action' => $type, - 'summary' => $alarm->summary(), + 'action' => $type, + 'summary' => $alarm->summary(), 'description' => $alarm->description(), ); if ($type == 'EMAIL') { $valarm['attendees'] = array(); $attvec = $alarm->attendees(); for ($j=0; $j < $attvec->size(); $j++) { $cr = $attvec->get($j); $valarm['attendees'][] = $cr->email(); } } else if ($type == 'AUDIO') { $attach = $alarm->audioFile(); $valarm['uri'] = $attach->uri(); } if ($start = self::php_datetime($alarm->start())) { - $object['alarms'] = '@' . $start->format('U'); + $object['alarms'] = '@' . $start->format('U'); $valarm['trigger'] = $start; } else if ($offset = $alarm->relativeStart()) { - $prefix = $alarm->relativeTo() == kolabformat::End ? '+' : '-'; - $value = $time = ''; + $prefix = $offset->isNegative() ? '-' : '+'; + $value = ''; + $time = ''; + if ($w = $offset->weeks()) $value .= $w . 'W'; else if ($d = $offset->days()) $value .= $d . 'D'; else if ($h = $offset->hours()) $time .= $h . 'H'; else if ($m = $offset->minutes()) $time .= $m . 'M'; else if ($s = $offset->seconds()) $time .= $s . 'S'; // assume 'at event time' if (empty($value) && empty($time)) { $prefix = ''; - $time = '0S'; + $time = '0S'; } - $object['alarms'] = $prefix . $value . $time; + $object['alarms'] = $prefix . $value . $time; $valarm['trigger'] = $prefix . 'P' . $value . ($time ? 'T' . $time : ''); + + if ($alarm->relativeTo() == kolabformat::End) { + $valarm['related'] == 'END'; + } } // read alarm duration and repeat properties if (($duration = $alarm->duration()) && $duration->isValid()) { $value = $time = ''; + if ($w = $duration->weeks()) $value .= $w . 'W'; else if ($d = $duration->days()) $value .= $d . 'D'; else if ($h = $duration->hours()) $time .= $h . 'H'; else if ($m = $duration->minutes()) $time .= $m . 'M'; else if ($s = $duration->seconds()) $time .= $s . 'S'; + $valarm['duration'] = 'P' . $value . ($time ? 'T' . $time : ''); - $valarm['repeat'] = $alarm->numrepeat(); + $valarm['repeat'] = $alarm->numrepeat(); } $object['alarms'] .= ':' . $type; // legacy property $object['valarms'][] = array_filter($valarm); } } $this->get_attachments($object); return $object; } /** * Set common xcal properties to the kolabformat object * * @param array Event data as hash array */ public function set(&$object) { $this->init(); $is_new = !$this->obj->uid(); $old_sequence = $this->obj->sequence(); $reschedule = $is_new; // set common object properties parent::set($object); // set sequence value if (!isset($object['sequence'])) { if ($is_new) { $object['sequence'] = 0; } else { $object['sequence'] = $old_sequence; - $old = $this->data['uid'] ? $this->data : $this->to_array(); // increment sequence when updating properties relevant for scheduling. // RFC 5545: "It is incremented [...] each time the Organizer makes a significant revision to the calendar component." - // TODO: make the list of properties considered 'significant' for scheduling configurable - foreach ($this->scheduling_properties as $prop) { - $a = $old[$prop]; - $b = $object[$prop]; - if ($object['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) { - $a = $a->format('Y-m-d'); - $b = $b->format('Y-m-d'); - } - if ($a != $b) { - $object['sequence']++; - break; - } + if ($this->check_rescheduling($object)) { + $object['sequence']++; } } } $this->obj->setSequence(intval($object['sequence'])); if ($object['sequence'] > $old_sequence) { $reschedule = true; } $this->obj->setSummary($object['title']); $this->obj->setLocation($object['location']); $this->obj->setDescription($object['description']); $this->obj->setPriority($object['priority']); $this->obj->setClassification($this->sensitivity_map[$object['sensitivity']]); $this->obj->setCategories(self::array2vector($object['categories'])); $this->obj->setUrl(strval($object['url'])); if (method_exists($this->obj, 'setComment')) { $this->obj->setComment($object['comment']); } // process event attendees $attendees = new vectorattendee; foreach ((array)$object['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $object['organizer'] = $attendee; } else if ($attendee['email'] != $object['organizer']['email']) { $cr = new ContactReference(ContactReference::EmailReference, $attendee['email']); $cr->setName($attendee['name']); // set attendee RSVP if missing if (!isset($attendee['rsvp'])) { - $object['attendees'][$i]['rsvp'] = $attendee['rsvp'] = true; + $object['attendees'][$i]['rsvp'] = $attendee['rsvp'] = $reschedule; } $att = new Attendee; $att->setContact($cr); $att->setPartStat($this->part_status_map[$attendee['status']]); $att->setRole($this->role_map[$attendee['role']] ? $this->role_map[$attendee['role']] : kolabformat::Required); $att->setCutype($this->cutype_map[$attendee['cutype']] ? $this->cutype_map[$attendee['cutype']] : kolabformat::CutypeIndividual); $att->setRSVP((bool)$attendee['rsvp']); if (!empty($attendee['delegated-from'])) { $vdelegators = new vectorcontactref; foreach ((array)$attendee['delegated-from'] as $delegator) { $vdelegators->push(new ContactReference(ContactReference::EmailReference, $delegator)); } $att->setDelegatedFrom($vdelegators); } if (!empty($attendee['delegated-to'])) { $vdelegatees = new vectorcontactref; foreach ((array)$attendee['delegated-to'] as $delegatee) { $vdelegatees->push(new ContactReference(ContactReference::EmailReference, $delegatee)); } $att->setDelegatedTo($vdelegatees); } if ($att->isValid()) { $attendees->push($att); } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Invalid event attendee: " . json_encode($attendee), ), true); } } } $this->obj->setAttendees($attendees); if ($object['organizer']) { $organizer = new ContactReference(ContactReference::EmailReference, $object['organizer']['email']); $organizer->setName($object['organizer']['name']); $this->obj->setOrganizer($organizer); } // save recurrence rule $rr = new RecurrenceRule; $rr->setFrequency(RecurrenceRule::FreqNone); if ($object['recurrence'] && !empty($object['recurrence']['FREQ'])) { $rr->setFrequency($this->rrule_type_map[$object['recurrence']['FREQ']]); if ($object['recurrence']['INTERVAL']) $rr->setInterval(intval($object['recurrence']['INTERVAL'])); if ($object['recurrence']['BYDAY']) { $byday = new vectordaypos; foreach (explode(',', $object['recurrence']['BYDAY']) as $day) { $occurrence = 0; if (preg_match('/^([\d-]+)([A-Z]+)$/', $day, $m)) { $occurrence = intval($m[1]); $day = $m[2]; } if (isset($this->weekday_map[$day])) $byday->push(new DayPos($occurrence, $this->weekday_map[$day])); } $rr->setByday($byday); } if ($object['recurrence']['BYMONTHDAY']) { $bymday = new vectori; foreach (explode(',', $object['recurrence']['BYMONTHDAY']) as $day) $bymday->push(intval($day)); $rr->setBymonthday($bymday); } if ($object['recurrence']['BYMONTH']) { $bymonth = new vectori; foreach (explode(',', $object['recurrence']['BYMONTH']) as $month) $bymonth->push(intval($month)); $rr->setBymonth($bymonth); } if ($object['recurrence']['COUNT']) $rr->setCount(intval($object['recurrence']['COUNT'])); else if ($object['recurrence']['UNTIL']) $rr->setEnd(self::get_datetime($object['recurrence']['UNTIL'], null, true)); if ($rr->isValid()) { // add exception dates (only if recurrence rule is valid) $exdates = new vectordatetime; foreach ((array)$object['recurrence']['EXDATE'] as $exdate) $exdates->push(self::get_datetime($exdate, null, true)); $this->obj->setExceptionDates($exdates); } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Invalid event recurrence rule: " . json_encode($object['recurrence']), ), true); } } $this->obj->setRecurrenceRule($rr); // save recurrence dates (aka RDATE) if (!empty($object['recurrence']['RDATE'])) { $rdates = new vectordatetime; foreach ((array)$object['recurrence']['RDATE'] as $rdate) $rdates->push(self::get_datetime($rdate, null, true)); $this->obj->setRecurrenceDates($rdates); } // save alarm $valarms = new vectoralarm; if ($object['valarms']) { foreach ($object['valarms'] as $valarm) { if (!array_key_exists($valarm['action'], $this->alarm_type_map)) { continue; // skip unknown alarm types } if ($valarm['action'] == 'EMAIL') { $recipients = new vectorcontactref; foreach (($valarm['attendees'] ?: array($object['_owner'])) as $email) { $recipients->push(new ContactReference(ContactReference::EmailReference, $email)); } $alarm = new Alarm( strval($valarm['summary'] ?: $object['title']), strval($valarm['description'] ?: $object['description']), $recipients ); } else if ($valarm['action'] == 'AUDIO') { $attach = new Attachment; $attach->setUri($valarm['uri'] ?: 'null', 'unknown'); $alarm = new Alarm($attach); } else { // action == DISPLAY $alarm = new Alarm(strval($valarm['summary'] ?: $object['title'])); } if (is_object($valarm['trigger']) && $valarm['trigger'] instanceof DateTime) { $alarm->setStart(self::get_datetime($valarm['trigger'], new DateTimeZone('UTC'))); } else { try { - $prefix = $valarm['trigger'][0]; - $period = new DateInterval(preg_replace('/[^0-9PTWDHMS]/', '', $valarm['trigger'])); - $duration = new Duration($period->d, $period->h, $period->i, $period->s, $prefix == '-'); + $period = new DateInterval(preg_replace('/[^0-9PTWDHMS]/', '', $valarm['trigger'])); + $duration = new Duration($period->d, $period->h, $period->i, $period->s, $valarm['trigger'][0] == '-'); } catch (Exception $e) { // skip alarm with invalid trigger values rcube::raise_error($e, true); continue; } - $alarm->setRelativeStart($duration, $prefix == '-' ? kolabformat::Start : kolabformat::End); + $related = strtoupper($valarm['related']) == 'END' ? kolabformat::End : kolabformat::Start; + $alarm->setRelativeStart($duration, $related); } if ($valarm['duration']) { try { $d = new DateInterval($valarm['duration']); $duration = new Duration($d->d, $d->h, $d->i, $d->s); $alarm->setDuration($duration, intval($valarm['repeat'])); } catch (Exception $e) { // ignore } } $valarms->push($alarm); } } // legacy support else if ($object['alarms']) { list($offset, $type) = explode(":", $object['alarms']); if ($type == 'EMAIL' && !empty($object['_owner'])) { // email alarms implicitly go to event owner $recipients = new vectorcontactref; $recipients->push(new ContactReference(ContactReference::EmailReference, $object['_owner'])); $alarm = new Alarm($object['title'], strval($object['description']), $recipients); } else { // default: display alarm $alarm = new Alarm($object['title']); } if (preg_match('/^@(\d+)/', $offset, $d)) { $alarm->setStart(self::get_datetime($d[1], new DateTimeZone('UTC'))); } else if (preg_match('/^([-+]?)P?T?(\d+)([SMHDW])/', $offset, $d)) { $days = $hours = $minutes = $seconds = 0; switch ($d[3]) { case 'W': $days = 7*intval($d[2]); break; case 'D': $days = intval($d[2]); break; case 'H': $hours = intval($d[2]); break; case 'M': $minutes = intval($d[2]); break; case 'S': $seconds = intval($d[2]); break; } $alarm->setRelativeStart(new Duration($days, $hours, $minutes, $seconds, $d[1] == '-'), $d[1] == '-' ? kolabformat::Start : kolabformat::End); } $valarms->push($alarm); } $this->obj->setAlarms($valarms); $this->set_attachments($object); } + /** + * Return the reference date for recurrence and alarms + * + * @return mixed DateTime instance of null if no refdate is available + */ + public function get_reference_date() + { + if ($this->data['start'] && $this->data['start'] instanceof DateTime) { + return $this->data['start']; + } + + return self::php_datetime($this->obj->start()); + } + /** * Callback for kolab_storage_cache to get words to index for fulltext search * * @return array List of words to save in cache */ - public function get_words() + public function get_words($obj = null) { $data = ''; + $object = $obj ?: $this->data; + foreach (self::$fulltext_cols as $colname) { list($col, $field) = explode(':', $colname); if ($field) { $a = array(); - foreach ((array)$this->data[$col] as $attr) + foreach ((array)$object[$col] as $attr) $a[] = $attr[$field]; $val = join(' ', $a); } else { - $val = is_array($this->data[$col]) ? join(' ', $this->data[$col]) : $this->data[$col]; + $val = is_array($object[$col]) ? join(' ', $object[$col]) : $object[$col]; } if (strlen($val)) $data .= $val . ' '; } - return array_unique(rcube_utils::normalize_string($data, true)); + $words = rcube_utils::normalize_string($data, true); + + // collect words from recurrence exceptions + if (is_array($object['exceptions'])) { + foreach ($object['exceptions'] as $exception) { + $words = array_merge($words, $this->get_words($exception)); + } + } + + return array_unique($words); } /** * Callback for kolab_storage_cache to get object specific tags to cache * * @return array List of tags to save in cache */ - public function get_tags() + public function get_tags($obj = null) { $tags = array(); + $object = $obj ?: $this->data; - if (!empty($this->data['valarms'])) { + if (!empty($object['valarms'])) { $tags[] = 'x-has-alarms'; } // create tags reflecting participant status - if (is_array($this->data['attendees'])) { - foreach ($this->data['attendees'] as $attendee) { + if (is_array($object['attendees'])) { + foreach ($object['attendees'] as $attendee) { if (!empty($attendee['email']) && !empty($attendee['status'])) $tags[] = 'x-partstat:' . $attendee['email'] . ':' . strtolower($attendee['status']); } } - return $tags; + // collect tags from recurrence exceptions + if (is_array($object['exceptions'])) { + foreach ($object['exceptions'] as $exception) { + $tags = array_merge($tags, $this->get_tags($exception)); + } + } + + if (!empty($object['status'])) { + $tags[] = 'x-status:' . strtolower($object['status']); + } + + return array_unique($tags); + } + + /** + * Identify changes considered relevant for scheduling + * + * @param array Hash array with NEW object properties + * @param array Hash array with OLD object properties + * + * @return boolean True if changes affect scheduling, False otherwise + */ + public function check_rescheduling($object, $old = null) + { + $reschedule = false; + + if (!is_array($old)) { + $old = $this->data['uid'] ? $this->data : $this->to_array(); + } + + foreach ($this->_scheduling_properties ?: self::$scheduling_properties as $prop) { + $a = $old[$prop]; + $b = $object[$prop]; + if ($object['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) { + $a = $a->format('Y-m-d'); + $b = $b->format('Y-m-d'); + } + if ($prop == 'recurrence' && is_array($a) && is_array($b)) { + unset($a['EXCEPTIONS'], $b['EXCEPTIONS']); + $a = array_filter($a); + $b = array_filter($b); + + // advanced rrule comparison: no rescheduling if series was shortened + if ($a['COUNT'] && $b['COUNT'] && $b['COUNT'] < $a['COUNT']) { + unset($a['COUNT'], $b['COUNT']); + } + else if ($a['UNTIL'] && $b['UNTIL'] && $b['UNTIL'] < $a['UNTIL']) { + unset($a['UNTIL'], $b['UNTIL']); + } + } + if ($a != $b) { + $reschedule = true; + break; + } + } + + return $reschedule; } } \ No newline at end of file diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage.php index 47c1e4b..9bafbe9 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage.php @@ -1,1571 +1,1603 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage { const CTYPE_KEY = '/shared/vendor/kolab/folder-type'; const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type'; const COLOR_KEY_SHARED = '/shared/vendor/kolab/color'; const COLOR_KEY_PRIVATE = '/private/vendor/kolab/color'; const NAME_KEY_SHARED = '/shared/vendor/kolab/displayname'; const NAME_KEY_PRIVATE = '/private/vendor/kolab/displayname'; const UID_KEY_SHARED = '/shared/vendor/kolab/uniqueid'; - const UID_KEY_PRIVATE = '/private/vendor/kolab/uniqueid'; const UID_KEY_CYRUS = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; const ERROR_IMAP_CONN = 1; const ERROR_CACHE_DB = 2; const ERROR_NO_PERMISSION = 3; const ERROR_INVALID_FOLDER = 4; public static $version = '3.0'; public static $last_error; public static $encode_ids = false; private static $ready = false; private static $with_tempsubs = true; private static $subscriptions; private static $typedata = array(); private static $states; private static $config; private static $imap; private static $ldap; // Default folder names private static $default_folders = array( 'event' => 'Calendar', 'contact' => 'Contacts', 'task' => 'Tasks', 'note' => 'Notes', 'file' => 'Files', 'configuration' => 'Configuration', 'journal' => 'Journal', 'mail.inbox' => 'INBOX', 'mail.drafts' => 'Drafts', 'mail.sentitems' => 'Sent', 'mail.wastebasket' => 'Trash', 'mail.outbox' => 'Outbox', 'mail.junkemail' => 'Junk', ); /** * Setup the environment needed by the libs */ public static function setup() { if (self::$ready) return true; $rcmail = rcube::get_instance(); self::$config = $rcmail->config; self::$version = strval($rcmail->config->get('kolab_format_version', self::$version)); self::$imap = $rcmail->get_storage(); self::$ready = class_exists('kolabformat') && (self::$imap->get_capability('METADATA') || self::$imap->get_capability('ANNOTATEMORE') || self::$imap->get_capability('ANNOTATEMORE2')); if (self::$ready) { // set imap options self::$imap->set_options(array( 'skip_deleted' => true, 'threading' => false, )); } else if (!class_exists('kolabformat')) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "required kolabformat module not found" ), true); } else { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "IMAP server doesn't support METADATA or ANNOTATEMORE" ), true); } + // adjust some configurable settings + if ($event_scheduling_prop = $rcmail->config->get('kolab_event_scheduling_properties', null)) { + kolab_format_event::$scheduling_properties = (array)$event_scheduling_prop; + } + // adjust some configurable settings + if ($task_scheduling_prop = $rcmail->config->get('kolab_task_scheduling_properties', null)) { + kolab_format_task::$scheduling_properties = (array)$task_scheduling_prop; + } + return self::$ready; } /** * Initializes LDAP object to resolve Kolab users */ public static function ldap() { if (self::$ldap) { return self::$ldap; } self::setup(); $config = self::$config->get('kolab_users_directory', self::$config->get('kolab_auth_addressbook')); if (!is_array($config)) { $ldap_config = (array)self::$config->get('ldap_public'); $config = $ldap_config[$config]; } if (empty($config)) { return null; } // overwrite filter option if ($filter = self::$config->get('kolab_users_filter')) { self::$config->set('kolab_auth_filter', $filter); } // re-use the LDAP wrapper class from kolab_auth plugin require_once rtrim(RCUBE_PLUGINS_DIR, '/') . '/kolab_auth/kolab_auth_ldap.php'; self::$ldap = new kolab_auth_ldap($config); return self::$ldap; } /** * Get a list of storage folders for the given data type * * @param string Data type to list folders for (contact,distribution-list,event,task,note) * @param boolean Enable to return subscribed folders only (null to use configured subscription mode) * * @return array List of Kolab_Folder objects (folder names in UTF7-IMAP) */ public static function get_folders($type, $subscribed = null) { $folders = $folderdata = array(); if (self::setup()) { foreach ((array)self::list_folders('', '*', $type, $subscribed, $folderdata) as $foldername) { $folders[$foldername] = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); } } return $folders; } /** * Getter for the storage folder for the given type * * @param string Data type to list folders for (contact,distribution-list,event,task,note) * @return object kolab_storage_folder The folder object */ public static function get_default_folder($type) { if (self::setup()) { foreach ((array)self::list_folders('', '*', $type . '.default', false, $folderdata) as $foldername) { return new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); } } return null; } /** * Getter for a specific storage folder * * @param string IMAP folder to access (UTF7-IMAP) * @param string Expected folder type * * @return object kolab_storage_folder The folder object */ public static function get_folder($folder, $type = null) { return self::setup() ? new kolab_storage_folder($folder, $type) : null; } /** * Getter for a single Kolab object, identified by its UID. * This will search all folders storing objects of the given type. * * @param string Object UID * @param string Object type (contact,event,task,journal,file,note,configuration) * @return array The Kolab object represented as hash array or false if not found */ public static function get_object($uid, $type) { self::setup(); $folder = null; foreach ((array)self::list_folders('', '*', $type, null, $folderdata) as $foldername) { if (!$folder) $folder = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); else $folder->set_folder($foldername, $type, $folderdata[$foldername]); if ($object = $folder->get_object($uid, '*')) return $object; } return false; } /** * Execute cross-folder searches with the given query. * * @param array Pseudo-SQL query as list of filter parameter triplets * @param string Object type (contact,event,task,journal,file,note,configuration) * @return array List of Kolab data objects (each represented as hash array) * @see kolab_storage_format::select() */ public static function select($query, $type) { self::setup(); $folder = null; $result = array(); foreach ((array)self::list_folders('', '*', $type, null, $folderdata) as $foldername) { if (!$folder) $folder = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); else $folder->set_folder($foldername, $type, $folderdata[$foldername]); foreach ($folder->select($query, '*') as $object) { $result[] = $object; } } return $result; } /** * Returns Free-busy server URL */ public static function get_freebusy_server() { + self::setup(); + $url = 'https://' . $_SESSION['imap_host'] . '/freebusy'; $url = self::$config->get('kolab_freebusy_server', $url); $url = rcube_utils::resolve_url($url); return unslashify($url); } /** * Compose an URL to query the free/busy status for the given user + * + * @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) + public static function get_freebusy_url($email, $start = null, $end = null) { - return self::get_freebusy_server() . '/' . $email . '.ifb'; + $query = ''; + $param = array(); + $utc = new \DateTimeZone('UTC'); + + if ($start instanceof \DateTime) { + $start->setTimezone($utc); + $param['dtstart'] = $start->format('Ymd\THis\Z'); + } + if ($end instanceof \DateTime) { + $end->setTimezone($utc); + $param['dtend'] = $end->format('Ymd\THis\Z'); + } + if (!empty($param)) { + $query = '?' . http_build_query($param); + } + + return self::get_freebusy_server() . '/' . $email . '.ifb' . $query; } /** * Creates folder ID from folder name * * @param string $folder Folder name (UTF7-IMAP) * @param boolean $enc Use lossless encoding * @return string Folder ID string */ public static function folder_id($folder, $enc = null) { return $enc == true || ($enc === null && self::$encode_ids) ? self::id_encode($folder) : asciiwords(strtr($folder, '/.-', '___')); } /** * Encode the given ID to a safe ascii representation * * @param string $id Arbitrary identifier string * * @return string Ascii representation */ public static function id_encode($id) { return rtrim(strtr(base64_encode($id), '+/', '-_'), '='); } /** * Convert the given identifier back to it's raw value * * @param string $id Ascii identifier * @return string Raw identifier string */ public static function id_decode($id) { return base64_decode(str_pad(strtr($id, '-_', '+/'), strlen($id) % 4, '=', STR_PAD_RIGHT)); } /** * Return the (first) path of the requested IMAP namespace * * @param string Namespace name (personal, shared, other) * @return string IMAP root path for that namespace */ public static function namespace_root($name) { foreach ((array)self::$imap->get_namespace($name) as $paths) { if (strlen($paths[0]) > 1) { return $paths[0]; } } return ''; } /** * Deletes IMAP folder * * @param string $name Folder name (UTF7-IMAP) * * @return bool True on success, false on failure */ public static function folder_delete($name) { // clear cached entries first if ($folder = self::get_folder($name)) $folder->cache->purge(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_delete', array('name' => $name)); $success = self::$imap->delete_folder($name); self::$last_error = self::$imap->get_error_str(); return $success; } /** * Creates IMAP folder * * @param string $name Folder name (UTF7-IMAP) * @param string $type Folder type * @param bool $subscribed Sets folder subscription * @param bool $active Sets folder state (client-side subscription) * * @return bool True on success, false on failure */ public static function folder_create($name, $type = null, $subscribed = false, $active = false) { self::setup(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_create', array('record' => array( 'name' => $name, 'subscribe' => $subscribed, ))); if ($saved = self::$imap->create_folder($name, $subscribed)) { // set metadata for folder type if ($type) { $saved = self::set_folder_type($name, $type); // revert if metadata could not be set if (!$saved) { self::$imap->delete_folder($name); } // activate folder else if ($active) { self::set_state($name, true); } } } if ($saved) { return true; } self::$last_error = self::$imap->get_error_str(); return false; } /** * Renames IMAP folder * * @param string $oldname Old folder name (UTF7-IMAP) * @param string $newname New folder name (UTF7-IMAP) * * @return bool True on success, false on failure */ public static function folder_rename($oldname, $newname) { self::setup(); $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->exec_hook('folder_rename', array( 'oldname' => $oldname, 'newname' => $newname)); $oldfolder = self::get_folder($oldname); $active = self::folder_is_active($oldname); $success = self::$imap->rename_folder($oldname, $newname); self::$last_error = self::$imap->get_error_str(); // pass active state to new folder name if ($success && $active) { self::set_state($oldname, false); self::set_state($newname, true); } // assign existing cache entries to new resource uri if ($success && $oldfolder) { $oldfolder->cache->rename($newname); } return $success; } /** * Rename or Create a new IMAP folder. * * Does additional checks for permissions and folder name restrictions * * @param array Hash array with folder properties and metadata * - name: Folder name * - oldname: Old folder name when changed * - parent: Parent folder to create the new one in * - type: Folder type to create * - subscribed: Subscribed flag (IMAP subscription) * - active: Activation flag (client-side subscription) * @return mixed New folder name or False on failure */ public static function folder_update(&$prop) { self::setup(); $folder = rcube_charset::convert($prop['name'], RCUBE_CHARSET, 'UTF7-IMAP'); $oldfolder = $prop['oldname']; // UTF7 $parent = $prop['parent']; // UTF7 $delimiter = self::$imap->get_hierarchy_delimiter(); if (strlen($oldfolder)) { $options = self::$imap->folder_info($oldfolder); } if (!empty($options) && ($options['norename'] || $options['protected'])) { } // sanity checks (from steps/settings/save_folder.inc) else if (!strlen($folder)) { self::$last_error = 'cannotbeempty'; return false; } else if (strlen($folder) > 128) { self::$last_error = 'nametoolong'; return false; } else { // these characters are problematic e.g. when used in LIST/LSUB foreach (array($delimiter, '%', '*') as $char) { if (strpos($folder, $char) !== false) { self::$last_error = 'forbiddencharacter'; return false; } } } if (!empty($options) && ($options['protected'] || $options['norename'])) { $folder = $oldfolder; } else if (strlen($parent)) { $folder = $parent . $delimiter . $folder; } else { // add namespace prefix (when needed) $folder = self::$imap->mod_folder($folder, 'in'); } // Check access rights to the parent folder if (strlen($parent) && (!strlen($oldfolder) || $oldfolder != $folder)) { $parent_opts = self::$imap->folder_info($parent); if ($parent_opts['namespace'] != 'personal' && (empty($parent_opts['rights']) || !preg_match('/[ck]/', implode($parent_opts['rights']))) ) { self::$last_error = 'No permission to create folder'; return false; } } // update the folder name if (strlen($oldfolder)) { if ($oldfolder != $folder) { $result = self::folder_rename($oldfolder, $folder); } else $result = true; } // create new folder else { $result = self::folder_create($folder, $prop['type'], $prop['subscribed'], $prop['active']); } if ($result) { self::set_folder_props($folder, $prop); } return $result ? $folder : false; } /** * Getter for human-readable name of Kolab object (folder) * See http://wiki.kolab.org/UI-Concepts/Folder-Listing for reference * * @param string $folder IMAP folder name (UTF7-IMAP) * @param string $folder_ns Will be set to namespace name of the folder * * @return string Name of the folder-object */ public static function object_name($folder, &$folder_ns=null) { self::setup(); // find custom display name in folder METADATA if ($name = self::custom_displayname($folder)) { return $name; } $found = false; $namespace = self::$imap->get_namespace(); if (!empty($namespace['shared'])) { foreach ($namespace['shared'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { $prefix = ''; $folder = substr($folder, strlen($ns[0])); $delim = $ns[1]; $found = true; $folder_ns = 'shared'; break; } } } if (!$found && !empty($namespace['other'])) { foreach ($namespace['other'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { // remove namespace prefix $folder = substr($folder, strlen($ns[0])); $delim = $ns[1]; // get username $pos = strpos($folder, $delim); if ($pos) { $prefix = '('.substr($folder, 0, $pos).')'; $folder = substr($folder, $pos+1); } else { $prefix = '('.$folder.')'; $folder = ''; } $found = true; $folder_ns = 'other'; break; } } } if (!$found && !empty($namespace['personal'])) { foreach ($namespace['personal'] as $ns) { if (strlen($ns[0]) && strpos($folder, $ns[0]) === 0) { // remove namespace prefix $folder = substr($folder, strlen($ns[0])); $prefix = ''; $delim = $ns[1]; $found = true; break; } } } if (empty($delim)) $delim = self::$imap->get_hierarchy_delimiter(); $folder = rcube_charset::convert($folder, 'UTF7-IMAP'); $folder = html::quote($folder); $folder = str_replace(html::quote($delim), ' » ', $folder); if ($prefix) $folder = html::quote($prefix) . ($folder !== '' ? ' ' . $folder : ''); if (!$folder_ns) $folder_ns = 'personal'; return $folder; } /** * Get custom display name (saved in metadata) for the given folder */ public static function custom_displayname($folder) { // find custom display name in folder METADATA if (self::$config->get('kolab_custom_display_names', true)) { $metadata = self::$imap->get_metadata($folder, array(self::NAME_KEY_PRIVATE, self::NAME_KEY_SHARED)); if (($name = $metadata[$folder][self::NAME_KEY_PRIVATE]) || ($name = $metadata[$folder][self::NAME_KEY_SHARED])) { return $name; } } return false; } /** * Helper method to generate a truncated folder name to display. * Note: $origname is a string returned by self::object_name() */ public static function folder_displayname($origname, &$names) { $name = $origname; // find folder prefix to truncate for ($i = count($names)-1; $i >= 0; $i--) { if (strpos($name, $names[$i] . ' » ') === 0) { $length = strlen($names[$i] . ' » '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $diff = 1; // check if prefix folder is in other users namespace for ($n = count($names)-1; $n >= 0; $n--) { if (strpos($prefix, '(' . $names[$n] . ') ') === 0) { $diff = 0; break; } } $name = str_repeat('   ', $count - $diff) . '» ' . substr($name, $length); break; } // other users namespace and parent folder exists else if (strpos($name, '(' . $names[$i] . ') ') === 0) { $length = strlen('(' . $names[$i] . ') '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $name = str_repeat('   ', $count) . '» ' . substr($name, $length); break; } } $names[] = $origname; return $name; } /** * Creates a SELECT field with folders list * * @param string $type Folder type * @param array $attrs SELECT field attributes (e.g. name) * @param string $current The name of current folder (to skip it) * * @return html_select SELECT object */ public static function folder_selector($type, $attrs, $current = '') { // get all folders of specified type (sorted) $folders = self::get_folders($type, true); $delim = self::$imap->get_hierarchy_delimiter(); $names = array(); $len = strlen($current); if ($len && ($rpos = strrpos($current, $delim))) { $parent = substr($current, 0, $rpos); $p_len = strlen($parent); } // Filter folders list foreach ($folders as $c_folder) { $name = $c_folder->name; // skip current folder and it's subfolders if ($len) { if ($name == $current) { // Make sure parent folder is listed (might be skipped e.g. if it's namespace root) if ($p_len && !isset($names[$parent])) { $names[$parent] = self::object_name($parent); } continue; } if (strpos($name, $current.$delim) === 0) { continue; } } // always show the parent of current folder if ($p_len && $name == $parent) { } // skip folders where user have no rights to create subfolders else if ($c_folder->get_owner() != $_SESSION['username']) { $rights = $c_folder->get_myrights(); if (!preg_match('/[ck]/', $rights)) { continue; } } $names[$name] = self::object_name($name); } // Build SELECT field of parent folder $attrs['is_escaped'] = true; $select = new html_select($attrs); $select->add('---', ''); $listnames = array(); foreach (array_keys($names) as $imap_name) { $name = $origname = $names[$imap_name]; // find folder prefix to truncate for ($i = count($listnames)-1; $i >= 0; $i--) { if (strpos($name, $listnames[$i].' » ') === 0) { $length = strlen($listnames[$i].' » '); $prefix = substr($name, 0, $length); $count = count(explode(' » ', $prefix)); $name = str_repeat('  ', $count-1) . '» ' . substr($name, $length); break; } } $listnames[] = $origname; $select->add($name, $imap_name); } return $select; } /** * Returns a list of folder names * * @param string Optional root folder * @param string Optional name pattern * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param boolean Enable to return subscribed folders only (null to use configured subscription mode) * @param array Will be filled with folder-types data * * @return array List of folders */ public static function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = array()) { if (!self::setup()) { return null; } // use IMAP subscriptions if ($subscribed === null && self::$config->get('kolab_use_subscriptions')) { $subscribed = true; } if (!$filter) { // Get ALL folders list, standard way if ($subscribed) { $folders = self::$imap->list_folders_subscribed($root, $mbox); // add temporarily subscribed folders if (self::$with_tempsubs && is_array($_SESSION['kolab_subscribed_folders'])) { $folders = array_unique(array_merge($folders, $_SESSION['kolab_subscribed_folders'])); } } else { $folders = self::_imap_list_folders($root, $mbox); } return $folders; } $prefix = $root . $mbox; $regexp = '/^' . preg_quote($filter, '/') . '(\..+)?$/'; // get folders types for all folders if (!$subscribed || $prefix == '*' || !self::$config->get('kolab_skip_namespace')) { $folderdata = self::folders_typedata($prefix); } else { // fetch folder types for the effective list of (subscribed) folders when post-filtering $folderdata = array(); } if (!is_array($folderdata)) { return array(); } // In some conditions we can skip LIST command (?) if (!$subscribed && $filter != 'mail' && $prefix == '*') { foreach ($folderdata as $folder => $type) { if (!preg_match($regexp, $type)) { unset($folderdata[$folder]); } } return self::$imap->sort_folder_list(array_keys($folderdata), true); } // Get folders list if ($subscribed) { $folders = self::$imap->list_folders_subscribed($root, $mbox); // add temporarily subscribed folders if (self::$with_tempsubs && is_array($_SESSION['kolab_subscribed_folders'])) { $folders = array_unique(array_merge($folders, $_SESSION['kolab_subscribed_folders'])); } } else { $folders = self::_imap_list_folders($root, $mbox); } // In case of an error, return empty list (?) if (!is_array($folders)) { return array(); } // Filter folders list foreach ($folders as $idx => $folder) { // lookup folder type if (!array_key_exists($folder, $folderdata)) { $folderdata[$folder] = self::folder_type($folder); } $type = $folderdata[$folder]; if ($filter == 'mail' && empty($type)) { continue; } if (empty($type) || !preg_match($regexp, $type)) { unset($folders[$idx]); } } return $folders; } /** * Wrapper for rcube_imap::list_folders() with optional post-filtering */ protected static function _imap_list_folders($root, $mbox) { $postfilter = null; // compose a post-filter expression for the excluded namespaces if ($root . $mbox == '*' && ($skip_ns = self::$config->get('kolab_skip_namespace'))) { $excludes = array(); foreach ((array)$skip_ns as $ns) { if ($ns_root = self::namespace_root($ns)) { $excludes[] = $ns_root; } } if (count($excludes)) { $postfilter = '!^(' . join(')|(', array_map('preg_quote', $excludes)) . ')!'; } } // use normal LIST command to return all folders, it's fast enough $folders = self::$imap->list_folders($root, $mbox, null, null, !empty($postfilter)); if (!empty($postfilter)) { $folders = array_filter($folders, function($folder) use ($postfilter) { return !preg_match($postfilter, $folder); }); $folders = self::$imap->sort_folder_list($folders); } return $folders; } /** * Search for shared or otherwise not listed groupware folders the user has access * * @param string Folder type of folders to search for * @param string Search string * @param array Namespace(s) to exclude results from * * @return array List of matching kolab_storage_folder objects */ public static function search_folders($type, $query, $exclude_ns = array()) { if (!self::setup()) { return array(); } $folders = array(); $query = str_replace('*', '', $query); // find unsubscribed IMAP folders of the given type foreach ((array)self::list_folders('', '*', $type, false, $folderdata) as $foldername) { // FIXME: only consider the last part of the folder path for searching? $realname = strtolower(rcube_charset::convert($foldername, 'UTF7-IMAP')); if (($query == '' || strpos($realname, $query) !== false) && !self::folder_is_subscribed($foldername, true) && !in_array(self::$imap->folder_namespace($foldername), (array)$exclude_ns) ) { $folders[] = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); } } return $folders; } /** * Sort the given list of kolab folders by namespace/name * * @param array List of kolab_storage_folder objects * @return array Sorted list of folders */ public static function sort_folders($folders) { $pad = ' '; $out = array(); $nsnames = array('personal' => array(), 'shared' => array(), 'other' => array()); foreach ($folders as $folder) { $folders[$folder->name] = $folder; $ns = $folder->get_namespace(); $nsnames[$ns][$folder->name] = strtolower(html_entity_decode(self::object_name($folder->name, $ns), ENT_COMPAT, RCUBE_CHARSET)) . $pad; // decode » } // $folders is a result of get_folders() we can assume folders were already sorted foreach (array_keys($nsnames) as $ns) { asort($nsnames[$ns], SORT_LOCALE_STRING); foreach (array_keys($nsnames[$ns]) as $utf7name) { $out[] = $folders[$utf7name]; } } return $out; } /** * Check the folder tree and add the missing parents as virtual folders * * @param array $folders Folders list * @param object $tree Reference to the root node of the folder tree * * @return array Flat folders list */ public static function folder_hierarchy($folders, &$tree = null) { $_folders = array(); $delim = self::$imap->get_hierarchy_delimiter(); $other_ns = rtrim(self::namespace_root('other'), $delim); $tree = new kolab_storage_folder_virtual('', '', ''); // create tree root $refs = array('' => $tree); foreach ($folders as $idx => $folder) { $path = explode($delim, $folder->name); array_pop($path); $folder->parent = join($delim, $path); $folder->children = array(); // reset list // skip top folders or ones with a custom displayname if (count($path) < 1 || kolab_storage::custom_displayname($folder->name)) { $tree->children[] = $folder; } else { $parents = array(); $depth = $folder->get_namespace() == 'personal' ? 1 : 2; while (count($path) >= $depth && ($parent = join($delim, $path))) { array_pop($path); $parent_parent = join($delim, $path); if (!$refs[$parent]) { if ($folder->type && self::folder_type($parent) == $folder->type) { $refs[$parent] = new kolab_storage_folder($parent, $folder->type, $folder->type); $refs[$parent]->parent = $parent_parent; } else if ($parent_parent == $other_ns) { $refs[$parent] = new kolab_storage_folder_user($parent, $parent_parent); } else { $name = kolab_storage::object_name($parent, $folder->get_namespace()); $refs[$parent] = new kolab_storage_folder_virtual($parent, $name, $folder->get_namespace(), $parent_parent); } $parents[] = $refs[$parent]; } } if (!empty($parents)) { $parents = array_reverse($parents); foreach ($parents as $parent) { $parent_node = $refs[$parent->parent] ?: $tree; $parent_node->children[] = $parent; $_folders[] = $parent; } } $parent_node = $refs[$folder->parent] ?: $tree; $parent_node->children[] = $folder; } $refs[$folder->name] = $folder; $_folders[] = $folder; unset($folders[$idx]); } return $_folders; } /** * Returns folder types indexed by folder name * * @param string $prefix Folder prefix (Default '*' for all folders) * * @return array|bool List of folders, False on failure */ public static function folders_typedata($prefix = '*') { if (!self::setup()) { return false; } // return cached result if (is_array(self::$typedata[$prefix])) { return self::$typedata[$prefix]; } $type_keys = array(self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE); // fetch metadata from *some* folders only if (($prefix == '*' || $prefix == '') && ($skip_ns = self::$config->get('kolab_skip_namespace'))) { $delimiter = self::$imap->get_hierarchy_delimiter(); $folderdata = $blacklist = array(); foreach ((array)$skip_ns as $ns) { if ($ns_root = rtrim(self::namespace_root($ns), $delimiter)) { $blacklist[] = $ns_root; } } foreach (array('personal','other','shared') as $ns) { if (!in_array($ns, (array)$skip_ns)) { $ns_root = rtrim(self::namespace_root($ns), $delimiter); // list top-level folders and their childs one by one // GETMETADATA "%" doesn't list shared or other namespace folders but "*" would if ($ns_root == '') { foreach ((array)self::$imap->get_metadata('%', $type_keys) as $folder => $metadata) { if (!in_array($folder, $blacklist)) { $folderdata[$folder] = $metadata; $opts = self::$imap->folder_attributes($folder); if (!in_array('\\HasNoChildren', $opts) && ($data = self::$imap->get_metadata($folder.$delimiter.'*', $type_keys))) { $folderdata += $data; } } } } else if ($data = self::$imap->get_metadata($ns_root.$delimiter.'*', $type_keys)) { $folderdata += $data; } } } } else { $folderdata = self::$imap->get_metadata($prefix, $type_keys); } if (!is_array($folderdata)) { return false; } // keep list in memory self::$typedata[$prefix] = array_map(array('kolab_storage', 'folder_select_metadata'), $folderdata); return self::$typedata[$prefix]; } /** * Callback for array_map to select the correct annotation value */ public static function folder_select_metadata($types) { if (!empty($types[self::CTYPE_KEY_PRIVATE])) { return $types[self::CTYPE_KEY_PRIVATE]; } else if (!empty($types[self::CTYPE_KEY])) { list($ctype, ) = explode('.', $types[self::CTYPE_KEY]); return $ctype; } return null; } /** * Returns type of IMAP folder * * @param string $folder Folder name (UTF7-IMAP) * * @return string Folder type */ public static function folder_type($folder) { self::setup(); // return in-memory cached result foreach (self::$typedata as $typedata) { if (array_key_exists($folder, $typedata)) { return $typedata[$folder]; } } $metadata = self::$imap->get_metadata($folder, array(self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE)); if (!is_array($metadata)) { return null; } if (!empty($metadata[$folder])) { return self::folder_select_metadata($metadata[$folder]); } return 'mail'; } /** * Sets folder content-type. * * @param string $folder Folder name * @param string $type Content type * * @return boolean True on success */ public static function set_folder_type($folder, $type='mail') { self::setup(); list($ctype, $subtype) = explode('.', $type); $success = self::$imap->set_metadata($folder, array(self::CTYPE_KEY => $ctype, self::CTYPE_KEY_PRIVATE => $subtype ? $type : null)); if (!$success) // fallback: only set private annotation $success |= self::$imap->set_metadata($folder, array(self::CTYPE_KEY_PRIVATE => $type)); return $success; } /** * Check subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Include temporary/session subscriptions * * @return boolean True if subscribed, false if not */ public static function folder_is_subscribed($folder, $temp = false) { if (self::$subscriptions === null) { self::setup(); self::$with_tempsubs = false; self::$subscriptions = self::$imap->list_folders_subscribed(); self::$with_tempsubs = true; } return in_array($folder, self::$subscriptions) || ($temp && in_array($folder, (array)$_SESSION['kolab_subscribed_folders'])); } /** * Change subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Only subscribe temporarily for the current session * * @return True on success, false on error */ public static function folder_subscribe($folder, $temp = false) { self::setup(); // temporary/session subscription if ($temp) { if (self::folder_is_subscribed($folder)) { return true; } else if (!is_array($_SESSION['kolab_subscribed_folders']) || !in_array($folder, $_SESSION['kolab_subscribed_folders'])) { $_SESSION['kolab_subscribed_folders'][] = $folder; return true; } } else if (self::$imap->subscribe($folder)) { self::$subscriptions = null; return true; } return false; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param boolean $temp Only remove temporary subscription * * @return True on success, false on error */ public static function folder_unsubscribe($folder, $temp = false) { self::setup(); // temporary/session subscription if ($temp) { if (is_array($_SESSION['kolab_subscribed_folders']) && ($i = array_search($folder, $_SESSION['kolab_subscribed_folders'])) !== false) { unset($_SESSION['kolab_subscribed_folders'][$i]); } return true; } else if (self::$imap->unsubscribe($folder)) { self::$subscriptions = null; return true; } return false; } /** * Check activation status of this folder * * @param string $folder Folder name * * @return boolean True if active, false if not */ public static function folder_is_active($folder) { $active_folders = self::get_states(); return in_array($folder, $active_folders); } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public static function folder_activate($folder) { // activation implies temporary subscription self::folder_subscribe($folder, true); return self::set_state($folder, true); } /** * Change activation status of this folder * * @param string $folder Folder name * * @return True on success, false on error */ public static function folder_deactivate($folder) { // remove from temp subscriptions, really? self::folder_unsubscribe($folder, true); return self::set_state($folder, false); } /** * Return list of active folders */ private static function get_states() { if (self::$states !== null) { return self::$states; } $rcube = rcube::get_instance(); $folders = $rcube->config->get('kolab_active_folders'); if ($folders !== null) { self::$states = !empty($folders) ? explode('**', $folders) : array(); } // for backward-compatibility copy server-side subscriptions to activation states else { self::setup(); if (self::$subscriptions === null) { self::$with_tempsubs = false; self::$subscriptions = self::$imap->list_folders_subscribed(); self::$with_tempsubs = true; } self::$states = self::$subscriptions; $folders = implode(self::$states, '**'); $rcube->user->save_prefs(array('kolab_active_folders' => $folders)); } return self::$states; } /** * Update list of active folders */ private static function set_state($folder, $state) { self::get_states(); // update in-memory list $idx = array_search($folder, self::$states); if ($state && $idx === false) { self::$states[] = $folder; } else if (!$state && $idx !== false) { unset(self::$states[$idx]); } // update user preferences $folders = implode(self::$states, '**'); $rcube = rcube::get_instance(); return $rcube->user->save_prefs(array('kolab_active_folders' => $folders)); } /** * Creates default folder of specified type * To be run when none of subscribed folders (of specified type) is found * * @param string $type Folder type * @param string $props Folder properties (color, etc) * * @return string Folder name */ public static function create_default_folder($type, $props = array()) { if (!self::setup()) { return; } $folders = self::$imap->get_metadata('*', array(kolab_storage::CTYPE_KEY_PRIVATE)); // from kolab_folders config $folder_type = strpos($type, '.') ? str_replace('.', '_', $type) : $type . '_default'; $default_name = self::$config->get('kolab_folders_' . $folder_type); $folder_type = str_replace('_', '.', $folder_type); // check if we have any folder in personal namespace // folder(s) may exist but not subscribed foreach ((array)$folders as $f => $data) { if (strpos($data[self::CTYPE_KEY_PRIVATE], $type) === 0) { $folder = $f; break; } } if (!$folder) { if (!$default_name) { $default_name = self::$default_folders[$type]; } if (!$default_name) { return; } $folder = rcube_charset::convert($default_name, RCUBE_CHARSET, 'UTF7-IMAP'); $prefix = self::$imap->get_namespace('prefix'); // add personal namespace prefix if needed if ($prefix && strpos($folder, $prefix) !== 0 && $folder != 'INBOX') { $folder = $prefix . $folder; } if (!self::$imap->folder_exists($folder)) { if (!self::$imap->create_folder($folder)) { return; } } self::set_folder_type($folder, $folder_type); } self::folder_subscribe($folder); if ($props['active']) { self::set_state($folder, true); } if (!empty($props)) { self::set_folder_props($folder, $props); } return $folder; } /** * Sets folder metadata properties * * @param string $folder Folder name * @param array $prop Folder properties */ public static function set_folder_props($folder, &$prop) { if (!self::setup()) { return; } // TODO: also save 'showalarams' and other properties here $ns = self::$imap->folder_namespace($folder); $supported = array( 'color' => array(self::COLOR_KEY_SHARED, self::COLOR_KEY_PRIVATE), 'displayname' => array(self::NAME_KEY_SHARED, self::NAME_KEY_PRIVATE), ); foreach ($supported as $key => $metakeys) { if (array_key_exists($key, $prop)) { $meta_saved = false; if ($ns == 'personal') // save in shared namespace for personal folders $meta_saved = self::$imap->set_metadata($folder, array($metakeys[0] => $prop[$key])); if (!$meta_saved) // try in private namespace $meta_saved = self::$imap->set_metadata($folder, array($metakeys[1] => $prop[$key])); if ($meta_saved) unset($prop[$key]); // unsetting will prevent fallback to local user prefs } } } /** * * @param mixed $query Search value (or array of field => value pairs) * @param int $mode Matching mode: 0 - partial (*abc*), 1 - strict (=), 2 - prefix (abc*) * @param array $required List of fields that shall ot be empty * @param int $limit Maximum number of records * @param int $count Returns the number of records found * * @return array List or false on error */ public static function search_users($query, $mode = 1, $required = array(), $limit = 0, &$count = 0) { $query = str_replace('*', '', $query); // requires a working LDAP setup if (!self::ldap() || strlen($query) == 0) { return array(); } // search users using the configured attributes $results = self::$ldap->dosearch(self::$config->get('kolab_users_search_attrib', array('cn','mail','alias')), $query, $mode, $required, $limit, $count); // exclude myself if ($_SESSION['kolab_dn']) { unset($results[$_SESSION['kolab_dn']]); } // resolve to IMAP folder name $root = self::namespace_root('other'); $user_attrib = self::$config->get('kolab_users_id_attrib', self::$config->get('kolab_auth_login', 'mail')); array_walk($results, function(&$user, $dn) use ($root, $user_attrib) { list($localpart, ) = explode('@', $user[$user_attrib]); $user['kolabtargetfolder'] = $root . $localpart; }); return $results; } /** * Returns a list of IMAP folders shared by the given user * * @param array User entry from LDAP * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param boolean Return subscribed folders only (null to use configured subscription mode) * @param array Will be filled with folder-types data * * @return array List of folders */ public static function list_user_folders($user, $type, $subscribed = null, &$folderdata = array()) { self::setup(); $folders = array(); // use localpart of user attribute as root for folder listing $user_attrib = self::$config->get('kolab_users_id_attrib', self::$config->get('kolab_auth_login', 'mail')); if (!empty($user[$user_attrib])) { list($mbox) = explode('@', $user[$user_attrib]); $delimiter = self::$imap->get_hierarchy_delimiter(); $other_ns = self::namespace_root('other'); $folders = self::list_folders($other_ns . $mbox . $delimiter, '*', $type, $subscribed, $folderdata); } return $folders; } /** * Get a list of (virtual) top-level folders from the other users namespace * * @param string Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param boolean Enable to return subscribed folders only (null to use configured subscription mode) * * @return array List of kolab_storage_folder_user objects */ public static function get_user_folders($type, $subscribed) { $folders = $folderdata = array(); if (self::setup()) { $delimiter = self::$imap->get_hierarchy_delimiter(); $other_ns = rtrim(self::namespace_root('other'), $delimiter); $path_len = count(explode($delimiter, $other_ns)); foreach ((array)self::list_folders($other_ns . $delimiter, '*', '', $subscribed) as $foldername) { if ($foldername == 'INBOX') // skip INBOX which is added by default continue; $path = explode($delimiter, $foldername); // compare folder type if a subfolder is listed if ($type && count($path) > $path_len + 1 && $type != self::folder_type($foldername)) { continue; } // truncate folder path to top-level folders of the 'other' namespace $foldername = join($delimiter, array_slice($path, 0, $path_len + 1)); if (!$folders[$foldername]) { $folders[$foldername] = new kolab_storage_folder_user($foldername, $other_ns); } } // for every (subscribed) user folder, list all (unsubscribed) subfolders foreach ($folders as $userfolder) { foreach ((array)self::list_folders($userfolder->name . $delimiter, '*', $type, false, $folderdata) as $foldername) { if (!$folders[$foldername]) { $folders[$foldername] = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); $userfolder->children[] = $folders[$foldername]; } } } } return $folders; } /** * Handler for user_delete plugin hooks * * Remove all cache data from the local database related to the given user. */ public static function delete_user_folders($args) { $db = rcmail::get_instance()->get_dbh(); $prefix = 'imap://' . urlencode($args['username']) . '@' . $args['host'] . '/%'; $db->query("DELETE FROM " . $db->table_name('kolab_folders', true) . " WHERE `resource` LIKE ?", $prefix); } } diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache.php index 227fa4e..162c220 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache.php @@ -1,1135 +1,1171 @@ * * Copyright (C) 2012-2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_cache { const DB_DATE_FORMAT = 'Y-m-d H:i:s'; public $sync_complete = false; protected $db; protected $imap; protected $folder; protected $uid2msg; protected $objects; protected $metadata = array(); protected $folder_id; protected $resource_uri; protected $enabled = true; protected $synched = false; protected $synclock = false; protected $ready = false; protected $cache_table; + protected $cache_refresh = 3600; protected $folders_table; protected $max_sql_packet; protected $max_sync_lock_time = 600; protected $binary_items = array(); protected $extra_cols = array(); protected $order_by = null; protected $limit = null; protected $error = 0; + protected $server_timezone; /** * Factory constructor */ public static function factory(kolab_storage_folder $storage_folder) { $subclass = 'kolab_storage_cache_' . $storage_folder->type; if (class_exists($subclass)) { return new $subclass($storage_folder); } else { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "No kolab_storage_cache class found for folder '$storage_folder->name' of type '$storage_folder->type'" ), true); return new kolab_storage_cache($storage_folder); } } /** * Default constructor */ public function __construct(kolab_storage_folder $storage_folder = null) { $rcmail = rcube::get_instance(); $this->db = $rcmail->get_dbh(); $this->imap = $rcmail->get_storage(); $this->enabled = $rcmail->config->get('kolab_cache', false); $this->folders_table = $this->db->table_name('kolab_folders'); + $this->cache_refresh = get_offset_sec($rcmail->config->get('kolab_cache_refresh', '12h')); + $this->server_timezone = new DateTimeZone(date_default_timezone_get()); if ($this->enabled) { // always read folder cache and lock state from DB master $this->db->set_table_dsn('kolab_folders', 'w'); // remove sync-lock on script termination $rcmail->add_shutdown_function(array($this, '_sync_unlock')); } if ($storage_folder) $this->set_folder($storage_folder); } /** * Direct access to cache by folder_id * (only for internal use) */ public function select_by_id($folder_id) { $sql_arr = $this->db->fetch_assoc($this->db->query("SELECT * FROM `{$this->folders_table}` WHERE `folder_id` = ?", $folder_id)); if ($sql_arr) { $this->metadata = $sql_arr; $this->folder_id = $sql_arr['folder_id']; $this->folder = new StdClass; $this->folder->type = $sql_arr['type']; $this->resource_uri = $sql_arr['resource']; $this->cache_table = $this->db->table_name('kolab_cache_' . $sql_arr['type']); $this->ready = true; } } /** * Connect cache with a storage folder * * @param kolab_storage_folder The storage folder instance to connect with */ public function set_folder(kolab_storage_folder $storage_folder) { $this->folder = $storage_folder; if (empty($this->folder->name) || !$this->folder->valid) { $this->ready = false; return; } // compose fully qualified ressource uri for this instance $this->resource_uri = $this->folder->get_resource_uri(); $this->cache_table = $this->db->table_name('kolab_cache_' . $this->folder->type); $this->ready = $this->enabled && !empty($this->folder->type); $this->folder_id = null; } /** * Returns true if this cache supports query by type */ public function has_type_col() { return in_array('type', $this->extra_cols); } /** * Getter for the numeric ID used in cache tables */ public function get_folder_id() { $this->_read_folder_data(); return $this->folder_id; } /** * Returns code of last error * * @return int Error code */ public function get_error() { return $this->error; } /** * Synchronize local cache data with remote */ public function synchronize() { // only sync once per request cycle if ($this->synched) return; // increase time limit @set_time_limit($this->max_sync_lock_time - 60); // get effective time limit we have for synchronization (~70% of the execution time) $time_limit = ini_get('max_execution_time') * 0.7; $sync_start = time(); // assume sync will be completed $this->sync_complete = true; if (!$this->ready) { // kolab cache is disabled, synchronize IMAP mailbox cache only $this->imap->folder_sync($this->folder->name); } else { // read cached folder metadata $this->_read_folder_data(); - // check cache status hash first ($this->metadata is set in _read_folder_data()) - if ($this->metadata['ctag'] != $this->folder->get_ctag()) { + // check cache status ($this->metadata is set in _read_folder_data()) + if ( empty($this->metadata['ctag']) || + empty($this->metadata['changed']) || + $this->metadata['objectcount'] === null || + $this->metadata['changed'] < date(self::DB_DATE_FORMAT, time() - $this->cache_refresh) || + $this->metadata['ctag'] != $this->folder->get_ctag() || + intval($this->metadata['objectcount']) !== $this->count() + ) { // lock synchronization for this folder or wait if locked $this->_sync_lock(); // disable messages cache if configured to do so $this->bypass(true); // synchronize IMAP mailbox cache $this->imap->folder_sync($this->folder->name); // compare IMAP index with object cache index $imap_index = $this->imap->index($this->folder->name, null, null, true, true); // determine objects to fetch or to invalidate if (!$imap_index->is_error()) { $imap_index = $imap_index->get(); // read cache index $sql_result = $this->db->query( "SELECT `msguid`, `uid` FROM `{$this->cache_table}` WHERE `folder_id` = ?", $this->folder_id ); $old_index = array(); while ($sql_arr = $this->db->fetch_assoc($sql_result)) { $old_index[] = $sql_arr['msguid']; } // fetch new objects from imap $i = 0; foreach (array_diff($imap_index, $old_index) as $msguid) { if ($object = $this->folder->read_object($msguid, '*')) { $this->_extended_insert($msguid, $object); // check time limit and abort sync if running too long if (++$i % 50 == 0 && time() - $sync_start > $time_limit) { $this->sync_complete = false; break; } } } $this->_extended_insert(0, null); // delete invalid entries from local DB $del_index = array_diff($old_index, $imap_index); if (!empty($del_index)) { $quoted_ids = join(',', array_map(array($this->db, 'quote'), $del_index)); $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `msguid` IN ($quoted_ids)", $this->folder_id ); } // update ctag value (will be written to database in _sync_unlock()) if ($this->sync_complete) { $this->metadata['ctag'] = $this->folder->get_ctag(); + $this->metadata['changed'] = date(self::DB_DATE_FORMAT, time()); + // remember the number of cache entries linked to this folder + $this->metadata['objectcount'] = $this->count(); } } $this->bypass(false); // remove lock $this->_sync_unlock(); } } $this->check_error(); $this->synched = time(); } /** * Read a single entry from cache or from IMAP directly * * @param string Related IMAP message UID * @param string Object type to read * @param string IMAP folder name the entry relates to * @param array Hash array with object properties or null if not found */ public function get($msguid, $type = null, $foldername = null) { // delegate to another cache instance if ($foldername && $foldername != $this->folder->name) { $success = false; if ($targetfolder = kolab_storage::get_folder($foldername)) { $success = $targetfolder->cache->get($msguid, $type); $this->error = $targetfolder->cache->get_error(); } return $success; } // load object if not in memory if (!isset($this->objects[$msguid])) { if ($this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT * FROM `{$this->cache_table}` ". "WHERE `folder_id` = ? AND `msguid` = ?", $this->folder_id, $msguid ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $this->objects = array($msguid => $this->_unserialize($sql_arr)); // store only this object in memory (#2827) } } // fetch from IMAP if not present in cache if (empty($this->objects[$msguid])) { if ($object = $this->folder->read_object($msguid, $type ?: '*', $foldername)) { $this->objects = array($msguid => $object); $this->set($msguid, $object); } } } $this->check_error(); return $this->objects[$msguid]; } /** * Insert/Update a cache entry * * @param string Related IMAP message UID * @param mixed Hash array with object properties to save or false to delete the cache entry * @param string IMAP folder name the entry relates to */ public function set($msguid, $object, $foldername = null) { if (!$msguid) { return; } // delegate to another cache instance if ($foldername && $foldername != $this->folder->name) { if ($targetfolder = kolab_storage::get_folder($foldername)) { $targetfolder->cache->set($msguid, $object); $this->error = $targetfolder->cache->get_error(); } return; } // remove old entry if ($this->ready) { $this->_read_folder_data(); $this->db->query("DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `msguid` = ?", $this->folder_id, $msguid); } if ($object) { // insert new object data... $this->save($msguid, $object); } else { // ...or set in-memory cache to false $this->objects[$msguid] = $object; } $this->check_error(); } /** * Insert (or update) a cache entry * * @param int Related IMAP message UID * @param mixed Hash array with object properties to save or false to delete the cache entry * @param int Optional old message UID (for update) */ public function save($msguid, $object, $olduid = null) { // write to cache if ($this->ready) { $this->_read_folder_data(); $sql_data = $this->_serialize($object); $sql_data['folder_id'] = $this->folder_id; $sql_data['msguid'] = $msguid; $sql_data['uid'] = $object['uid']; $args = array(); $cols = array('folder_id', 'msguid', 'uid', 'changed', 'data', 'xml', 'tags', 'words'); $cols = array_merge($cols, $this->extra_cols); foreach ($cols as $idx => $col) { $cols[$idx] = $this->db->quote_identifier($col); $args[] = $sql_data[$col]; } if ($olduid) { foreach ($cols as $idx => $col) { $cols[$idx] = "$col = ?"; } $query = "UPDATE `{$this->cache_table}` SET " . implode(', ', $cols) . " WHERE `folder_id` = ? AND `msguid` = ?"; $args[] = $this->folder_id; $args[] = $olduid; } else { $query = "INSERT INTO `{$this->cache_table}` (`created`, " . implode(', ', $cols) . ") VALUES (" . $this->db->now() . str_repeat(', ?', count($cols)) . ")"; } $result = $this->db->query($query, $args); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "Failed to write to kolab cache" ), true); } } // keep a copy in memory for fast access $this->objects = array($msguid => $object); $this->uid2msg = array($object['uid'] => $msguid); $this->check_error(); } /** * Move an existing cache entry to a new resource * * @param string Entry's IMAP message UID * @param string Entry's Object UID * @param object kolab_storage_folder Target storage folder instance */ public function move($msguid, $uid, $target) { if ($this->ready) { // clear cached uid mapping and force new lookup unset($target->cache->uid2msg[$uid]); // resolve new message UID in target folder if ($new_msguid = $target->cache->uid2msguid($uid)) { $this->_read_folder_data(); $this->db->query( "UPDATE `{$this->cache_table}` SET `folder_id` = ?, `msguid` = ? ". "WHERE `folder_id` = ? AND `msguid` = ?", $target->cache->get_folder_id(), $new_msguid, $this->folder_id, $msguid ); $result = $this->db->affected_rows(); } } if (empty($result)) { // just clear cache entry $this->set($msguid, false); } unset($this->uid2msg[$uid]); $this->check_error(); } /** * Remove all objects from local cache */ public function purge() { if (!$this->ready) { return true; } $this->_read_folder_data(); $result = $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ?", $this->folder_id ); return $this->db->affected_rows($result); } /** * Update resource URI for existing cache entries * * @param string Target IMAP folder to move it to */ public function rename($new_folder) { if (!$this->ready) { return; } if ($target = kolab_storage::get_folder($new_folder)) { // resolve new message UID in target folder $this->db->query( "UPDATE `{$this->folders_table}` SET `resource` = ? ". "WHERE `resource` = ?", $target->get_resource_uri(), $this->resource_uri ); $this->check_error(); } else { $this->error = kolab_storage::ERROR_IMAP_CONN; } } /** * Select Kolab objects filtered by the given query * * @param array Pseudo-SQL query as list of filter parameter triplets * triplet: array('', '', '') * @param boolean Set true to only return UIDs instead of complete objects * @return array List of Kolab data objects (each represented as hash array) or UIDs */ public function select($query = array(), $uids = false) { $result = $uids ? array() : new kolab_storage_dataset($this); // read from local cache DB (assume it to be synchronized) if ($this->ready) { $this->_read_folder_data(); // fetch full object data on one query if a small result set is expected - $fetchall = !$uids && ($this->limit ? $this->limit[0] : $this->count($query)) < 500; - $sql_query = "SELECT " . ($fetchall ? '*' : '`msguid` AS `_msguid`, `uid`') . " FROM `{$this->cache_table}` ". - "WHERE `folder_id` = ? " . $this->_sql_where($query); - if (!empty($this->order_by)) { - $sql_query .= ' ORDER BY ' . $this->order_by; + $fetchall = !$uids && ($this->limit ? $this->limit[0] : ($count = $this->count($query))) < 500; + + // skip SELECT if we know it will return nothing + if ($count === 0) { + return $result; } + + $sql_query = "SELECT " . ($fetchall ? '*' : "`msguid` AS `_msguid`, `uid`") + . " FROM `{$this->cache_table}` WHERE `folder_id` = ?" + . $this->_sql_where($query) + . (!empty($this->order_by) ? " ORDER BY " . $this->order_by : ''); + $sql_result = $this->limit ? $this->db->limitquery($sql_query, $this->limit[1], $this->limit[0], $this->folder_id) : $this->db->query($sql_query, $this->folder_id); if ($this->db->is_error($sql_result)) { if ($uids) { return null; } $result->set_error(true); return $result; } while ($sql_arr = $this->db->fetch_assoc($sql_result)) { if ($uids) { $this->uid2msg[$sql_arr['uid']] = $sql_arr['_msguid']; $result[] = $sql_arr['uid']; } else if ($fetchall && ($object = $this->_unserialize($sql_arr))) { $result[] = $object; } else if (!$fetchall) { // only add msguid to dataset index $result[] = $sql_arr; } } } // use IMAP else { $filter = $this->_query2assoc($query); if ($filter['type']) { $search = 'UNDELETED HEADER X-Kolab-Type ' . kolab_format::KTYPE_PREFIX . $filter['type']; $index = $this->imap->search_once($this->folder->name, $search); } else { $index = $this->imap->index($this->folder->name, null, null, true, true); } if ($index->is_error()) { $this->check_error(); if ($uids) { return null; } $result->set_error(true); return $result; } $index = $index->get(); $result = $uids ? $index : $this->_fetch($index, $filter['type']); // TODO: post-filter result according to query } // We don't want to cache big results in-memory, however // if we select only one object here, there's a big chance we will need it later if (!$uids && count($result) == 1) { if ($msguid = $result[0]['_msguid']) { $this->uid2msg[$result[0]['uid']] = $msguid; $this->objects = array($msguid => $result[0]); } } $this->check_error(); return $result; } /** * Get number of objects mathing the given query * * @param array $query Pseudo-SQL query as list of filter parameter triplets * @return integer The number of objects of the given type */ public function count($query = array()) { // read from local cache DB (assume it to be synchronized) if ($this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` ". "WHERE `folder_id` = ?" . $this->_sql_where($query), $this->folder_id ); if ($this->db->is_error($sql_result)) { return null; } $sql_arr = $this->db->fetch_assoc($sql_result); $count = intval($sql_arr['numrows']); } // use IMAP else { $filter = $this->_query2assoc($query); if ($filter['type']) { $search = 'UNDELETED HEADER X-Kolab-Type ' . kolab_format::KTYPE_PREFIX . $filter['type']; $index = $this->imap->search_once($this->folder->name, $search); } else { $index = $this->imap->index($this->folder->name, null, null, true, true); } if ($index->is_error()) { $this->check_error(); return null; } // TODO: post-filter result according to query $count = $index->count(); } $this->check_error(); return $count; } /** * Define ORDER BY clause for cache queries */ public function set_order_by($sortcols) { if (!empty($sortcols)) { $this->order_by = '`' . join('`, `', (array)$sortcols) . '`'; } else { $this->order_by = null; } } /** * Define LIMIT clause for cache queries */ public function set_limit($length, $offset = 0) { $this->limit = array($length, $offset); } /** * Helper method to compose a valid SQL query from pseudo filter triplets */ protected function _sql_where($query) { $sql_where = ''; foreach ((array) $query as $param) { if (is_array($param[0])) { $subq = array(); foreach ($param[0] as $q) { $subq[] = preg_replace('/^\s*AND\s+/i', '', $this->_sql_where(array($q))); } if (!empty($subq)) { $sql_where .= ' AND (' . implode($param[1] == 'OR' ? ' OR ' : ' AND ', $subq) . ')'; } continue; } else if ($param[1] == '=' && is_array($param[2])) { $qvalue = '(' . join(',', array_map(array($this->db, 'quote'), $param[2])) . ')'; $param[1] = 'IN'; } else if ($param[1] == '~' || $param[1] == 'LIKE' || $param[1] == '!~' || $param[1] == '!LIKE') { $not = ($param[1] == '!~' || $param[1] == '!LIKE') ? 'NOT ' : ''; $param[1] = $not . 'LIKE'; $qvalue = $this->db->quote('%'.preg_replace('/(^\^|\$$)/', ' ', $param[2]).'%'); } else if ($param[0] == 'tags') { $param[1] = ($param[1] == '!=' ? 'NOT ' : '' ) . 'LIKE'; $qvalue = $this->db->quote('% '.$param[2].' %'); } else { $qvalue = $this->db->quote($param[2]); } $sql_where .= sprintf(' AND %s %s %s', $this->db->quote_identifier($param[0]), $param[1], $qvalue ); } return $sql_where; } /** * Helper method to convert the given pseudo-query triplets into * an associative filter array with 'equals' values only */ protected function _query2assoc($query) { // extract object type from query parameter $filter = array(); foreach ($query as $param) { if ($param[1] == '=') $filter[$param[0]] = $param[2]; } return $filter; } /** * Fetch messages from IMAP * * @param array List of message UIDs to fetch * @param string Requested object type or * for all * @param string IMAP folder to read from * @return array List of parsed Kolab objects */ protected function _fetch($index, $type = null, $folder = null) { $results = new kolab_storage_dataset($this); foreach ((array)$index as $msguid) { if ($object = $this->folder->read_object($msguid, $type, $folder)) { $results[] = $object; $this->set($msguid, $object); } } return $results; } /** * Helper method to convert the given Kolab object into a dataset to be written to cache */ protected function _serialize($object) { $sql_data = array('changed' => null, 'xml' => '', 'tags' => '', 'words' => ''); if ($object['changed']) { - $sql_data['changed'] = date('Y-m-d H:i:s', is_object($object['changed']) ? $object['changed']->format('U') : $object['changed']); + $sql_data['changed'] = date(self::DB_DATE_FORMAT, is_object($object['changed']) ? $object['changed']->format('U') : $object['changed']); } if ($object['_formatobj']) { $sql_data['xml'] = preg_replace('!()[\n\r\t\s]+!ms', '$1', (string)$object['_formatobj']->write(3.0)); $sql_data['tags'] = ' ' . join(' ', $object['_formatobj']->get_tags()) . ' '; // pad with spaces for strict/prefix search $sql_data['words'] = ' ' . join(' ', $object['_formatobj']->get_words()) . ' '; } // extract object data $data = array(); foreach ($object as $key => $val) { // skip empty properties if ($val === "" || $val === null) { continue; } // mark binary data to be extracted from xml on unserialize() if (isset($this->binary_items[$key])) { $data[$key] = true; } else if ($key[0] != '_') { $data[$key] = $val; } else if ($key == '_attachments') { foreach ($val as $k => $att) { unset($att['content'], $att['path']); if ($att['id']) $data[$key][$k] = $att; } } } // use base64 encoding (Bug #1912, #2662) $sql_data['data'] = base64_encode(serialize($data)); return $sql_data; } /** * Helper method to turn stored cache data into a valid storage object */ protected function _unserialize($sql_arr) { // check if data is a base64-encoded string, for backward compat. if (strpos(substr($sql_arr['data'], 0, 64), ':') === false) { $sql_arr['data'] = base64_decode($sql_arr['data']); } $object = unserialize($sql_arr['data']); // de-serialization failed if ($object === false) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "Malformed data for {$this->resource_uri}/{$sql_arr['msguid']} object." ), true); return null; } // decode binary properties foreach ($this->binary_items as $key => $regexp) { if (!empty($object[$key]) && preg_match($regexp, $sql_arr['xml'], $m)) { $object[$key] = base64_decode($m[1]); } } $object_type = $sql_arr['type'] ?: $this->folder->type; $format_type = $this->folder->type == 'configuration' ? 'configuration' : $object_type; // add meta data $object['_type'] = $object_type; $object['_msguid'] = $sql_arr['msguid']; $object['_mailbox'] = $this->folder->name; $object['_size'] = strlen($sql_arr['xml']); $object['_formatobj'] = kolab_format::factory($format_type, 3.0, $sql_arr['xml']); return $object; } /** * Write records into cache using extended inserts to reduce the number of queries to be executed * * @param int Message UID. Set 0 to commit buffered inserts * @param array Kolab object to cache */ protected function _extended_insert($msguid, $object) { static $buffer = ''; $line = ''; if ($object) { $sql_data = $this->_serialize($object); // Skip multifolder insert for Oracle, we can't put long data inline if ($this->db->db_provider == 'oracle') { $extra_cols = ''; if ($this->extra_cols) { $extra_cols = array_map(function($n) { return "`{$n}`"; }, $this->extra_cols); $extra_cols = ', ' . join(', ', $extra_cols); $extra_args = str_repeat(', ?', count($this->extra_cols)); } $params = array($this->folder_id, $msguid, $object['uid'], $sql_data['changed'], $sql_data['data'], $sql_data['xml'], $sql_data['tags'], $sql_data['words']); foreach ($this->extra_cols as $col) { $params[] = $sql_data[$col]; } $result = $this->db->query( "INSERT INTO `{$this->cache_table}` " . " (`folder_id`, `msguid`, `uid`, `created`, `changed`, `data`, `xml`, `tags`, `words` $extra_cols)" . " VALUES (?, ?, ?, " . $this->db->now() . ", ?, ?, ?, ?, ? $extra_args)", $params ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "Failed to write to kolab cache" ), true); } return; } $values = array( $this->db->quote($this->folder_id), $this->db->quote($msguid), $this->db->quote($object['uid']), $this->db->now(), $this->db->quote($sql_data['changed']), $this->db->quote($sql_data['data']), $this->db->quote($sql_data['xml']), $this->db->quote($sql_data['tags']), $this->db->quote($sql_data['words']), ); foreach ($this->extra_cols as $col) { $values[] = $this->db->quote($sql_data[$col]); } $line = '(' . join(',', $values) . ')'; } if ($buffer && (!$msguid || (strlen($buffer) + strlen($line) > $this->max_sql_packet()))) { $extra_cols = ''; if ($this->extra_cols) { $extra_cols = array_map(function($n) { return "`{$n}`"; }, $this->extra_cols); $extra_cols = ', ' . join(', ', $extra_cols); } $result = $this->db->query( "INSERT INTO `{$this->cache_table}` ". " (`folder_id`, `msguid`, `uid`, `created`, `changed`, `data`, `xml`, `tags`, `words` $extra_cols)". " VALUES $buffer" ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "Failed to write to kolab cache" ), true); } $buffer = ''; } $buffer .= ($buffer ? ',' : '') . $line; } /** * Returns max_allowed_packet from mysql config */ protected function max_sql_packet() { if (!$this->max_sql_packet) { // mysql limit or max 4 MB $value = $this->db->get_variable('max_allowed_packet', 1048500); $this->max_sql_packet = min($value, 4*1024*1024) - 2000; } return $this->max_sql_packet; } /** * Read this folder's ID and cache metadata */ protected function _read_folder_data() { // already done if (!empty($this->folder_id) || !$this->ready) return; $sql_arr = $this->db->fetch_assoc($this->db->query( - "SELECT `folder_id`, `synclock`, `ctag`" + "SELECT `folder_id`, `synclock`, `ctag`, `changed`, `objectcount`" . " FROM `{$this->folders_table}` WHERE `resource` = ?", $this->resource_uri )); if ($sql_arr) { $this->metadata = $sql_arr; $this->folder_id = $sql_arr['folder_id']; } else { $this->db->query("INSERT INTO `{$this->folders_table}` (`resource`, `type`)" . " VALUES (?, ?)", $this->resource_uri, $this->folder->type); $this->folder_id = $this->db->insert_id('kolab_folders'); $this->metadata = array(); } } /** * Check lock record for this folder and wait if locked or set lock */ protected function _sync_lock() { if (!$this->ready) return; $this->_read_folder_data(); // abort if database is not set-up if ($this->db->is_error()) { $this->check_error(); $this->ready = false; return; } $read_query = "SELECT `synclock`, `ctag` FROM `{$this->folders_table}` WHERE `folder_id` = ?"; $write_query = "UPDATE `{$this->folders_table}` SET `synclock` = ? WHERE `folder_id` = ? AND `synclock` = ?"; // wait if locked (expire locks after 10 minutes) ... // ... or if setting lock fails (another process meanwhile set it) while ( (intval($this->metadata['synclock']) + $this->max_sync_lock_time > time()) || (($res = $this->db->query($write_query, time(), $this->folder_id, intval($this->metadata['synclock']))) && !($affected = $this->db->affected_rows($res))) ) { usleep(500000); $this->metadata = $this->db->fetch_assoc($this->db->query($read_query, $this->folder_id)); } $this->synclock = $affected > 0; } /** * Remove lock for this folder */ public function _sync_unlock() { if (!$this->ready || !$this->synclock) return; $this->db->query( - "UPDATE `{$this->folders_table}` SET `synclock` = 0, `ctag` = ? WHERE `folder_id` = ?", + "UPDATE `{$this->folders_table}` SET `synclock` = 0, `ctag` = ?, `changed` = ?, `objectcount` = ? WHERE `folder_id` = ?", $this->metadata['ctag'], + $this->metadata['changed'], + $this->metadata['objectcount'], $this->folder_id ); $this->synclock = false; } /** * Check IMAP connection error state */ protected function check_error() { if (($err_code = $this->imap->get_error_code()) < 0) { $this->error = kolab_storage::ERROR_IMAP_CONN; if (($res_code = $this->imap->get_response_code()) !== 0 && in_array($res_code, array(rcube_storage::NOPERM, rcube_storage::READONLY))) { $this->error = kolab_storage::ERROR_NO_PERMISSION; } } else if ($this->db->is_error()) { $this->error = kolab_storage::ERROR_CACHE_DB; } } /** * Resolve an object UID into an IMAP message UID * * @param string Kolab object UID * @param boolean Include deleted objects * @return int The resolved IMAP message UID */ public function uid2msguid($uid, $deleted = false) { // query local database if available if (!isset($this->uid2msg[$uid]) && $this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT `msguid` FROM `{$this->cache_table}` ". "WHERE `folder_id` = ? AND `uid` = ? ORDER BY `msguid` DESC", $this->folder_id, $uid ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $this->uid2msg[$uid] = $sql_arr['msguid']; } } if (!isset($this->uid2msg[$uid])) { // use IMAP SEARCH to get the right message $index = $this->imap->search_once($this->folder->name, ($deleted ? '' : 'UNDELETED ') . 'HEADER SUBJECT ' . rcube_imap_generic::escape($uid)); $results = $index->get(); $this->uid2msg[$uid] = end($results); } return $this->uid2msg[$uid]; } /** * Getter for protected member variables */ public function __get($name) { if ($name == 'folder_id') { $this->_read_folder_data(); } return $this->$name; } /** * Bypass Roundcube messages cache. * Roundcube cache duplicates information already stored in kolab_cache. * * @param bool $disable True disables, False enables messages cache */ public function bypass($disable = false) { // if kolab cache is disabled do nothing if (!$this->enabled) { return; } static $messages_cache, $cache_bypass; if ($messages_cache === null) { $rcmail = rcube::get_instance(); $messages_cache = (bool) $rcmail->config->get('messages_cache'); $cache_bypass = (int) $rcmail->config->get('kolab_messages_cache_bypass'); } if ($messages_cache) { // handle recurrent (multilevel) bypass() calls if ($disable) { $this->cache_bypassed += 1; if ($this->cache_bypassed > 1) { return; } } else { $this->cache_bypassed -= 1; if ($this->cache_bypassed > 0) { return; } } switch ($cache_bypass) { case 2: // Disable messages cache completely $this->imap->set_messages_caching(!$disable); break; case 1: // We'll disable messages cache, but keep index cache. // Default mode is both (MODE_INDEX | MODE_MESSAGE) $mode = rcube_imap_cache::MODE_INDEX; if (!$disable) { $mode |= rcube_imap_cache::MODE_MESSAGE; } $this->imap->set_messages_caching(true, $mode); } } } + /** + * Converts DateTime or unix timestamp into sql date format + * using server timezone. + */ + protected function _convert_datetime($datetime) + { + if (is_object($datetime)) { + $dt = clone $datetime; + $dt->setTimeZone($this->server_timezone); + return $dt->format(self::DB_DATE_FORMAT); + } + else if ($datetime) { + return date(self::DB_DATE_FORMAT, $datetime); + } + } } diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_contact.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_contact.php index 9666a39..d526a0e 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_contact.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_contact.php @@ -1,59 +1,64 @@ * * Copyright (C) 2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_cache_contact extends kolab_storage_cache { protected $extra_cols = array('type','name','firstname','surname','email'); protected $binary_items = array( 'photo' => '|[^;]+;base64,([^<]+)|i', - 'pgppublickey' => '|date:application/pgp-keys;base64,([^<]+)|i', - 'pkcs7publickey' => '|date:application/pkcs7-mime;base64,([^<]+)|i', + 'pgppublickey' => '|data:application/pgp-keys;base64,([^<]+)|i', + 'pkcs7publickey' => '|data:application/pkcs7-mime;base64,([^<]+)|i', ); /** * 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']; // 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']); 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'] = ''; } + // use organization if name is empty + if (empty($sql_data['name']) && !empty($object['organization'])) { + $sql_data['name'] = rcube_charset::clean($object['organization']); + } + return $sql_data; } } \ No newline at end of file diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_event.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_event.php index 5fc44cd..ae9c693 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_event.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_event.php @@ -1,49 +1,67 @@ * * Copyright (C) 2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_cache_event extends kolab_storage_cache { protected $extra_cols = array('dtstart','dtend'); /** * Helper method to convert the given Kolab object into a dataset to be written to cache * * @override */ protected function _serialize($object) { $sql_data = parent::_serialize($object); - $sql_data['dtstart'] = is_object($object['start']) ? $object['start']->format(self::DB_DATE_FORMAT) : date(self::DB_DATE_FORMAT, $object['start']); - $sql_data['dtend'] = is_object($object['end']) ? $object['end']->format(self::DB_DATE_FORMAT) : date(self::DB_DATE_FORMAT, $object['end']); + $sql_data['dtstart'] = $this->_convert_datetime($object['start']); + $sql_data['dtend'] = $this->_convert_datetime($object['end']); // extend date range for recurring events if ($object['recurrence'] && $object['_formatobj']) { $recurrence = new kolab_date_recurrence($object['_formatobj']); $dtend = $recurrence->end() ?: new DateTime('now +10 years'); - $sql_data['dtend'] = $dtend->format(self::DB_DATE_FORMAT); + $sql_data['dtend'] = $this->_convert_datetime($dtend); + } + + // extend start/end dates to spawn all exceptions + if (is_array($object['exceptions'])) { + foreach ($object['exceptions'] as $exception) { + if (is_a($exception['start'], 'DateTime')) { + $exstart = $this->_convert_datetime($exception['start']); + if ($exstart < $sql_data['dtstart']) { + $sql_data['dtstart'] = $exstart; + } + } + if (is_a($exception['end'], 'DateTime')) { + $exend = $this->_convert_datetime($exception['end']); + if ($exend > $sql_data['dtend']) { + $sql_data['dtend'] = $exend; + } + } + } } return $sql_data; } -} \ No newline at end of file +} diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_freebusy.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_freebusy.php index d8ab554..50e4804 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_freebusy.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_freebusy.php @@ -1,27 +1,42 @@ * * Copyright (C) 2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_cache_freebusy extends kolab_storage_cache { - protected $extra_cols = array('dtstart','dtend'); -} \ No newline at end of file + protected $extra_cols = array('dtstart','dtend'); + + /** + * 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) + array('dtstart' => null, 'dtend' => null); + + $sql_data['dtstart'] = $this->_convert_datetime($object['start']); + $sql_data['dtend'] = $this->_convert_datetime($object['end']); + + return $sql_data; + } +} diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_journal.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_journal.php index a63577b..766f1da 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_journal.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_journal.php @@ -1,28 +1,42 @@ * * Copyright (C) 2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_cache_journal extends kolab_storage_cache { protected $extra_cols = array('dtstart','dtend'); - -} \ No newline at end of file + + /** + * 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) + array('dtstart' => null, 'dtend' => null); + + $sql_data['dtstart'] = $this->_convert_datetime($object['start']); + $sql_data['dtend'] = $this->_convert_datetime($object['end']); + + return $sql_data; + } +} diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_task.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_task.php index 7bf5c79..8b714e6 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_task.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_cache_task.php @@ -1,44 +1,42 @@ * * Copyright (C) 2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_cache_task extends kolab_storage_cache { protected $extra_cols = array('dtstart','dtend'); /** * 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) + array('dtstart' => null, 'dtend' => null); + $sql_data = parent::_serialize($object); - if ($object['start']) - $sql_data['dtstart'] = is_object($object['start']) ? $object['start']->format(self::DB_DATE_FORMAT) : date(self::DB_DATE_FORMAT, $object['start']); - if ($object['due']) - $sql_data['dtend'] = is_object($object['due']) ? $object['due']->format(self::DB_DATE_FORMAT) : date(self::DB_DATE_FORMAT, $object['due']); + $sql_data['dtstart'] = $this->_convert_datetime($object['start']); + $sql_data['dtend'] = $this->_convert_datetime($object['due']); return $sql_data; } -} \ No newline at end of file +} diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_config.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_config.php index 036b827..e843e97 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_config.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_config.php @@ -1,866 +1,918 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_config { const FOLDER_TYPE = 'configuration'; /** * Singleton instace of kolab_storage_config * * @var kolab_storage_config */ static protected $instance; private $folders; private $default; private $enabled; + private $tags; /** * This implements the 'singleton' design pattern * * @return kolab_storage_config The one and only instance */ static function get_instance() { if (!self::$instance) { self::$instance = new kolab_storage_config(); } return self::$instance; } /** * Private constructor (finds default configuration folder as a config source) */ private function __construct() { // get all configuration folders $this->folders = kolab_storage::get_folders(self::FOLDER_TYPE, false); foreach ($this->folders as $folder) { if ($folder->default) { $this->default = $folder; break; } } // if no folder is set as default, choose the first one if (!$this->default) { $this->default = reset($this->folders); } // attempt to create a default folder if it does not exist if (!$this->default) { $folder_name = 'Configuration'; $folder_type = self::FOLDER_TYPE . '.default'; if (kolab_storage::folder_create($folder_name, $folder_type, true)) { $this->default = new kolab_storage_folder($folder_name, $folder_type); } } // check if configuration folder exist if ($this->default && $this->default->name) { $this->enabled = true; } } /** * Check wether any configuration storage (folder) exists * * @return bool */ public function is_enabled() { return $this->enabled; } /** * Get configuration objects * * @param array $filter Search filter * @param bool $default Enable to get objects only from default folder * @param int $limit Max. number of records (per-folder) * * @return array List of objects */ public function get_objects($filter = array(), $default = false, $limit = 0) { $list = array(); foreach ($this->folders as $folder) { // we only want to read from default folder if ($default && !$folder->default) { continue; } // for better performance it's good to assume max. number of records if ($limit) { $folder->set_order_and_limit(null, $limit); } foreach ($folder->select($filter) as $object) { unset($object['_formatobj']); $list[] = $object; } } return $list; } /** * Get configuration object * * @param string $uid Object UID * @param bool $default Enable to get objects only from default folder * * @return array Object data */ public function get_object($uid, $default = false) { foreach ($this->folders as $folder) { // we only want to read from default folder if ($default && !$folder->default) { continue; } if ($object = $folder->get_object($uid)) { return $object; } } } /** * Create/update configuration object * * @param array $object Object data * @param string $type Object type * * @return bool True on success, False on failure */ public function save(&$object, $type) { if (!$this->enabled) { return false; } $folder = $this->find_folder($object); if ($type) { $object['type'] = $type; } - return $folder->save($object, self::FOLDER_TYPE . '.' . $object['type'], $object['uid']); + $status = $folder->save($object, self::FOLDER_TYPE . '.' . $object['type'], $object['uid']); + + // on success, update cached tags list + if ($status && is_array($this->tags)) { + $found = false; + unset($object['_formatobj']); // we don't need it anymore + + foreach ($this->tags as $idx => $tag) { + if ($tag['uid'] == $object['uid']) { + $found = true; + $this->tags[$idx] = $object; + } + } + + if (!$found) { + $this->tags[] = $object; + } + } + + return !empty($status); } /** * Remove configuration object * * @param string $uid Object UID * * @return bool True on success, False on failure */ public function delete($uid) { if (!$this->enabled) { return false; } // fetch the object to find folder $object = $this->get_object($uid); if (!$object) { return false; } $folder = $this->find_folder($object); + $status = $folder->delete($uid); + + // on success, update cached tags list + if ($status && is_array($this->tags)) { + foreach ($this->tags as $idx => $tag) { + if ($tag['uid'] == $uid) { + unset($this->tags[$idx]); + break; + } + } + } - return $folder->delete($uid); + return $status; } /** * Find folder */ public function find_folder($object = array()) { // find folder object if ($object['_mailbox']) { foreach ($this->folders as $folder) { if ($folder->name == $object['_mailbox']) { break; } } } else { $folder = $this->default; } return $folder; } /** * Builds relation member URI * * @param string|array Object UUID or Message folder, UID, Search headers (Message-Id, Date) * * @return string $url Member URI */ public static function build_member_url($params) { // param is object UUID if (is_string($params) && !empty($params)) { return 'urn:uuid:' . $params; } if (empty($params) || !strlen($params['folder'])) { return null; } $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); + list($username, $domain) = explode('@', $rcube->get_user_name()); + + if (strlen($domain)) { + $domain = '@' . $domain; + } // modify folder spec. according to namespace $folder = $params['folder']; $ns = $storage->folder_namespace($folder); if ($ns == 'shared') { // Note: this assumes there's only one shared namespace root if ($ns = $storage->get_namespace('shared')) { if ($prefix = $ns[0][0]) { - $folder = 'shared' . substr($folder, strlen($prefix)); + $folder = substr($folder, strlen($prefix)); } } } else { if ($ns == 'other') { // Note: this assumes there's only one other users namespace root - if ($ns = $storage->get_namespace('shared')) { + if ($ns = $storage->get_namespace('other')) { if ($prefix = $ns[0][0]) { - $folder = 'user' . substr($folder, strlen($prefix)); + list($otheruser, $path) = explode('/', substr($folder, strlen($prefix)), 2); + $folder = 'user/' . $otheruser . $domain . '/' . $path; } } } else { - $folder = 'user' . '/' . $rcube->get_user_name() . '/' . $folder; + $folder = 'user/' . $username . $domain . '/' . $folder; } } $folder = implode('/', array_map('rawurlencode', explode('/', $folder))); // build URI $url = 'imap:///' . $folder; // UID is optional here because sometimes we want // to build just a member uri prefix if ($params['uid']) { $url .= '/' . $params['uid']; } unset($params['folder']); unset($params['uid']); if (!empty($params)) { $url .= '?' . http_build_query($params, '', '&'); } return $url; } /** * Parses relation member string * * @param string $url Member URI * * @return array Message folder, UID, Search headers (Message-Id, Date) */ public static function parse_member_url($url) { // Look for IMAP URI: // imap:///(user/username@domain|shared)//? if (strpos($url, 'imap:///') === 0) { $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); // parse_url does not work with imap:/// prefix $url = parse_url(substr($url, 8)); $path = explode('/', $url['path']); parse_str($url['query'], $params); $uid = array_pop($path); $ns = array_shift($path); $path = array_map('rawurldecode', $path); // resolve folder name - if ($ns == 'shared') { - $folder = implode('/', $path); - // Note: this assumes there's only one shared namespace root - if ($ns = $storage->get_namespace('shared')) { - if ($prefix = $ns[0][0]) { - $folder = $prefix . '/' . $folder; - } - } - } - else if ($ns == 'user') { + if ($ns == 'user') { $username = array_shift($path); $folder = implode('/', $path); if ($username != $rcube->get_user_name()) { + list($user, $domain) = explode('@', $username); + // Note: this assumes there's only one other users namespace root if ($ns = $storage->get_namespace('other')) { if ($prefix = $ns[0][0]) { - $folder = $prefix . '/' . $username . '/' . $folder; + $folder = $prefix . $user . '/' . $folder; } } } else if (!strlen($folder)) { $folder = 'INBOX'; } } else { - return; + $folder = $ns . '/' . implode('/', $path); + // Note: this assumes there's only one shared namespace root + if ($ns = $storage->get_namespace('shared')) { + if ($prefix = $ns[0][0]) { + $folder = $prefix . $folder; + } + } } return array( 'folder' => $folder, 'uid' => $uid, 'params' => $params, ); } return false; } /** * Build array of member URIs from set of messages * * @param string $folder Folder name * @param array $messages Array of rcube_message objects * * @return array List of members (IMAP URIs) */ public static function build_members($folder, $messages) { $members = array(); foreach ((array) $messages as $msg) { $params = array( 'folder' => $folder, 'uid' => $msg->uid, ); // add search parameters: // we don't want to build "invalid" searches e.g. that // will return false positives (more or wrong messages) if (($messageid = $msg->get('message-id', false)) && ($date = $msg->get('date', false))) { $params['message-id'] = $messageid; $params['date'] = $date; if ($subject = $msg->get('subject', false)) { $params['subject'] = substr($subject, 0, 256); } } $members[] = self::build_member_url($params); } return $members; } /** * Resolve/validate/update members (which are IMAP URIs) of relation object. * * @param array $tag Tag object * @param bool $force Force members list update * * @return array Folder/UIDs list */ public static function resolve_members(&$tag, $force = true) { $result = array(); foreach ((array) $tag['members'] as $member) { // IMAP URI members if ($url = self::parse_member_url($member)) { $folder = $url['folder']; if (!$force) { $result[$folder][] = $url['uid']; } else { $result[$folder]['uid'][] = $url['uid']; $result[$folder]['params'][] = $url['params']; $result[$folder]['member'][] = $member; } } } if (empty($result) || !$force) { return $result; } $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $search = array(); $missing = array(); // first we search messages by Folder+UID foreach ($result as $folder => $data) { // @FIXME: maybe better use index() which is cached? // @TODO: consider skip_deleted option $index = $storage->search_once($folder, 'UID ' . rcube_imap_generic::compressMessageSet($data['uid'])); $uids = $index->get(); // messages that were not found need to be searched by search parameters $not_found = array_diff($data['uid'], $uids); if (!empty($not_found)) { foreach ($not_found as $uid) { $idx = array_search($uid, $data['uid']); if ($p = $data['params'][$idx]) { $search[] = $p; } $missing[] = $result[$folder]['member'][$idx]; unset($result[$folder]['uid'][$idx]); unset($result[$folder]['params'][$idx]); unset($result[$folder]['member'][$idx]); } } $result[$folder] = $uids; } // search in all subscribed mail folders using search parameters if (!empty($search)) { // remove not found members from the members list $tag['members'] = array_diff($tag['members'], $missing); // get subscribed folders $folders = $storage->list_folders_subscribed('', '*', 'mail', null, true); // @TODO: do this search in chunks (for e.g. 10 messages)? $search_str = ''; foreach ($search as $p) { $search_params = array(); foreach ($p as $key => $val) { $key = strtoupper($key); // don't search by subject, we don't want false-positives if ($key != 'SUBJECT') { $search_params[] = 'HEADER ' . $key . ' ' . rcube_imap_generic::escape($val); } } $search_str .= ' (' . implode(' ', $search_params) . ')'; } $search_str = trim(str_repeat(' OR', count($search)-1) . $search_str); // search $search = $storage->search_once($folders, $search_str); // handle search result $folders = (array) $search->get_parameters('MAILBOX'); foreach ($folders as $folder) { $set = $search->get_set($folder); $uids = $set->get(); if (!empty($uids)) { $msgs = $storage->fetch_headers($folder, $uids, false); $members = self::build_members($folder, $msgs); // merge new members into the tag members list $tag['members'] = array_merge($tag['members'], $members); // add UIDs into the result $result[$folder] = array_unique(array_merge((array)$result[$folder], $uids)); } } // update tag object with new members list $tag['members'] = array_unique($tag['members']); kolab_storage_config::get_instance()->save($tag, 'relation', false); } return $result; } /** * Assign tags to kolab objects * * @param array $records List of kolab objects * * @return array List of tags */ public function apply_tags(&$records) { // first convert categories into tags foreach ($records as $i => $rec) { if (!empty($rec['categories'])) { $folder = new kolab_storage_folder($rec['_mailbox']); if ($object = $folder->get_object($rec['uid'])) { $tags = $rec['categories']; unset($object['categories']); unset($records[$i]['categories']); $this->save_tags($rec['uid'], $tags); $folder->save($object, $rec['_type'], $rec['uid']); } } } $tags = array(); // assign tags to objects foreach ($this->get_tags() as $tag) { foreach ($records as $idx => $rec) { $uid = self::build_member_url($rec['uid']); if (in_array($uid, (array) $tag['members'])) { $records[$idx]['tags'][] = $tag['name']; } } $tags[] = $tag['name']; } $tags = array_unique($tags); return $tags; } /** * Update object tags * * @param string $uid Kolab object UID * @param array $tags List of tag names */ public function save_tags($uid, $tags) { $url = self::build_member_url($uid); $relations = $this->get_tags(); foreach ($relations as $idx => $relation) { $selected = !empty($tags) && in_array($relation['name'], $tags); $found = !empty($relation['members']) && in_array($url, $relation['members']); $update = false; // remove member from the relation if ($found && !$selected) { $relation['members'] = array_diff($relation['members'], (array) $url); $update = true; } // add member to the relation else if (!$found && $selected) { $relation['members'][] = $url; $update = true; } if ($update) { if ($this->save($relation, 'relation')) { $this->tags[$idx] = $relation; // update in-memory cache } } if ($selected) { $tags = array_diff($tags, (array)$relation['name']); } } // create new relations if (!empty($tags)) { foreach ($tags as $tag) { $relation = array( 'name' => $tag, 'members' => (array) $url, 'category' => 'tag', ); if ($this->save($relation, 'relation')) { $this->tags[] = $relation; // update in-memory cache } } } } /** * Get tags (all or referring to specified object) * - * @param string $uid Optional object UID + * @param string $member Optional object UID or mail message-id + * @param int $limit Max. number of records (per-folder) + * Used when searching by member * * @return array List of Relation objects */ - public function get_tags($uid = '*') + public function get_tags($member = '*', $limit = 0) { if (!isset($this->tags)) { $default = true; $filter = array( array('type', '=', 'relation'), array('category', '=', 'tag') ); // use faster method - if ($uid && $uid != '*') { - $filter[] = array('member', '=', $uid); - $tags = $this->get_objects($filter, $default); + if ($member && $member != '*') { + $filter[] = array('member', '=', $member); + $tags = $this->get_objects($filter, $default, $limit); } else { $this->tags = $tags = $this->get_objects($filter, $default); } } else { $tags = $this->tags; } - if ($uid === '*') { + if ($member === '*') { return $tags; } $result = array(); - $search = self::build_member_url($uid); + + if ($member[0] == '<') { + $search_msg = urlencode($member); + } + else { + $search_uid = self::build_member_url($member); + } foreach ($tags as $tag) { - if (in_array($search, (array) $tag['members'])) { + if ($search_uid && in_array($search_uid, (array) $tag['members'])) { $result[] = $tag; } + else if ($search_msg) { + foreach ($tag['members'] as $m) { + if (strpos($m, $search_msg) !== false) { + $result[] = $tag; + break; + } + } + } } return $result; } /** * Find objects linked with the given groupware object through a relation * * @param string Object UUID * @param array List of related URIs */ public function get_object_links($uid) { $links = array(); $object_uri = self::build_member_url($uid); foreach ($this->get_relations_for_member($uid) as $relation) { if (in_array($object_uri, (array) $relation['members'])) { // make relation members up-to-date kolab_storage_config::resolve_members($relation); foreach ($relation['members'] as $member) { if ($member != $object_uri) { $links[] = $member; } } } } return array_unique($links); } /** * */ public function save_object_links($uid, $links, $remove = array()) { $object_uri = self::build_member_url($uid); $relations = $this->get_relations_for_member($uid); $done = false; foreach ($relations as $relation) { // make relation members up-to-date kolab_storage_config::resolve_members($relation); // remove and add links $members = array_diff($relation['members'], (array)$remove); $members = array_unique(array_merge($members, $links)); // make sure the object_uri is still a member if (!in_array($object_uri, $members)) { $members[$object_uri]; } // remove relation if no other members remain if (count($members) <= 1) { $done = $this->delete($relation['uid']); } // update relation object if members changed else if (count(array_diff($members, $relation['members'])) || count(array_diff($relation['members'], $members))) { $relation['members'] = $members; $done = $this->save($relation, 'relation'); $links = array(); } // no changes, we're happy else { $done = true; $links = array(); } } // create a new relation if (!$done && !empty($links)) { $relation = array( 'members' => array_merge($links, array($object_uri)), 'category' => 'generic', ); $ret = $this->save($relation, 'relation'); } return $ret; } /** * Find relation objects referring to specified note */ public function get_relations_for_member($uid, $reltype = 'generic') { $default = true; $filter = array( array('type', '=', 'relation'), array('category', '=', $reltype), array('member', '=', $uid), ); return $this->get_objects($filter, $default, 100); } /** * Find kolab objects assigned to specified e-mail message * * @param rcube_message $message E-mail message * @param string $folder Folder name * @param string $type Result objects type * * @return array List of kolab objects */ public function get_message_relations($message, $folder, $type) { static $_cache = array(); $result = array(); $uids = array(); $default = true; $uri = self::get_message_uri($message, $folder); $filter = array( array('type', '=', 'relation'), array('category', '=', 'generic'), ); // query by message-id $member_id = $message->get('message-id', false); if (empty($member_id)) { // derive message identifier from URI $member_id = md5($uri); } $filter[] = array('member', '=', $member_id); if (!isset($_cache[$uri])) { // get UIDs of related groupware objects foreach ($this->get_objects($filter, $default) as $relation) { // we don't need to update members if the URI is found if (!in_array($uri, $relation['members'])) { // update members... $messages = kolab_storage_config::resolve_members($relation); // ...and check again if (empty($messages[$folder]) || !in_array($message->uid, $messages[$folder])) { continue; } } // find groupware object UID(s) foreach ($relation['members'] as $member) { if (strpos($member, 'urn:uuid:') === 0) { $uids[] = substr($member, 9); } } } // remember this lookup $_cache[$uri] = $uids; } else { $uids = $_cache[$uri]; } // get kolab objects of specified type if (!empty($uids)) { $query = array(array('uid', '=', array_unique($uids))); $result = kolab_storage::select($query, $type); } return $result; } /** * Build a URI representing the given message reference */ public static function get_message_uri($headers, $folder) { $params = array( 'folder' => $headers->folder ?: $folder, 'uid' => $headers->uid, ); if (($messageid = $headers->get('message-id', false)) && ($date = $headers->get('date', false))) { $params['message-id'] = $messageid; $params['date'] = $date; if ($subject = $headers->get('subject')) { $params['subject'] = $subject; } } return self::build_member_url($params); } /** * Resolve the email message reference from the given URI */ public function get_message_reference($uri, $rel = null) { if ($linkref = self::parse_member_url($uri)) { $linkref['subject'] = $linkref['params']['subject']; $linkref['uri'] = $uri; $rcmail = rcube::get_instance(); if (method_exists($rcmail, 'url')) { $linkref['mailurl'] = $rcmail->url(array( 'task' => 'mail', 'action' => 'show', 'mbox' => $linkref['folder'], 'uid' => $linkref['uid'], 'rel' => $rel, )); } unset($linkref['params']); } return $linkref; } } diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_folder.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_folder.php index ab3c63f..8d86d70 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_folder.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_folder.php @@ -1,1166 +1,1173 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_folder extends kolab_storage_folder_api { /** * The kolab_storage_cache instance for caching operations * @var object */ public $cache; /** * Indicate validity status * @var boolean */ public $valid = false; protected $error = 0; protected $resource_uri; /** * Default constructor * * @param string The folder name/path * @param string Expected folder type */ function __construct($name, $type = null, $type_annotation = null) { parent::__construct($name); $this->imap->set_options(array('skip_deleted' => true)); $this->set_folder($name, $type, $type_annotation); } /** * Set the IMAP folder this instance connects to * * @param string The folder name/path * @param string Expected folder type * @param string Optional folder type if known */ public function set_folder($name, $type = null, $type_annotation = null) { if (empty($type_annotation)) { $type_annotation = kolab_storage::folder_type($name); } $oldtype = $this->type; list($this->type, $suffix) = explode('.', $type_annotation); $this->default = $suffix == 'default'; $this->subtype = $this->default ? '' : $suffix; $this->name = $name; $this->id = kolab_storage::folder_id($name); $this->valid = !empty($this->type) && $this->type != 'mail' && (!$type || $this->type == $type); if (!$this->valid) { $this->error = $this->imap->get_error_code() < 0 ? kolab_storage::ERROR_IMAP_CONN : kolab_storage::ERROR_INVALID_FOLDER; } // reset cached object properties $this->owner = $this->namespace = $this->resource_uri = $this->info = $this->idata = null; // get a new cache instance if folder type changed if (!$this->cache || $this->type != $oldtype) $this->cache = kolab_storage_cache::factory($this); else $this->cache->set_folder($this); $this->imap->set_folder($this->name); } /** * Returns code of last error * * @return int Error code */ public function get_error() { return $this->error ?: $this->cache->get_error(); } /** * Check IMAP connection error state */ public function check_error() { if (($err_code = $this->imap->get_error_code()) < 0) { $this->error = kolab_storage::ERROR_IMAP_CONN; if (($res_code = $this->imap->get_response_code()) !== 0 && in_array($res_code, array(rcube_storage::NOPERM, rcube_storage::READONLY))) { $this->error = kolab_storage::ERROR_NO_PERMISSION; } } return $this->error; } /** * Compose a unique resource URI for this IMAP folder */ public function get_resource_uri() { - if (!empty($this->resource_uri)) + if (!empty($this->resource_uri)) { return $this->resource_uri; + } // strip namespace prefix from folder name - $ns = $this->get_namespace(); + $ns = $this->get_namespace(); $nsdata = $this->imap->get_namespace($ns); + if (is_array($nsdata[0]) && strlen($nsdata[0][0]) && strpos($this->name, $nsdata[0][0]) === 0) { $subpath = substr($this->name, strlen($nsdata[0][0])); if ($ns == 'other') { list($user, $suffix) = explode($nsdata[0][1], $subpath, 2); $subpath = $suffix; } } else { $subpath = $this->name; } // compose fully qualified ressource uri for this instance $this->resource_uri = 'imap://' . urlencode($this->get_owner(true)) . '@' . $this->imap->options['host'] . '/' . $subpath; return $this->resource_uri; } /** * Helper method to extract folder UID metadata * * @return string Folder's UID */ public function get_uid() { // UID is defined in folder METADATA - $metakeys = array(kolab_storage::UID_KEY_SHARED, kolab_storage::UID_KEY_PRIVATE, kolab_storage::UID_KEY_CYRUS); + $metakeys = array(kolab_storage::UID_KEY_SHARED, kolab_storage::UID_KEY_CYRUS); $metadata = $this->get_metadata($metakeys); - foreach ($metakeys as $key) { - if (($uid = $metadata[$key])) { + + if ($metadata !== null) { + foreach ($metakeys as $key) { + if ($uid = $metadata[$key]) { + return $uid; + } + } + + // generate a folder UID and set it to IMAP + $uid = rtrim(chunk_split(md5($this->name . $this->get_owner() . uniqid('-', true)), 12, '-'), '-'); + if ($this->set_uid($uid)) { return $uid; } } - // generate a folder UID and set it to IMAP - $uid = rtrim(chunk_split(md5($this->name . $this->get_owner() . uniqid('-', true)), 12, '-'), '-'); - if ($this->set_uid($uid)) { - return $uid; - } + $this->check_error(); // create hash from folder name if we can't write the UID metadata return md5($this->name . $this->get_owner()); } /** * Helper method to set an UID value to the given IMAP folder instance * * @param string Folder's UID * @return boolean True on succes, False on failure */ public function set_uid($uid) { - if (!($success = $this->set_metadata(array(kolab_storage::UID_KEY_SHARED => $uid)))) { - $success = $this->set_metadata(array(kolab_storage::UID_KEY_PRIVATE => $uid)); - } + $success = $this->set_metadata(array(kolab_storage::UID_KEY_SHARED => $uid)); $this->check_error(); return $success; } /** * Compose a folder Etag identifier */ public function get_ctag() { $fdata = $this->get_imap_data(); $this->check_error(); return sprintf('%d-%d-%d', $fdata['UIDVALIDITY'], $fdata['HIGHESTMODSEQ'], $fdata['UIDNEXT']); } /** * Check activation status of this folder * * @return boolean True if enabled, false if not */ public function is_active() { return kolab_storage::folder_is_active($this->name); } /** * Change activation status of this folder * * @param boolean The desired subscription status: true = active, false = not active * * @return True on success, false on error */ public function activate($active) { return $active ? kolab_storage::folder_activate($this->name) : kolab_storage::folder_deactivate($this->name); } /** * Check subscription status of this folder * * @return boolean True if subscribed, false if not */ public function is_subscribed() { return kolab_storage::folder_is_subscribed($this->name); } /** * Change subscription status of this folder * * @param boolean The desired subscription status: true = subscribed, false = not subscribed * * @return True on success, false on error */ public function subscribe($subscribed) { return $subscribed ? kolab_storage::folder_subscribe($this->name) : kolab_storage::folder_unsubscribe($this->name); } /** * Get number of objects stored in this folder * * @param mixed Pseudo-SQL query as list of filter parameter triplets * or string with object type (e.g. contact, event, todo, journal, note, configuration) * @return integer The number of objects of the given type * @see self::select() */ public function count($query = null) { if (!$this->valid) { return 0; } // synchronize cache first $this->cache->synchronize(); return $this->cache->count($this->_prepare_query($query)); } /** * List all Kolab objects of the given type * * @param string $type Object type (e.g. contact, event, todo, journal, note, configuration) * @return array List of Kolab data objects (each represented as hash array) */ public function get_objects($type = null) { if (!$type) $type = $this->type; if (!$this->valid) { return array(); } // synchronize caches $this->cache->synchronize(); // fetch objects from cache return $this->cache->select($this->_prepare_query($type)); } /** * Select *some* Kolab objects matching the given query * * @param array Pseudo-SQL query as list of filter parameter triplets * triplet: array('', '', '') * @return array List of Kolab data objects (each represented as hash array) */ public function select($query = array()) { if (!$this->valid) { return array(); } // check query argument if (empty($query)) { return $this->get_objects(); } // synchronize caches $this->cache->synchronize(); // fetch objects from cache return $this->cache->select($this->_prepare_query($query)); } /** * Getter for object UIDs only * * @param array Pseudo-SQL query as list of filter parameter triplets * @return array List of Kolab object UIDs */ public function get_uids($query = array()) { if (!$this->valid) { return array(); } // synchronize caches $this->cache->synchronize(); // fetch UIDs from cache return $this->cache->select($this->_prepare_query($query), true); } /** * Setter for ORDER BY and LIMIT parameters for cache queries * * @param array List of columns to order by * @param integer Limit result set to this length * @param integer Offset row */ public function set_order_and_limit($sortcols, $length = null, $offset = 0) { $this->cache->set_order_by($sortcols); if ($length !== null) { $this->cache->set_limit($length, $offset); } } /** * Helper method to sanitize query arguments */ private function _prepare_query($query) { // string equals type query // FIXME: should not be called this way! if (is_string($query)) { return $this->cache->has_type_col() && !empty($query) ? array(array('type','=',$query)) : array(); } foreach ((array)$query as $i => $param) { if ($param[0] == 'type' && !$this->cache->has_type_col()) { unset($query[$i]); } else if (($param[0] == 'dtstart' || $param[0] == 'dtend' || $param[0] == 'changed')) { if (is_object($param[2]) && is_a($param[2], 'DateTime')) $param[2] = $param[2]->format('U'); if (is_numeric($param[2])) $query[$i][2] = date('Y-m-d H:i:s', $param[2]); } } return $query; } /** * Getter for a single Kolab object, identified by its UID * * @param string $uid Object UID * @param string $type Object type (e.g. contact, event, todo, journal, note, configuration) * Defaults to folder type * * @return array The Kolab object represented as hash array */ public function get_object($uid, $type = null) { if (!$this->valid) { return false; } // synchronize caches $this->cache->synchronize(); $msguid = $this->cache->uid2msguid($uid); if ($msguid && ($object = $this->cache->get($msguid, $type))) { return $object; } return false; } /** * Fetch a Kolab object attachment which is stored in a separate part * of the mail MIME message that represents the Kolab record. * * @param string Object's UID * @param string The attachment's mime number * @param string IMAP folder where message is stored; * If set, that also implies that the given UID is an IMAP UID * @param bool True to print the part content * @param resource File pointer to save the message part * @param boolean Disables charset conversion * * @return mixed The attachment content as binary string */ public function get_attachment($uid, $part, $mailbox = null, $print = false, $fp = null, $skip_charset_conv = false) { if ($this->valid && ($msguid = ($mailbox ? $uid : $this->cache->uid2msguid($uid)))) { $this->imap->set_folder($mailbox ? $mailbox : $this->name); if (substr($part, 0, 2) == 'i:') { // attachment data is stored in XML if ($object = $this->cache->get($msguid)) { // load data from XML (attachment content is not stored in cache) if ($object['_formatobj'] && isset($object['_size'])) { $object['_attachments'] = array(); $object['_formatobj']->get_attachments($object); } foreach ($object['_attachments'] as $attach) { if ($attach['id'] == $part) { if ($print) echo $attach['content']; else if ($fp) fwrite($fp, $attach['content']); else return $attach['content']; return true; } } } } else { // return message part from IMAP directly return $this->imap->get_message_part($msguid, $part, null, $print, $fp, $skip_charset_conv); } } return null; } /** * Fetch the mime message from the storage server and extract * the Kolab groupware object from it * * @param string The IMAP message UID to fetch * @param string The object type expected (use wildcard '*' to accept all types) * @param string The folder name where the message is stored * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($msguid, $type = null, $folder = null) { if (!$this->valid) { return false; } if (!$type) $type = $this->type; if (!$folder) $folder = $this->name; $this->imap->set_folder($folder); $this->cache->bypass(true); $message = new rcube_message($msguid); $this->cache->bypass(false); // Message doesn't exist? if (empty($message->headers)) { return false; } // extract the X-Kolab-Type header from the XML attachment part if missing if (empty($message->headers->others['x-kolab-type'])) { foreach ((array)$message->attachments as $part) { if (strpos($part->mimetype, kolab_format::KTYPE_PREFIX) === 0) { $message->headers->others['x-kolab-type'] = $part->mimetype; break; } } } // fix buggy messages stating the X-Kolab-Type header twice else if (is_array($message->headers->others['x-kolab-type'])) { $message->headers->others['x-kolab-type'] = reset($message->headers->others['x-kolab-type']); } // no object type header found: abort if (empty($message->headers->others['x-kolab-type'])) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "No X-Kolab-Type information found in message $msguid ($this->name).", ), true); return false; } $object_type = kolab_format::mime2object_type($message->headers->others['x-kolab-type']); $content_type = kolab_format::KTYPE_PREFIX . $object_type; // check object type header and abort on mismatch if ($type != '*' && $object_type != $type) return false; $attachments = array(); // get XML part foreach ((array)$message->attachments as $part) { if (!$xml && ($part->mimetype == $content_type || preg_match('!application/([a-z.]+\+)?xml!', $part->mimetype))) { $xml = $message->get_part_body($part->mime_id, true); } else if ($part->filename || $part->content_id) { $key = $part->content_id ? trim($part->content_id, '<>') : $part->filename; $size = null; // Use Content-Disposition 'size' as for the Kolab Format spec. if (isset($part->d_parameters['size'])) { $size = $part->d_parameters['size']; } // we can trust part size only if it's not encoded else if ($part->encoding == 'binary' || $part->encoding == '7bit' || $part->encoding == '8bit') { $size = $part->size; } $attachments[$key] = array( 'id' => $part->mime_id, 'name' => $part->filename, 'mimetype' => $part->mimetype, 'size' => $size, ); } } if (!$xml) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not find Kolab data part in message $msguid ($this->name).", ), true); return false; } // check kolab format version $format_version = $message->headers->others['x-kolab-mime-version']; if (empty($format_version)) { list($xmltype, $subtype) = explode('.', $object_type); $xmlhead = substr($xml, 0, 512); // detect old Kolab 2.0 format if (strpos($xmlhead, '<' . $xmltype) !== false && strpos($xmlhead, 'xmlns=') === false) $format_version = '2.0'; else $format_version = '3.0'; // assume 3.0 } // get Kolab format handler for the given type $format = kolab_format::factory($object_type, $format_version); if (is_a($format, 'PEAR_Error')) return false; // load Kolab object from XML part $format->load($xml); if ($format->is_valid()) { $object = $format->to_array(array('_attachments' => $attachments)); $object['_type'] = $object_type; $object['_msguid'] = $msguid; $object['_mailbox'] = $this->name; $object['_formatobj'] = $format; return $object; } else { // try to extract object UID from XML block if (preg_match('!(.+)!Uims', $xml, $m)) $msgadd = " UID = " . trim(strip_tags($m[1])); rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not parse Kolab object data in message $msguid ($this->name)." . $msgadd, ), true); } 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 boolean True on success, false on error + * @param array $object The array that holds the data of the object. + * @param string $type The type of the kolab object. + * @param string $uid The UID of the old object if it existed before + * + * @return mixed False on error or IMAP message UID on success */ public function save(&$object, $type = null, $uid = null) { if (!$this->valid) { return false; } if (!$type) $type = $this->type; // copy attachments from old message - if (!empty($object['_msguid']) && ($old = $this->cache->get($object['_msguid'], $type, $object['_mailbox']))) { + $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($object['_msguid'], $att['id'], $object['_mailbox']); + $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']); unset($object['_attachments'][$key]); } } } // save contact photo to attachment for Kolab2 format if (kolab_storage::$version == '2.0' && $object['photo']) { $attkey = 'kolab-picture.png'; // this file name is hard-coded in libkolab/kolabformatV2/contact.cpp $object['_attachments'][$attkey] = array( 'mimetype'=> rcube_mime::image_content_type($object['photo']), 'content' => preg_match('![^a-z0-9/=+-]!i', $object['photo']) ? $object['photo'] : base64_decode($object['photo']), ); } // process attachments if (is_array($object['_attachments'])) { $numatt = count($object['_attachments']); foreach ($object['_attachments'] as $key => $attachment) { // FIXME: kolab_storage and Roundcube attachment hooks use different fields! if (empty($attachment['content']) && !empty($attachment['data'])) { $attachment['content'] = $attachment['data']; unset($attachment['data'], $object['_attachments'][$key]['data']); } // make sure size is set, so object saved in cache contains this info if (!isset($attachment['size'])) { if (!empty($attachment['content'])) { if (is_resource($attachment['content'])) { // this need to be a seekable resource, otherwise // fstat() failes and we're unable to determine size // here nor in rcube_imap_generic before IMAP APPEND $stat = fstat($attachment['content']); $attachment['size'] = $stat ? $stat['size'] : 0; } else { $attachment['size'] = strlen($attachment['content']); } } else if (!empty($attachment['path'])) { $attachment['size'] = filesize($attachment['path']); } $object['_attachments'][$key] = $attachment; } // generate unique keys (used as content-id) for attachments if (is_numeric($key) && $key < $numatt) { // derrive content-id from attachment file name $ext = preg_match('/(\.[a-z0-9]{1,6})$/i', $attachment['name'], $m) ? $m[1] : null; $basename = preg_replace('/[^a-z0-9_.-]/i', '', basename($attachment['name'], $ext)); // to 7bit ascii if (!$basename) $basename = 'noname'; - $cid = $basename . '.' . microtime(true) . $ext; + $cid = $basename . '.' . microtime(true) . $key . $ext; $object['_attachments'][$cid] = $attachment; unset($object['_attachments'][$key]); } } } // save recurrence exceptions as individual objects due to lack of support in Kolab v2 format if (kolab_storage::$version == '2.0' && $object['recurrence']['EXCEPTIONS']) { $this->save_recurrence_exceptions($object, $type); } // check IMAP BINARY extension support for 'file' objects // allow configuration to workaround bug in Cyrus < 2.4.17 $rcmail = rcube::get_instance(); $binary = $type == 'file' && !$rcmail->config->get('kolab_binary_disable') && $this->imap->get_capability('BINARY'); // generate and save object message if ($raw_msg = $this->build_message($object, $type, $binary, $body_file)) { // resolve old msguid before saving if ($uid && empty($object['_msguid']) && ($msguid = $this->cache->uid2msguid($uid))) { $object['_msguid'] = $msguid; $object['_mailbox'] = $this->name; } $result = $this->imap->save_message($this->name, $raw_msg, null, false, null, null, $binary); // update cache with new UID if ($result) { $old_uid = $object['_msguid']; $object['_msguid'] = $result; $object['_mailbox'] = $this->name; if ($old_uid) { // delete old message $this->cache->bypass(true); $this->imap->delete_message($old_uid, $object['_mailbox']); $this->cache->bypass(false); } // insert/update message in cache $this->cache->save($result, $object, $old_uid); } // remove temp file if ($body_file) { @unlink($body_file); } } return $result; } /** * Save recurrence exceptions as individual objects. * The Kolab v2 format doesn't allow us to save fully embedded exception objects. * * @param array Hash array with event properties * @param string Object type */ private function save_recurrence_exceptions(&$object, $type = null) { if ($object['recurrence']['EXCEPTIONS']) { $exdates = array(); foreach ((array)$object['recurrence']['EXDATE'] as $exdate) { $key = is_a($exdate, 'DateTime') ? $exdate->format('Y-m-d') : strval($exdate); $exdates[$key] = 1; } // save every exception as individual object foreach((array)$object['recurrence']['EXCEPTIONS'] as $exception) { $exception['uid'] = self::recurrence_exception_uid($object['uid'], $exception['start']->format('Ymd')); $exception['sequence'] = $object['sequence'] + 1; if ($exception['thisandfuture']) { $exception['recurrence'] = $object['recurrence']; // adjust the recurrence duration of the exception if ($object['recurrence']['COUNT']) { $recurrence = new kolab_date_recurrence($object['_formatobj']); if ($end = $recurrence->end()) { unset($exception['recurrence']['COUNT']); $exception['recurrence']['UNTIL'] = $end; } } // set UNTIL date if we have a thisandfuture exception $untildate = clone $exception['start']; $untildate->sub(new DateInterval('P1D')); $object['recurrence']['UNTIL'] = $untildate; unset($object['recurrence']['COUNT']); } else { if (!$exdates[$exception['start']->format('Y-m-d')]) $object['recurrence']['EXDATE'][] = clone $exception['start']; unset($exception['recurrence']); } unset($exception['recurrence']['EXCEPTIONS'], $exception['_formatobj'], $exception['_msguid']); $this->save($exception, $type, $exception['uid']); } unset($object['recurrence']['EXCEPTIONS']); } } /** * Generate an object UID with the given recurrence-ID in a way that it is * unique (the original UID is not a substring) but still recoverable. */ private static function recurrence_exception_uid($uid, $recurrence_id) { $offset = -2; return substr($uid, 0, $offset) . '-' . $recurrence_id . '-' . substr($uid, $offset); } /** * Delete the specified object from this folder. * * @param mixed $object The Kolab object to delete or object UID * @param boolean $expunge Should the folder be expunged? * * @return boolean True if successful, false on error */ public function delete($object, $expunge = true) { if (!$this->valid) { return false; } $msguid = is_array($object) ? $object['_msguid'] : $this->cache->uid2msguid($object); $success = false; $this->cache->bypass(true); if ($msguid && $expunge) { $success = $this->imap->delete_message($msguid, $this->name); } else if ($msguid) { $success = $this->imap->set_flag($msguid, 'DELETED', $this->name); } $this->cache->bypass(false); if ($success) { $this->cache->set($msguid, false); } return $success; } /** * */ public function delete_all() { if (!$this->valid) { return false; } $this->cache->purge(); $this->cache->bypass(true); $result = $this->imap->clear_folder($this->name); $this->cache->bypass(false); return $result; } /** * Restore a previously deleted object * * @param string Object UID * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } if ($msguid = $this->cache->uid2msguid($uid, true)) { $this->cache->bypass(true); $result = $this->imap->set_flag($msguid, 'UNDELETED', $this->name); $this->cache->bypass(false); if ($result) { return $msguid; } } return false; } /** * Move a Kolab object message to another IMAP folder * * @param string Object UID * @param string IMAP folder to move object to * @return boolean True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } if (is_string($target_folder)) $target_folder = kolab_storage::get_folder($target_folder); if ($msguid = $this->cache->uid2msguid($uid)) { $this->cache->bypass(true); $result = $this->imap->move_message($msguid, $target_folder->name, $this->name); $this->cache->bypass(false); if ($result) { $this->cache->move($msguid, $uid, $target_folder); return true; } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to move message $msguid to $target_folder: " . $this->imap->get_error_str(), ), true); } } return false; } /** * Creates source of the configuration object message * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param bool $binary Enables use of binary encoding of attachment(s) * @param string $body_file Reference to filename of message body * * @return mixed Message as string or array with two elements * (one for message file path, second for message headers) */ private function build_message(&$object, $type, $binary, &$body_file) { // load old object to preserve data we don't understand/process if (is_object($object['_formatobj'])) $format = $object['_formatobj']; else if ($object['_msguid'] && ($old = $this->cache->get($object['_msguid'], $type, $object['_mailbox']))) $format = $old['_formatobj']; // create new kolab_format instance if (!$format) $format = kolab_format::factory($type, kolab_storage::$version); if (PEAR::isError($format)) return false; $format->set($object); $xml = $format->write(kolab_storage::$version); $object['uid'] = $format->uid; // read UID from format $object['_formatobj'] = $format; if (empty($xml) || !$format->is_valid() || empty($object['uid'])) { return false; } $mime = new Mail_mime("\r\n"); $rcmail = rcube::get_instance(); $headers = array(); $files = array(); $part_id = 1; $encoding = $binary ? 'binary' : 'base64'; if ($user_email = $rcmail->get_user_email()) { $headers['From'] = $user_email; $headers['To'] = $user_email; } $headers['Date'] = date('r'); $headers['X-Kolab-Type'] = kolab_format::KTYPE_PREFIX . $type; $headers['X-Kolab-Mime-Version'] = kolab_storage::$version; $headers['Subject'] = $object['uid']; // $headers['Message-ID'] = $rcmail->gen_message_id(); $headers['User-Agent'] = $rcmail->config->get('useragent'); // Check if we have enough memory to handle the message in it // It's faster than using files, so we'll do this if we only can if (!empty($object['_attachments']) && ($mem_limit = parse_bytes(ini_get('memory_limit'))) > 0) { $memory = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024; // safe value: 16MB foreach ($object['_attachments'] as $attachment) { $memory += $attachment['size']; } // 1.33 is for base64, we need at least 4x more memory than the message size if ($memory * ($binary ? 1 : 1.33) * 4 > $mem_limit) { $marker = '%%%~~~' . md5(microtime(true) . $memory) . '~~~%%%'; $is_file = true; $temp_dir = unslashify($rcmail->config->get('temp_dir')); $mime->setParam('delay_file_io', true); } } $mime->headers($headers); $mime->setTXTBody("This is a Kolab Groupware object. " . "To view this object you will need an email client that understands the Kolab Groupware format. " . "For a list of such email clients please visit http://www.kolab.org/\n\n"); $ctype = kolab_storage::$version == '2.0' ? $format->CTYPEv2 : $format->CTYPE; // Convert new lines to \r\n, to wrokaround "NO Message contains bare newlines" // when APPENDing from temp file $xml = preg_replace('/\r?\n/', "\r\n", $xml); $mime->addAttachment($xml, // file $ctype, // content-type 'kolab.xml', // filename false, // is_file '8bit', // encoding 'attachment', // disposition RCUBE_CHARSET // charset ); $part_id++; // save object attachments as separate parts foreach ((array)$object['_attachments'] as $key => $att) { if (empty($att['content']) && !empty($att['id'])) { // @TODO: use IMAP CATENATE to skip attachment fetch+push operation - $msguid = !empty($object['_msguid']) ? $object['_msguid'] : $object['uid']; + $msguid = $object['_copyfrom'] ?: ($object['_msguid'] ?: $object['uid']); if ($is_file) { $att['path'] = tempnam($temp_dir, 'rcmAttmnt'); if (($fp = fopen($att['path'], 'w')) && $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, $fp, true)) { fclose($fp); } else { return false; } } else { $att['content'] = $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, null, true); } } $headers = array('Content-ID' => Mail_mimePart::encodeHeader('Content-ID', '<' . $key . '>', RCUBE_CHARSET, 'quoted-printable')); $name = !empty($att['name']) ? $att['name'] : $key; // To store binary files we can use faster method // without writting full message content to a temporary file but // directly to IMAP, see rcube_imap_generic::append(). // I.e. use file handles where possible if (!empty($att['path'])) { if ($is_file && $binary) { $files[] = fopen($att['path'], 'r'); $mime->addAttachment($marker, $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } else { $mime->addAttachment($att['path'], $att['mimetype'], $name, true, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } } else { if (is_resource($att['content']) && $is_file && $binary) { $files[] = $att['content']; $mime->addAttachment($marker, $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } else { if (is_resource($att['content'])) { @rewind($att['content']); $att['content'] = stream_get_contents($att['content']); } $mime->addAttachment($att['content'], $att['mimetype'], $name, false, $encoding, 'attachment', '', '', '', null, null, '', RCUBE_CHARSET, $headers); } } $object['_attachments'][$key]['id'] = ++$part_id; } if (!$is_file || !empty($files)) { $message = $mime->getMessage(); } // parse message and build message array with // attachment file pointers in place of file markers if (!empty($files)) { $message = explode($marker, $message); $tmp = array(); foreach ($message as $msg_part) { $tmp[] = $msg_part; if ($file = array_shift($files)) { $tmp[] = $file; } } $message = $tmp; } // write complete message body into temp file else if ($is_file) { // use common temp dir $body_file = tempnam($temp_dir, 'rcmMsg'); if (PEAR::isError($mime_result = $mime->saveMessageBody($body_file))) { - self::raise_error(array('code' => 650, 'type' => 'php', + rcube::raise_error(array('code' => 650, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not create message: ".$mime_result->getMessage()), true, false); return false; } $message = array(trim($mime->txtHeaders()) . "\r\n\r\n", fopen($body_file, 'r')); } return $message; } /** * Triggers any required updates after changes within the * folder. This is currently only required for handling free/busy * information with Kolab. * * @return boolean|PEAR_Error True if successfull. */ public function trigger() { $owner = $this->get_owner(); $result = false; switch($this->type) { case 'event': if ($this->get_namespace() == 'personal') { $result = $this->trigger_url( sprintf('%s/trigger/%s/%s.pfb', kolab_storage::get_freebusy_server(), urlencode($owner), urlencode($this->imap->mod_folder($this->name)) ), $this->imap->options['user'], $this->imap->options['password'] ); } break; default: return true; } if ($result && is_object($result) && is_a($result, 'PEAR_Error')) { return PEAR::raiseError(sprintf("Failed triggering folder %s. Error was: %s", $this->name, $result->getMessage())); } return $result; } /** * Triggers a URL. * * @param string $url The URL to be triggered. * @param string $auth_user Username to authenticate with * @param string $auth_passwd Password for basic auth * @return boolean|PEAR_Error True if successfull. */ private function trigger_url($url, $auth_user = null, $auth_passwd = null) { try { $request = libkolab::http_request($url); // set authentication credentials if ($auth_user && $auth_passwd) $request->setAuth($auth_user, $auth_passwd); $result = $request->send(); // rcube::write_log('trigger', $result->getBody()); } catch (Exception $e) { return PEAR::raiseError($e->getMessage()); } return true; } } diff --git a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_folder_api.php b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_folder_api.php index 7280389..0b9091f 100644 --- a/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_folder_api.php +++ b/lib/drivers/kolab/plugins/libkolab/lib/kolab_storage_folder_api.php @@ -1,351 +1,361 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ abstract class kolab_storage_folder_api { /** * Folder identifier * @var string */ public $id; /** * The folder name. * @var string */ public $name; /** * The type of this folder. * @var string */ public $type; /** * The subtype of this folder. * @var string */ public $subtype; /** * Is this folder set to be the default for its type * @var boolean */ public $default = false; /** * List of direct child folders * @var array */ public $children = array(); /** * Name of the parent folder * @var string */ public $parent = ''; protected $imap; protected $owner; protected $info; protected $idata; protected $namespace; /** * Private constructor */ protected function __construct($name) { $this->name = $name; $this->id = kolab_storage::folder_id($name); $this->imap = rcube::get_instance()->get_storage(); } /** * Returns the owner of the folder. * * @param boolean 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; $info = $this->get_folder_info(); $rcmail = rcube::get_instance(); switch ($info['namespace']) { case 'personal': $this->owner = $rcmail->get_user_name(); break; case 'shared': $this->owner = 'anonymous'; break; default: list($prefix, $this->owner) = explode($this->imap->get_hierarchy_delimiter(), $info['name']); $fully_qualified = true; // enforce email addresses (backwards compatibility) break; } if ($fully_qualified && strpos($this->owner, '@') === false) { // extract domain from current user name $domain = strstr($rcmail->get_user_name(), '@'); // fall back to mail_domain config option if (empty($domain) && ($mdomain = $rcmail->config->mail_domain($this->imap->options['host']))) { $domain = '@' . $mdomain; } $this->owner .= $domain; } return $this->owner; } /** * 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 (!isset($this->namespace)) $this->namespace = $this->imap->folder_namespace($this->name); return $this->namespace; } /** * Get the display name value of this folder * * @return string Folder name */ public function get_name() { return kolab_storage::object_name($this->name, $this->get_namespace()); } /** * Getter for the top-end folder name (not the entire path) * * @return string Name of this folder */ public function get_foldername() { $parts = explode('/', $this->name); return rcube_charset::convert(end($parts), 'UTF7-IMAP'); } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { $path = explode('/', $this->name); array_pop($path); // don't list top-level namespace folder if (count($path) == 1 && in_array($this->get_namespace(), array('other', 'shared'))) { $path = array(); } return join('/', $path); } /** * 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() { $info = $this->get_folder_info(); $owner = $this->get_owner(); list($user, $domain) = explode('@', $owner); switch ($info['namespace']) { case 'personal': return sprintf('user/%s/%s@%s', $user, $this->name, $domain); case 'shared': $ns = $this->imap->get_namespace('shared'); $prefix = is_array($ns) ? $ns[0][0] : ''; list(, $domain) = explode('@', rcube::get_instance()->get_user_name()); return substr($this->name, strlen($prefix)) . '@' . $domain; default: $ns = $this->imap->get_namespace('other'); $prefix = is_array($ns) ? $ns[0][0] : ''; list($user, $folder) = explode($this->imap->get_hierarchy_delimiter(), substr($info['name'], strlen($prefix)), 2); if (strpos($user, '@')) { list($user, $domain) = explode('@', $user); } return sprintf('user/%s/%s@%s', $user, $folder, $domain); } } /** * Get the color value stored in metadata * * @param string Default color value to return if not set * @return mixed Color value from IMAP metadata or $default is not set */ public function get_color($default = null) { // color is defined in folder METADATA $metadata = $this->get_metadata(array(kolab_storage::COLOR_KEY_PRIVATE, kolab_storage::COLOR_KEY_SHARED)); if (($color = $metadata[kolab_storage::COLOR_KEY_PRIVATE]) || ($color = $metadata[kolab_storage::COLOR_KEY_SHARED])) { return $color; } return $default; } /** * Returns IMAP metadata/annotations (GETMETADATA/GETANNOTATION) * * @param array List of metadata keys to read * @return array Metadata entry-value hash array on success, NULL on error */ public function get_metadata($keys) { $metadata = rcube::get_instance()->get_storage()->get_metadata($this->name, (array)$keys); return $metadata[$this->name]; } /** * Sets IMAP metadata/annotations (SETMETADATA/SETANNOTATION) * * @param array $entries Entry-value array (use NULL value as NIL) * @return boolean True on success, False on failure */ public function set_metadata($entries) { return $this->imap->set_metadata($this->name, $entries); } /** * */ public function get_folder_info() { if (!isset($this->info)) $this->info = $this->imap->folder_info($this->name); return $this->info; } /** * Make IMAP folder data available for this folder */ public function get_imap_data() { if (!isset($this->idata)) $this->idata = $this->imap->folder_data($this->name); return $this->idata; } /** * Get IMAP ACL information for this folder * * @return string Permissions as string */ public function get_myrights() { $rights = $this->info['rights']; if (!is_array($rights)) $rights = $this->imap->my_rights($this->name); return join('', (array)$rights); } + /** + * Helper method to extract folder UID metadata + * + * @return string Folder's UID + */ + public function get_uid() + { + // To be implemented by extending classes + return false; + } /** * Check activation status of this folder * * @return boolean True if enabled, false if not */ public function is_active() { return kolab_storage::folder_is_active($this->name); } /** * Change activation status of this folder * * @param boolean The desired subscription status: true = active, false = not active * * @return True on success, false on error */ public function activate($active) { return $active ? kolab_storage::folder_activate($this->name) : kolab_storage::folder_deactivate($this->name); } /** * Check subscription status of this folder * * @return boolean True if subscribed, false if not */ public function is_subscribed() { return kolab_storage::folder_is_subscribed($this->name); } /** * Change subscription status of this folder * * @param boolean The desired subscription status: true = subscribed, false = not subscribed * * @return True on success, false on error */ public function subscribe($subscribed) { return $subscribed ? kolab_storage::folder_subscribe($this->name) : kolab_storage::folder_unsubscribe($this->name); } /** * Return folder name as string representation of this object * * @return string Full IMAP folder name */ public function __toString() { return $this->name; } } diff --git a/lib/drivers/kolab/plugins/libkolab/libkolab.php b/lib/drivers/kolab/plugins/libkolab/libkolab.php index 052724c..0e4c8af 100644 --- a/lib/drivers/kolab/plugins/libkolab/libkolab.php +++ b/lib/drivers/kolab/plugins/libkolab/libkolab.php @@ -1,138 +1,316 @@ * - * Copyright (C) 2012, Kolab Systems AG + * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libkolab extends rcube_plugin { static $http_requests = array(); + static $bonnie_api = false; /** * Required startup method of a Roundcube plugin */ public function init() { // load local config $this->load_config(); // extend include path to load bundled lib classes $include_path = $this->home . '/lib' . PATH_SEPARATOR . ini_get('include_path'); set_include_path($include_path); $this->add_hook('storage_init', array($this, 'storage_init')); $this->add_hook('user_delete', array('kolab_storage', 'delete_user_folders')); $rcmail = rcube::get_instance(); try { kolab_format::$timezone = new DateTimeZone($rcmail->config->get('timezone', 'GMT')); } catch (Exception $e) { rcube::raise_error($e, true); kolab_format::$timezone = new DateTimeZone('GMT'); } + + $this->add_texts('localization/', false); + + // embed scripts and templates for email message audit trail + if ($rcmail->task == 'mail' && self::get_bonnie_api()) { + if ($rcmail->output->type == 'html') { + $this->add_hook('render_page', array($this, 'bonnie_render_page')); + + $this->include_script('js/audittrail.js'); + $this->include_stylesheet($this->local_skin_path() . '/libkolab.css'); + + // add 'Show history' item to message menu + $this->api->add_content(html::tag('li', null, + $this->api->output->button(array( + 'command' => 'kolab-mail-history', + 'label' => 'libkolab.showhistory', + 'type' => 'link', + 'classact' => 'icon history active', + 'class' => 'icon history', + 'innerclass' => 'icon history', + ))), + 'messagemenu'); + } + + $this->register_action('plugin.message-changelog', array($this, 'message_changelog')); + } } /** * Hook into IMAP FETCH HEADER.FIELDS command and request Kolab-specific headers */ function storage_init($p) { $p['fetch_headers'] = trim($p['fetch_headers'] .' X-KOLAB-TYPE X-KOLAB-MIME-VERSION'); return $p; } + /** + * Getter for a singleton instance of the Bonnie API + * + * @return mixed kolab_bonnie_api instance if configured, false otherwise + */ + public static function get_bonnie_api() + { + // get configuration for the Bonnie API + if (!self::$bonnie_api && ($bonnie_config = rcube::get_instance()->config->get('kolab_bonnie_api', false))) { + self::$bonnie_api = new kolab_bonnie_api($bonnie_config); + } + + return self::$bonnie_api; + } + + /** + * Hook to append the message history dialog template to the mail view + */ + function bonnie_render_page($p) + { + if (($p['template'] === 'mail' || $p['template'] === 'message') && !$p['kolab-audittrail']) { + // append a template for the audit trail dialog + $this->api->output->add_footer( + html::div(array('id' => 'mailmessagehistory', 'class' => 'uidialog', 'aria-hidden' => 'true', 'style' => 'display:none'), + self::object_changelog_table(array('class' => 'records-table changelog-table')) + ) + ); + $this->api->output->set_env('kolab_audit_trail', true); + $p['kolab-audittrail'] = true; + } + + return $p; + } + + /** + * Handler for message audit trail changelog requests + */ + public function message_changelog() + { + if (!self::$bonnie_api) { + return false; + } + + $rcmail = rcube::get_instance(); + $msguid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST, true); + $mailbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); + + $result = $msguid && $mailbox ? self::$bonnie_api->changelog('mail', null, $mailbox, $msguid) : null; + if (is_array($result)) { + if (is_array($result['changes'])) { + $dtformat = $rcmail->config->get('date_format') . ' ' . $rcmail->config->get('time_format'); + array_walk($result['changes'], function(&$change) use ($dtformat, $rcmail) { + if ($change['date']) { + $dt = rcube_utils::anytodatetime($change['date']); + if ($dt instanceof DateTime) { + $change['date'] = $rcmail->format_date($dt, $dtformat); + } + } + }); + } + $this->api->output->command('plugin.message_render_changelog', $result['changes']); + } + else { + $this->api->output->command('plugin.message_render_changelog', false); + } + + $this->api->output->send(); + } + /** * Wrapper function to load and initalize the HTTP_Request2 Object * * @param string|Net_Url2 Request URL * @param string Request method ('OPTIONS','GET','HEAD','POST','PUT','DELETE','TRACE','CONNECT') * @param array Configuration for this Request instance, that will be merged * with default configuration * * @return HTTP_Request2 Request object */ public static function http_request($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); } + // force CURL adapter, this allows to handle correctly + // compressed responses with SplObserver registered (kolab_files) (#4507) + $http_config['adapter'] = 'HTTP_Request2_Adapter_Curl'; + $key = md5(serialize($http_config)); if (!($request = self::$http_requests[$key])) { // load HTTP_Request2 require_once 'HTTP/Request2.php'; try { $request = new HTTP_Request2(); $request->setConfig($http_config); } catch (Exception $e) { rcube::raise_error($e, true, true); } // proxy User-Agent string $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); self::$http_requests[$key] = $request; } // cleanup try { $request->setBody(''); $request->setUrl($url); $request->setMethod($method); } catch (Exception $e) { rcube::raise_error($e, true, true); } return $request; } + /** + * Table oultine for object changelog display + */ + public static function object_changelog_table($attrib = array()) + { + $rcube = rcube::get_instance(); + $attrib += array('domain' => 'libkolab'); + + $table = new html_table(array('cols' => 5, 'border' => 0, 'cellspacing' => 0)); + $table->add_header('diff', ''); + $table->add_header('revision', $rcube->gettext('revision', $attrib['domain'])); + $table->add_header('date', $rcube->gettext('date', $attrib['domain'])); + $table->add_header('user', $rcube->gettext('user', $attrib['domain'])); + $table->add_header('operation', $rcube->gettext('operation', $attrib['domain'])); + $table->add_header('actions', ' '); + + $rcube->output->add_label( + 'libkolab.showrevision', + 'libkolab.actionreceive', + 'libkolab.actionappend', + 'libkolab.actionmove', + 'libkolab.actiondelete', + 'libkolab.actionread', + 'libkolab.actionflagset', + 'libkolab.actionflagclear', + 'libkolab.objectchangelog', + 'close' + ); + + return $table->show($attrib); + } + /** * Wrapper function for generating a html diff using the FineDiff class by Raymond Hill */ - public static function html_diff($from, $to) + public static function html_diff($from, $to, $is_html = null) { - include_once __dir__ . '/vendor/finediff.php'; + // auto-detect text/html format + if ($is_html === null) { + $from_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $from, $m) && strpos($from, '') > 0); + $to_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $to, $m) && strpos($to, '') > 0); + $is_html = $from_html || $to_html; + + // ensure both parts are of the same format + if ($is_html && !$from_html) { + $converter = new rcube_text2html($from, false, array('wrap' => true)); + $from = $converter->get_html(); + } + if ($is_html && !$to_html) { + $converter = new rcube_text2html($to, false, array('wrap' => true)); + $to = $converter->get_html(); + } + } + + // compute diff from HTML + if ($is_html) { + include_once __dir__ . '/vendor/Caxy/HtmlDiff/Match.php'; + include_once __dir__ . '/vendor/Caxy/HtmlDiff/Operation.php'; + include_once __dir__ . '/vendor/Caxy/HtmlDiff/HtmlDiff.php'; + + // replace data: urls with a transparent image to avoid memory problems + $from = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $from); + $to = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $to); + + $diff = new Caxy\HtmlDiff\HtmlDiff($from, $to); + $diffhtml = $diff->build(); + + // remove empty inserts (from tables) + return preg_replace('!\s*!Uims', '', $diffhtml); + } + else { + include_once __dir__ . '/vendor/finediff.php'; - $diff = new FineDiff($from, $to, FineDiff::$wordGranularity); - return $diff->renderDiffToHTML(); + $diff = new FineDiff($from, $to, FineDiff::$wordGranularity); + return $diff->renderDiffToHTML(); + } + } + + /** + * Return a date() format string to render identifiers for recurrence instances + * + * @param array Hash array with event properties + * @return string Format string + */ + public static function recurrence_id_format($event) + { + return $event['allday'] ? 'Ymd' : 'Ymd\THis'; } }