diff --git a/plugins/kolab_auth/kolab_auth.php b/plugins/kolab_auth/kolab_auth.php index f2e4ba72..28bb2a4a 100644 --- a/plugins/kolab_auth/kolab_auth.php +++ b/plugins/kolab_auth/kolab_auth.php @@ -1,893 +1,895 @@ * * 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 { public static $ldap; private $username; private $data = []; public function init() { $rcmail = rcube::get_instance(); $this->load_config(); $this->require_plugin('libkolab'); $this->add_hook('authenticate', [$this, 'authenticate']); $this->add_hook('startup', [$this, 'startup']); $this->add_hook('ready', [$this, 'ready']); $this->add_hook('user_create', [$this, 'user_create']); // Hook for password change $this->add_hook('password_ldap_bind', [$this, 'password_ldap_bind']); // Hooks related to "Login As" feature $this->add_hook('template_object_loginform', [$this, 'login_form']); $this->add_hook('storage_connect', [$this, 'imap_connect']); $this->add_hook('managesieve_connect', [$this, 'imap_connect']); $this->add_hook('smtp_connect', [$this, 'smtp_connect']); $this->add_hook('identity_form', [$this, 'identity_form']); // Hook to modify some configuration, e.g. ldap $this->add_hook('config_get', [$this, 'config_get']); // Hook to modify logging directory $this->add_hook('write_log', [$this, 'write_log']); $this->username = $_SESSION['username'] ?? null; // 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('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); } } } /** * Ready hook handler */ public function ready($args) { $rcmail = rcube::get_instance(); // Store user unique identifier for freebusy_session_auth feature if (!($uniqueid = $rcmail->config->get('kolab_uniqueid'))) { $uniqueid = $_SESSION['kolab_auth_uniqueid']; if (!$uniqueid) { // Find user record in LDAP if (($ldap = self::ldap()) && $ldap->ready) { if ($record = $ldap->get_user_record($rcmail->get_user_name(), $_SESSION['kolab_host'])) { $uniqueid = $record['uniqueid']; } } } if ($uniqueid) { $uniqueid = md5($uniqueid); $rcmail->user->save_prefs(['kolab_uniqueid' => $uniqueid]); } } // Set/update freebusy_session_auth entry if ($uniqueid && empty($_SESSION['kolab_auth_admin']) && ($ttl = $rcmail->config->get('freebusy_session_auth')) ) { if ($ttl === true) { $ttl = $rcmail->config->get('session_lifetime', 0) * 60; if (!$ttl) { $ttl = 10 * 60; } } $rcmail->config->set('freebusy_auth_cache', 'db'); $rcmail->config->set('freebusy_auth_cache_ttl', $ttl); if ($cache = $rcmail->get_cache_shared('freebusy_auth', false)) { $key = md5($uniqueid . ':' . rcube_utils::remote_addr() . ':' . $rcmail->get_user_name()); $value = $cache->get($key); $deadline = new DateTime('now', new DateTimeZone('UTC')); // We don't want to do the cache update on every request // do it once in a 1/10 of the ttl if ($value) { $value = new DateTime($value); $value->sub(new DateInterval('PT' . intval($ttl * 9 / 10) . 'S')); if ($value > $deadline) { return; } } $deadline->add(new DateInterval('PT' . $ttl . 'S')); $cache->set($key, $deadline->format(DateTime::ISO8601)); } } } /** * 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', [$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); } } } elseif ($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($startup = false) { 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, [$setting_name])); } else { if (in_array($setting_name, $dont_override)) { $_dont_override = []; foreach ($dont_override as $_setting) { if ($_setting != $setting_name) { $_dont_override[] = $_setting; } } $rcmail->config->set('dont_override', $_dont_override); } } if ($setting_name == 'skin' && $rcmail instanceof rcmail) { 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) { $loaded = $this->api->load_plugin($plugin); // Some plugins e.g. kolab_2fa use 'startup' hook to // register other hooks, but when called on 'authenticate' hook // we're already after 'startup', so we'll call it directly if ($loaded && $startup && $plugin == 'kolab_2fa' && $rcmail instanceof rcmail && ($plugin = $this->api->get_plugin($plugin)) && method_exists($plugin, 'startup') ) { $plugin->startup(['task' => $rcmail->task, 'action' => $rcmail->action]); } } } } } /** * 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 elseif ($rcmail->config->get('per_user_logging') && !empty($this->username) && !stripos($log_dir, '/' . $this->username) // maybe already set by syncroton, skip ) { $user_log_dir = $log_dir . '/' . strtolower($this->username); if (is_writable($user_log_dir)) { $args['dir'] = $user_log_dir; } elseif (!in_array($args['name'], ['errors', 'userlogins', 'sendmail'])) { $args['abort'] = true; // don't log if unauthenticed or no per-user log dir } } 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] = [ '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; } // Don't add the extra field on 2FA form if (strpos($args['content'], 'plugin.kolab-2fa-login')) { return $args; } $input = new html_inputfield(['name' => '_loginas', 'id' => 'rcmloginas', 'type' => 'text', 'autocomplete' => 'off']); $row = html::tag( 'tr', null, 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)))) ); // add icon style for Elastic $style = html::tag('style', [], '#login-form .input-group .icon.loginas::before { content: "\f508"; } '); $args['content'] = preg_replace('/<\/tbody>/i', $row . '' . $style, $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) && 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) { self::log_login_error($user, "LDAP not ready"); $args['abort'] = true; $args['kolab_ldap_error'] = true; return $args; } // Find user record in LDAP $record = $ldap->get_user_record($user, $host); if (empty($record)) { self::log_login_error($user, "No user record found"); $args['abort'] = true; 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])) { $imap_host = $rcmail->config->get('imap_host', $rcmail->config->get('default_host')); if (!empty($imap_host)) { rcube::write_log("errors", "Both imap 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) { self::log_login_error($user, "Unable to bind with '" . $record['dn'] . "'"); $args['abort'] = true; return $args; } $isadmin = false; $admin_rights = $rcmail->config->get('kolab_auth_admin_rights', []); $allowed_tasks = []; // @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)) { // compat with out of date Net_LDAP3 $effective_rights = array_change_key_case($effective_rights, CASE_LOWER); $effective_rights['attrib'] = $effective_rights['attributelevelrights']; $effective_rights['entry'] = $effective_rights['entrylevelrights']; // compare the rights with the permissions mapping $allowed_tasks = []; 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; } elseif ($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([ 'name' => 'loginasnotallowed', 'vars' => ['user' => rcube::Q($loginas)], ]); self::log_login_error($user, "No privileges to login as '" . $loginas . "'", $loginas); 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(); // Store user unique identifier for freebusy_session_auth feature $_SESSION['kolab_auth_uniqueid'] = is_array($record['uniqueid']) ? $record['uniqueid'][0] : $record['uniqueid']; // Store also host as we need it for get_user_reacod() in 'ready' hook handler $_SESSION['kolab_host'] = $host; // 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] ?? null) ? $record[$field][0] : ($record[$field] ?? null); 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] ?? null) ? array_filter($record[$field]) : ($record[$field] ?? null); if (!empty($email)) { $this->data['user_email'] = array_merge((array)($this->data['user_email'] ?? null), (array)$email); } } // Organization name for identity (first log in) foreach ((array)$org_attr as $field) { $organization = is_array($record[$field] ?? null) ? $record[$field][0] : ($record[$field] ?? null); 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(true); 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 = []; $user_record = $ldap->get_record($_SESSION['kolab_dn']); foreach ((array)$rcmail->config->get('kolab_auth_email', []) 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', ['emails' => $emails]); $emails = $plugin['emails']; if (!empty($emails)) { $args['form']['addressing']['content']['email'] = [ '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 = rcmail::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 + * + * @return ?kolab_ldap Kolab LDAP addressbook */ public static function ldap() { self::$ldap = kolab_storage::ldap('kolab_auth_addressbook'); if (self::$ldap) { self::$ldap->extend_fieldmap(['uniqueid' => 'nsuniqueid']); } return self::$ldap; } /** * Close LDAP connection */ public static function ldap_close() { if (self::$ldap) { self::$ldap->close(); self::$ldap = null; } } /** * Parses LDAP DN string with replacing supported variables. * See kolab_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; } /** * Log failed logins * * @param string $username Username/Login * @param string $message Error message (failure reason) * @param string $login_as Username/Login of "login as" user */ public static function log_login_error($username, $message = null, $login_as = null) { $config = rcube::get_instance()->config; if ($config->get('log_logins')) { // don't fill the log with complete input, which could // have been prepared by a hacker if (strlen($username) > 256) { $username = substr($username, 0, 256) . '...'; } if (strlen($login_as) > 256) { $login_as = substr($login_as, 0, 256) . '...'; } if ($login_as) { $username = sprintf('%s (as user %s)', $username, $login_as); } // Don't log full session id for better security $session_id = session_id(); $session_id = $session_id ? substr($session_id, 0, 16) : 'no-session'; $message = sprintf( "Failed login for %s from %s in session %s %s", $username, rcube_utils::remote_ip(), $session_id, $message ? "($message)" : '' ); rcube::write_log('userlogins', $message); // disable log_logins to prevent from duplicate log entries $config->set('log_logins', false); } } } diff --git a/plugins/kolab_delegation/kolab_delegation_engine.php b/plugins/kolab_delegation/kolab_delegation_engine.php index 0684b593..d2f05c6d 100644 --- a/plugins/kolab_delegation/kolab_delegation_engine.php +++ b/plugins/kolab_delegation/kolab_delegation_engine.php @@ -1,964 +1,966 @@ * @author Aleksander Machniak * * Copyright (C) 2011-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_delegation_engine { public $context; private $rc; private $ldap; private $ldap_filter; private $ldap_delegate_field; private $ldap_login_field; private $ldap_name_field; private $ldap_email_field; private $ldap_org_field; private $ldap_dn; private $cache = []; private $folder_types = ['mail', 'event', 'task']; private $supported; public const ACL_READ = 1; public const ACL_WRITE = 2; /** * Class constructor */ public function __construct() { $this->rc = rcube::get_instance(); } /** * Add delegate * * @param string|array $delegate Delegate DN (encoded) or delegate data (result of delegate_get()) * @param array $acl List of folder->right map * * @return string On error returns an error label, on success returns null */ public function delegate_add($delegate, $acl) { if (!is_array($delegate)) { $delegate = $this->delegate_get($delegate); } $dn = $delegate['ID']; if (empty($delegate) || empty($dn)) { return 'createerror'; } $list = $this->list_delegates(); $list = array_keys((array)$list); $list = array_filter($list); if (in_array($dn, $list)) { return 'delegationexisterror'; } // add delegate to the list $list[] = $dn; $list = array_map(['kolab_ldap', 'dn_decode'], $list); // update user record $result = $this->user_update_delegates($list); // Set ACL on folders if ($result && !empty($acl)) { $this->delegate_acl_update($delegate['uid'], $acl); } return $result ? null : 'createerror'; } /** * Set/Update ACL on delegator's folders * * @param string $uid Delegate authentication identifier * @param array $acl List of folder->right map * @param bool $update Update (remove) old rights */ public function delegate_acl_update($uid, $acl, $update = false) { $storage = $this->rc->get_storage(); $right_types = $this->right_types(); $folders = $update ? $this->list_folders($uid) : []; foreach ($acl as $folder_name => $rights) { $r = $right_types[$rights] ?? null; if ($r) { $storage->set_acl($folder_name, $uid, $r); } else { $storage->delete_acl($folder_name, $uid); } if (!empty($folders) && isset($folders[$folder_name])) { unset($folders[$folder_name]); } } foreach ($folders as $folder_name => $folder) { if (!empty($folder['rights'])) { $storage->delete_acl($folder_name, $uid); } } } /** * Delete delgate * * @param string $dn Delegate DN (encoded) * @param bool $acl_del Enable ACL deletion on delegator folders * * @return string On error returns an error label, on success returns null */ public function delegate_delete($dn, $acl_del = false) { $delegate = $this->delegate_get($dn); $list = $this->list_delegates(); $user = $this->user(); if (empty($delegate) || !isset($list[$dn])) { return 'deleteerror'; } // remove delegate from the list unset($list[$dn]); $list = array_keys($list); $list = array_map(['kolab_ldap', 'dn_decode'], $list); $user[$this->ldap_delegate_field] = $list; // update user record $result = $this->user_update_delegates($list); // remove ACL if ($result && $acl_del) { $this->delegate_acl_update($delegate['uid'], [], true); } return $result ? null : 'deleteerror'; } /** * Return delegate data * * @param string $dn Delegate DN (encoded) * * @return array Delegate record (ID, name, uid, imap_uid) */ public function delegate_get($dn) { // use internal cache so we not query LDAP more than once per request if (!isset($this->cache[$dn])) { $ldap = $this->ldap(); if (!$ldap || empty($dn)) { return []; } // Get delegate $user = $ldap->get_record(kolab_ldap::dn_decode($dn)); if (empty($user)) { return []; } $delegate = $this->parse_ldap_record($user); $delegate['ID'] = $dn; $this->cache[$dn] = $delegate; } return $this->cache[$dn]; } /** * Return delegate data * * @param string $login Delegate name (the 'uid' returned in get_users()) * * @return array Delegate record (ID, name, uid, imap_uid) */ public function delegate_get_by_name($login) { $ldap = $this->ldap(); if (!$ldap || empty($login)) { return []; } $list = $ldap->dosearch($this->ldap_login_field, $login, 1); if (count($list) == 1) { $dn = key($list); $user = $list[$dn]; return $this->parse_ldap_record($user, $dn); } return []; } /** * LDAP object getter + * + * @return ?kolab_ldap Kolab LDAP addressbook */ private function ldap() { if ($this->ldap !== null) { return $this->ldap; } $this->ldap = kolab_storage::ldap('kolab_delegation_addressbook'); if (!$this->ldap || !$this->ldap->ready) { return null; } // Default filter of LDAP queries $this->ldap_filter = $this->rc->config->get('kolab_delegation_filter', '(|(objectClass=kolabInetOrgPerson)(&(objectclass=kolabsharedfolder)(kolabFolderType=mail)))'); // Name of the LDAP field for delegates list $this->ldap_delegate_field = $this->rc->config->get('kolab_delegation_delegate_field', 'kolabDelegate'); // Encoded LDAP DN of current user, set on login by kolab_auth plugin $this->ldap_dn = $_SESSION['kolab_dn']; // Name of the LDAP field with authentication ID $this->ldap_login_field = $this->rc->config->get('kolab_delegation_login_field', $this->rc->config->get('kolab_auth_login')); // Name of the LDAP field with user name used for identities $this->ldap_name_field = $this->rc->config->get('kolab_delegation_name_field', $this->rc->config->get('kolab_auth_name')); // Name of the LDAP field with email addresses used for identities $this->ldap_email_field = $this->rc->config->get('kolab_delegation_email_field', $this->rc->config->get('kolab_auth_email')); // Name of the LDAP field with organization name for identities $this->ldap_org_field = $this->rc->config->get('kolab_delegation_organization_field', $this->rc->config->get('kolab_auth_organization')); $this->ldap->set_filter($this->ldap_filter); $this->ldap->extend_fieldmap([$this->ldap_delegate_field => $this->ldap_delegate_field]); return $this->ldap; } /** * List current user delegates */ public function list_delegates() { $result = []; $ldap = $this->ldap(); $user = $this->user(); if (empty($ldap) || empty($user)) { return []; } // Get delegates of current user $delegates = $user[$this->ldap_delegate_field] ?? null; if (!empty($delegates)) { foreach ((array)$delegates as $dn) { $delegate = $ldap->get_record($dn); $data = $this->parse_ldap_record($delegate, $dn); if (!empty($data) && !empty($data['name'])) { $result[$data['ID']] = $data['name']; } } } return $result; } /** * List current user delegators * * @return array List of delegators */ public function list_delegators() { $result = []; $ldap = $this->ldap(); if (empty($ldap) || empty($this->ldap_dn)) { return []; } $list = $ldap->dosearch($this->ldap_delegate_field, $this->ldap_dn, 1); foreach ($list as $dn => $delegator) { $delegator = $this->parse_ldap_record($delegator, $dn); $result[$delegator['ID']] = $delegator; } return $result; } /** * List current user delegators in format compatible with Calendar plugin * * @return array List of delegators */ public function list_delegators_js() { $list = $this->list_delegators(); $result = []; foreach ($list as $delegator) { $name = $delegator['name']; if ($pos = strrpos($name, '(')) { $name = trim(substr($name, 0, $pos)); } $result[$delegator['imap_uid']] = [ 'emails' => ';' . implode(';', $delegator['email']), 'email' => $delegator['email'][0], 'name' => $name, ]; } return $result; } /** * Prepare namespace prefixes for JS environment * * @return array List of prefixes */ public function namespace_js() { $storage = $this->rc->get_storage(); $ns = $storage->get_namespace('other'); if ($ns) { foreach ($ns as $idx => $nsval) { $ns[$idx] = kolab_storage::folder_id($nsval[0]); } } return $ns; } /** * Get all folders to which current user has admin access * * @param string $delegate IMAP user identifier * * @return array Folder type/rights */ public function list_folders($delegate = null) { $storage = $this->rc->get_storage(); $folders = $storage->list_folders(); $metadata = kolab_storage::folders_typedata(); $result = []; if (!is_array($metadata)) { return $result; } // Definition of read and write ACL $right_types = $this->right_types(); $delegate_lc = strtolower((string) $delegate); foreach ($folders as $folder) { // get only folders in personal namespace if ($storage->folder_namespace($folder) != 'personal') { continue; } $rights = null; $type = !empty($metadata[$folder]) ? $metadata[$folder] : 'mail'; [$class, $subclass] = strpos($type, '.') ? explode('.', $type) : [$type, '']; if (!in_array($class, $this->folder_types)) { continue; } // in edit mode, get folder ACL if ($delegate) { // @TODO: cache ACL $imap_acl = $storage->get_acl($folder); if (!empty($imap_acl) && (($acl = ($imap_acl[$delegate] ?? null)) || ($acl = ($imap_acl[$delegate_lc] ?? null)))) { if ($this->acl_compare($acl, $right_types[self::ACL_WRITE])) { $rights = self::ACL_WRITE; } elseif ($this->acl_compare($acl, $right_types[self::ACL_READ])) { $rights = self::ACL_READ; } } } elseif ($folder == 'INBOX' || $subclass == 'default' || $subclass == 'inbox') { $rights = self::ACL_WRITE; } $result[$folder] = [ 'type' => $class, 'rights' => $rights, ]; } return $result; } /** * Returns list of users for autocompletion * * @param string $search Search string * * @return array Users list */ public function list_users($search) { $ldap = $this->ldap(); if (empty($ldap) || $search === '' || $search === null) { return []; } $max = (int) $this->rc->config->get('autocomplete_max', 15); $mode = (int) $this->rc->config->get('addressbook_search_mode'); $fields = array_unique(array_filter(array_merge((array)$this->ldap_name_field, (array)$this->ldap_login_field))); $users = []; $keys = []; $result = $ldap->dosearch($fields, $search, $mode, (array)$this->ldap_login_field, $max); foreach ($result as $record) { // skip self if ($record['dn'] == $_SESSION['kolab_dn']) { continue; } $user = $this->parse_ldap_record($record); if ($uid = $user['uid']) { $display = rcube_addressbook::compose_search_name($record); $user = ['name' => $uid, 'display' => $display]; $users[] = $user; $keys[] = $display ?: $uid; } } if (count($users)) { // sort users index asort($keys, SORT_LOCALE_STRING); // re-sort users according to index foreach (array_keys($keys) as $idx) { $keys[$idx] = $users[$idx]; } $users = array_values($keys); } return $users; } /** * Extract delegate identifiers and pretty name from LDAP record */ private function parse_ldap_record($data, $dn = null) { $email = []; $uid = $data[$this->ldap_login_field]; $name = ''; if (is_array($uid)) { $uid = array_filter($uid); $uid = $uid[0]; } // User name for identity foreach ((array)$this->ldap_name_field as $field) { $name = is_array($data[$field]) ? $data[$field][0] : $data[$field]; if (!empty($name)) { break; } } // User email(s) for identity foreach ((array)$this->ldap_email_field as $field) { $user_email = is_array($data[$field]) ? array_filter($data[$field]) : $data[$field]; if (!empty($user_email)) { $email = array_merge((array)$email, (array)$user_email); } } // Organization for identity foreach ((array)$this->ldap_org_field as $field) { $organization = is_array($data[$field]) ? $data[$field][0] : $data[$field]; if (!empty($organization)) { break; } } $realname = $name; if ($uid && $name) { $name .= ' (' . $uid . ')'; } else { $name = $uid; } // get IMAP uid - identifier used in shared folder hierarchy $imap_uid = $uid; if ($pos = strpos($imap_uid, '@')) { $imap_uid = substr($imap_uid, 0, $pos); } return [ 'ID' => kolab_ldap::dn_encode($dn), 'uid' => $uid, 'name' => $name, 'realname' => $realname, 'imap_uid' => $imap_uid, 'email' => $email, 'organization' => $organization ?? null, ]; } /** * Returns LDAP record of current user * * @return array User data */ public function user($parsed = false) { if (!isset($this->cache['user'])) { $ldap = $this->ldap(); if (!$ldap) { return []; } // Get current user record $this->cache['user'] = $ldap->get_record($this->ldap_dn); } return $parsed ? $this->parse_ldap_record($this->cache['user']) : $this->cache['user']; } /** * Update LDAP record of current user * * @param array $list List of delegates */ public function user_update_delegates($list) { $ldap = $this->ldap(); $pass = $this->rc->decrypt($_SESSION['password']); if (!$ldap) { return false; } // need to bind as self for sufficient privilages if (!$ldap->bind($this->ldap_dn, $pass)) { return false; } $user[$this->ldap_delegate_field] = $list; unset($this->cache['user']); // replace delegators list in user record return $ldap->replace($this->ldap_dn, $user); } /** * Manage delegation data on user login */ public function delegation_init() { // Fetch all delegators from LDAP who assigned the // current user as their delegate and create identities // a) if identity with delegator's email exists, continue // b) create identity ($delegate on behalf of $delegator // <$delegator-email>) for new delegators // c) remove all other identities which do not match the user's primary // or alias email if 'kolab_delegation_purge_identities' is set. $delegators = $this->list_delegators(); $use_subs = $this->rc->config->get('kolab_use_subscriptions'); $identities = $this->rc->user->list_emails(); $emails = []; $uids = []; if (!empty($delegators)) { $storage = $this->rc->get_storage(); $other_ns = $storage->get_namespace('other') ?: []; $folders = $storage->list_folders(); } // convert identities to simpler format for faster access foreach ($identities as $idx => $ident) { // get user name from default identity if (!$idx) { $default = [ 'name' => $ident['name'], ]; } $emails[$ident['identity_id']] = $ident['email']; } // for every delegator... foreach ($delegators as $delegator) { $uids[$delegator['imap_uid']] = $email_arr = $delegator['email']; $diff = array_intersect($emails, $email_arr); // identity with delegator's email already exist, do nothing if (count($diff)) { $emails = array_diff($emails, $email_arr); continue; } // create identities for delegator emails foreach ($email_arr as $email) { // @TODO: "Delegatorname" or "Username on behalf of Delegatorname"? $default['name'] = $delegator['realname']; $default['email'] = $email; // Database field for organization is NOT NULL $default['organization'] = empty($delegator['organization']) ? '' : $delegator['organization']; $this->rc->user->insert_identity($default); } // IMAP folders shared by new delegators shall be subscribed on login, // as well as existing subscriptions of previously shared folders shall // be removed. I suppose the latter one is already done in Roundcube. // for every accessible folder... foreach ($folders as $folder) { // for every 'other' namespace root... foreach ($other_ns as $ns) { $prefix = $ns[0] . $delegator['imap_uid']; // subscribe delegator's folder if ($folder === $prefix || strpos($folder, $prefix . substr($ns[0], -1)) === 0) { // Event/Task folders need client-side activation $type = kolab_storage::folder_type($folder); if (preg_match('/^(event|task)/i', $type)) { kolab_storage::folder_activate($folder); } // Subscribe to mail folders and (if system is configured // to display only subscribed folders) to other if ($use_subs || preg_match('/^mail/i', $type)) { $storage->subscribe($folder); } } } } } // remove identities that "do not belong" to user nor delegators if ($this->rc->config->get('kolab_delegation_purge_identities')) { $user = $this->user(true); $emails = array_diff($emails, $user['email']); foreach (array_keys($emails) as $idx) { $this->rc->user->delete_identity($idx); } } $_SESSION['delegators'] = $uids; } /** * Sets delegator context according to email message recipient * * @param rcube_message $message Email message object */ public function delegator_context_from_message($message) { if (empty($_SESSION['delegators'])) { return; } // Match delegators' addresses with message To: address // @TODO: Is this reliable enough? // Roundcube sends invitations to every attendee separately, // but maybe there's a software which sends with CC header or many addresses in To: $emails = $message->get_header('to'); $emails = rcube_mime::decode_address_list($emails, null, false); foreach ($emails as $email) { foreach ($_SESSION['delegators'] as $uid => $addresses) { if (in_array($email['mailto'], $addresses)) { return $this->context = $uid; } } } } /** * Return (set) current delegator context * * @return string Delegator UID */ public function delegator_context() { if (!$this->context && !empty($_SESSION['delegators'])) { $context = rcube_utils::get_input_value('_context', rcube_utils::INPUT_GPC); if ($context && isset($_SESSION['delegators'][$context])) { $this->context = $context; } } return $this->context; } /** * Set user identity according to delegator delegator * * @param array $args Reference to plugin hook arguments */ public function delegator_identity_filter(&$args) { $context = $this->delegator_context(); if (!$context) { return; } $identities = $this->rc->user->list_emails(); $emails = $_SESSION['delegators'][$context]; foreach ($identities as $ident) { if (in_array($ident['email'], $emails)) { $args['identity'] = $ident; return; } } // fallback to default identity $args['identity'] = array_shift($identities); } /** * Filter user emails according to delegator context * * @param array $args Reference to plugin hook arguments */ public function delegator_emails_filter(&$args) { $context = $this->delegator_context(); // try to derive context from the given user email if (!$context && !empty($args['emails'])) { if (($user = preg_replace('/@.+$/', '', $args['emails'][0])) && isset($_SESSION['delegators'][$user])) { $context = $user; } } // return delegator's addresses if ($context) { $args['emails'] = $_SESSION['delegators'][$context]; $args['abort'] = true; } // return only user addresses (exclude all delegators addresses) elseif (!empty($_SESSION['delegators'])) { $identities = $this->rc->user->list_emails(); $emails[] = $this->rc->user->get_username(); foreach ($identities as $identity) { $emails[] = $identity['email']; } foreach ($_SESSION['delegators'] as $delegator_emails) { $emails = array_diff($emails, $delegator_emails); } $args['emails'] = array_unique($emails); $args['abort'] = true; } } /** * Filters list of calendar/task folders according to delegator context * * @param array $args Reference to plugin hook arguments */ public function delegator_folder_filter(&$args, $mode = 'calendars') { $context = $this->delegator_context(); if (empty($context)) { return $args; } $storage = $this->rc->get_storage(); $other_ns = $storage->get_namespace('other') ?: []; $delim = $storage->get_hierarchy_delimiter(); if ($mode == 'calendars') { $editable = $args['filter'] & calendar_driver::FILTER_WRITEABLE; $active = $args['filter'] & calendar_driver::FILTER_ACTIVE; $personal = $args['filter'] & calendar_driver::FILTER_PERSONAL; $shared = $args['filter'] & calendar_driver::FILTER_SHARED; } else { $editable = $args['filter'] & tasklist_driver::FILTER_WRITEABLE; $active = $args['filter'] & tasklist_driver::FILTER_ACTIVE; $personal = $args['filter'] & tasklist_driver::FILTER_PERSONAL; $shared = $args['filter'] & tasklist_driver::FILTER_SHARED; } $folders = []; foreach ($args['list'] as $folder) { if (isset($folder->ready) && !$folder->ready) { continue; } if ($editable && !$folder->editable) { continue; } if ($active && !$folder->storage->is_active()) { continue; } if ($personal || $shared) { $ns = $folder->get_namespace(); if ($personal && $ns == 'personal') { continue; } elseif ($personal && $ns == 'other') { $found = false; foreach ($other_ns as $ns) { $c_folder = $ns[0] . $context . $delim; if (strpos($folder->name, $c_folder) === 0) { $found = true; } } if (!$found) { continue; } } elseif (!$shared || $ns != 'shared') { continue; } } $folders[$folder->id] = $folder; } $args[$mode] = $folders; $args['abort'] = true; } /** * Filters/updates message headers according to delegator context * * @param array $args Reference to plugin hook arguments */ public function delegator_delivery_filter(&$args) { // no context, but message still can be send on behalf of... if (!empty($_SESSION['delegators'])) { $message = $args['message']; $headers = $message->headers(); // get email address from From: header $from = rcube_mime::decode_address_list($headers['From']); $from = array_shift($from); $from = $from['mailto']; foreach ($_SESSION['delegators'] as $uid => $addresses) { if (in_array($from, $addresses)) { $context = $uid; break; } } // add Sender: header with current user default identity if (!empty($context)) { $identity = $this->rc->user->get_identity(); $sender = format_email_recipient($identity['email'], $identity['name']); $message->headers(['Sender' => $sender], false, true); } } } /** * Compares two ACLs (according to supported rights) * * @param array $acl1 ACL rights array (or string) * @param array $acl2 ACL rights array (or string) * * @return bool True if $acl1 contains all rights from $acl2 */ public function acl_compare($acl1, $acl2) { if (!is_array($acl1)) { $acl1 = str_split($acl1); } if (!is_array($acl2)) { $acl2 = str_split($acl2); } $rights = $this->rights_supported(); $acl1 = array_intersect($acl1, $rights); $acl2 = array_intersect($acl2, $rights); $res = array_intersect($acl1, $acl2); $cnt1 = count($res); $cnt2 = count($acl2); return $cnt1 >= $cnt2; } /** * Get list of supported access rights (according to RIGHTS capability) * * @todo: this is stolen from acl plugin, move to rcube_storage/rcube_imap * * @return array List of supported access rights abbreviations */ public function rights_supported() { if ($this->supported !== null) { return $this->supported; } $storage = $this->rc->get_storage(); $capa = $storage->get_capability('RIGHTS'); if (is_array($capa)) { $rights = strtolower($capa[0]); } else { $rights = 'cd'; } return $this->supported = str_split('lrswi' . $rights . 'pa'); } private function right_types() { // Get supported rights and build column names $supported = $this->rights_supported(); // depending on server capability either use 'te' or 'd' for deleting msgs $deleteright = implode('', array_intersect(str_split('ted'), $supported)); return [ self::ACL_READ => 'lrs', self::ACL_WRITE => 'lrswi' . $deleteright, ]; } } diff --git a/plugins/libkolab/lib/kolab_storage.php b/plugins/libkolab/lib/kolab_storage.php index 54e248a5..4fe9abe0 100644 --- a/plugins/libkolab/lib/kolab_storage.php +++ b/plugins/libkolab/lib/kolab_storage.php @@ -1,1818 +1,1820 @@ * @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 { public const CTYPE_KEY = '/shared/vendor/kolab/folder-type'; public const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type'; public const COLOR_KEY_SHARED = '/shared/vendor/kolab/color'; public const COLOR_KEY_PRIVATE = '/private/vendor/kolab/color'; public const NAME_KEY_SHARED = '/shared/vendor/kolab/displayname'; public const NAME_KEY_PRIVATE = '/private/vendor/kolab/displayname'; public const UID_KEY_SHARED = '/shared/vendor/kolab/uniqueid'; public const UID_KEY_CYRUS = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; public const ERROR_IMAP_CONN = 1; public const ERROR_CACHE_DB = 2; public const ERROR_NO_PERMISSION = 3; public 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 $ldapcache = []; private static $ldap = []; private static $states; private static $config; private static $imap; // Default folder names private static $default_folders = [ '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) { // do nothing } elseif (!class_exists('kolabformat')) { rcube::raise_error([ 'code' => 900, 'type' => 'php', 'message' => "required kolabformat module not found", ], true); } elseif (self::$imap->get_error_code()) { rcube::raise_error([ 'code' => 900, 'type' => 'php', 'message' => "IMAP error", ], 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 * * @param string $name Name of the configuration option with LDAP config + * + * @return ?kolab_ldap Kolab LDAP addressbook */ public static function ldap($name = 'kolab_users_directory') { self::setup(); $config = self::$config->get($name); if (empty($config)) { $name = 'kolab_auth_addressbook'; $config = self::$config->get($name); } if (!empty(self::$ldap[$name])) { return self::$ldap[$name]; } if (!is_array($config)) { $ldap_config = (array)self::$config->get('ldap_public'); $config = $ldap_config[$config] ?? null; } if (empty($config)) { return null; } $ldap = new kolab_ldap($config); // overwrite filter option if ($filter = self::$config->get('kolab_users_filter')) { self::$config->set('kolab_auth_filter', $filter); } $user_field = $user_attrib = self::$config->get('kolab_users_id_attrib'); // Fallback to kolab_auth_login, which is not attribute, but field name if (!$user_field && ($user_field = self::$config->get('kolab_auth_login', 'email'))) { $user_attrib = $config['fieldmap'][$user_field] ?? null; } if ($user_field && $user_attrib) { $ldap->extend_fieldmap([$user_field => $user_attrib]); } self::$ldap[$name] = $ldap; return $ldap; } /** * Get a list of storage folders for the given data type * * @param string $type Data type to list folders for (contact,distribution-list,event,task,note) * @param ?bool $subscribed 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 = []; 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 $type Data type to list folders for (contact,distribution-list,event,task,note) * * @return kolab_storage_folder|null 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 $folder IMAP folder to access (UTF7-IMAP) * @param string $type Expected folder type * * @return kolab_storage_folder|null 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 $uid Object UID * @param string $type Object type (contact,event,task,journal,file,note,configuration) * * @return array|false 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 $query Pseudo-SQL query as list of filter parameter triplets * @param string $type Folder type (contact,event,task,journal,file,note,configuration) * @param ?int $limit Expected number of records or limit (for performance reasons) * * @return array List of Kolab data objects (each represented as hash array) * @see kolab_storage_format::select() */ public static function select($query, $type, $limit = null) { self::setup(); $folder = null; $result = []; foreach ((array)self::list_folders('', '*', $type, null, $folderdata) as $foldername) { $folder = new kolab_storage_folder($foldername, $type, $folderdata[$foldername]); if ($limit) { $folder->set_order_and_limit(null, $limit); } foreach ($folder->select($query) as $object) { $result[] = $object; } } return $result; } /** * Returns Free-busy server URL */ public static function get_freebusy_server() { $rcmail = rcube::get_instance(); if ($url = $rcmail->config->get('kolab_freebusy_server')) { $url = rcube_utils::resolve_url($url); $url = unslashify($url); } return $url; } /** * Compose an URL to query the free/busy status for the given user * * @param string $email Email address of the user to get free/busy data for * @param ?DateTime $start Start of the query range (optional) * @param ?DateTime $end End of the query range (optional) * * @return ?string Fully qualified URL to query free/busy data */ public static function get_freebusy_url($email, $start = null, $end = null) { $url = self::get_freebusy_server(); if (empty($url)) { return null; } $query = ''; $param = []; $utc = new \DateTimeZone('UTC'); // https://www.calconnect.org/pubdocs/CD0903%20Freebusy%20Read%20URL.pdf if ($start instanceof \DateTime) { $start->setTimezone($utc); $param['start'] = $param['dtstart'] = $start->format('Ymd\THis\Z'); } if ($end instanceof \DateTime) { $end->setTimezone($utc); $param['end'] = $param['dtend'] = $end->format('Ymd\THis\Z'); } if (!empty($param)) { $query = '?' . http_build_query($param); } if (strpos($url, '%u')) { // Expected configured full URL, just replace the %u variable // Note: Cyrus v3 Free-Busy service does not use .ifb extension $url = str_replace('%u', rawurlencode($email), $url); } else { $url .= '/' . $email . '.ifb'; } return $url . $query; } /** * Creates folder ID from folder name * * @param string $folder Folder name (UTF7-IMAP) * @param bool $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 $name Namespace name (personal, shared, other) * * @return string IMAP root path for that namespace */ public static function namespace_root($name) { self::setup(); 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', ['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', ['record' => [ '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 elseif ($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', [ '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 &$prop Hash array with folder properties and metadata * - name: Folder name * - oldname: Old folder name when changed * - parent: Parent folder to create the new one in * - type: Folder type to create * - subscribed: Subscribed flag (IMAP subscription) * - active: Activation flag (client-side subscription) * * @return string|false New folder name or False on failure * * @see self::set_folder_props() for list of other properties */ 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) elseif (!strlen($folder)) { self::$last_error = 'cannotbeempty'; return false; } elseif (strlen($folder) > 128) { self::$last_error = 'nametoolong'; return false; } else { // these characters are problematic e.g. when used in LIST/LSUB foreach ([$delimiter, '%', '*'] as $char) { if (strpos($folder, $char) !== false) { self::$last_error = 'forbiddencharacter'; return false; } } } if (!empty($options) && (!empty($options['protected']) || !empty($options['norename']))) { $folder = $oldfolder; } elseif (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) * with kolab_custom_display_names support. * 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) { // find custom display name in folder METADATA if ($name = self::custom_displayname($folder)) { return $name; } return self::object_prettyname($folder, $folder_ns); } /** * Get custom display name (saved in metadata) for the given folder */ public static function custom_displayname($folder) { static $_metadata; // find custom display name in folder METADATA if (self::$config->get('kolab_custom_display_names', true) && self::setup()) { if ($_metadata !== null) { $metadata = $_metadata; } else { // For performance reasons ask for all folders, it will be cached as one cache entry $metadata = self::$imap->get_metadata("*", [self::NAME_KEY_PRIVATE, self::NAME_KEY_SHARED]); // If cache is disabled store result in memory if (!self::$config->get('imap_cache')) { $_metadata = $metadata; } } if ($data = $metadata[$folder] ?? null) { if (($name = $data[self::NAME_KEY_PRIVATE]) || ($name = $data[self::NAME_KEY_SHARED])) { return $name; } } } return 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_prettyname($folder, &$folder_ns = null) { self::setup(); $found = false; $namespace = self::$imap->get_namespace(); $prefix = null; 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 and extract username $folder = substr($folder, strlen($ns[0])); $delim = $ns[1]; // get username part and map it to user name $pos = strpos($folder, $delim); $fid = $pos ? substr($folder, 0, $pos) : $folder; if ($user = self::folder_id2user($fid, true)) { $fid = str_replace($delim, '', $user); } $prefix = "($fid)"; $folder = $pos ? substr($folder, $pos + 1) : ''; $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 (empty($folder_ns)) { $folder_ns = 'personal'; } return $folder; } /** * 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 elseif (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 = []; $len = strlen($current); $parent = ''; $p_len = 0; 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; } } if ($p_len && $name == $parent) { // always show the parent of current folder } elseif ($c_folder->get_owner() != $_SESSION['username']) { // skip folders where user have no rights to create subfolders $rights = $c_folder->get_myrights(); if (!preg_match('/[ck]/', $rights)) { continue; } } $names[$name] = $c_folder->get_name(); } // Build SELECT field of parent folder $attrs['is_escaped'] = true; $select = new html_select($attrs); $select->add('---', ''); $listnames = []; 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 $root Optional root folder * @param string $mbox Optional name pattern * @param string $filter Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param bool $subscribed Enable to return subscribed folders only (null to use configured subscription mode) * @param array $folderdata Will be filled with folder-types data * * @return array List of folders */ public static function list_folders($root = '', $mbox = '*', $filter = null, $subscribed = null, &$folderdata = []) { if (!self::setup()) { return []; } // 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_subscribed($root, $mbox, $filter); } else { $folders = self::_imap_list_folders($root, $mbox); } return $folders; } $prefix = $root . $mbox; $regexp = '/^' . preg_quote($filter, '/') . '(\..+)?$/'; // get folders types for all folders $folderdata = self::folders_typedata($prefix); if (!is_array($folderdata)) { return []; } // If we only want groupware folders and don't care about the subscription state, // then the metadata will already contain all folder names and we can avoid the LIST below. if (!$subscribed && $filter != 'mail' && $prefix == '*') { foreach ($folderdata as $folder => $type) { if (!preg_match($regexp, (string) $type)) { unset($folderdata[$folder]); } } return self::$imap->sort_folder_list(array_keys($folderdata), true); } // Get folders list if ($subscribed) { $folders = self::_imap_list_subscribed($root, $mbox, $filter); } else { $folders = self::_imap_list_folders($root, $mbox); } // In case of an error, return empty list (?) if (!is_array($folders)) { return []; } // Filter folders list foreach ($folders as $idx => $folder) { $type = $folderdata[$folder] ?? null; 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 = []; foreach ((array)$skip_ns as $ns) { if ($ns_root = self::namespace_root($ns)) { $excludes[] = $ns_root; } } if (count($excludes)) { $postfilter = '!^(' . implode(')|(', 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; } /** * Wrapper for rcube_imap::list_folders_subscribed() * with support for temporarily subscribed folders */ protected static function _imap_list_subscribed($root, $mbox, $filter = null) { $folders = self::$imap->list_folders_subscribed($root, $mbox); // add temporarily subscribed folders if ($filter != 'mail' && self::$with_tempsubs && is_array($_SESSION['kolab_subscribed_folders'] ?? null)) { $folders = array_unique(array_merge($folders, $_SESSION['kolab_subscribed_folders'])); } return $folders; } /** * Search for shared or otherwise not listed groupware folders the user has access * * @param string $type Folder type of folders to search for * @param string $query Search string * @param array $exclude_ns Namespace(s) to exclude results from * * @return array List of matching kolab_storage_folder objects */ public static function search_folders($type, $query, $exclude_ns = []) { if (!self::setup()) { return []; } $folders = []; $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 $folders List of kolab_storage_folder objects * * @return array Sorted list of folders */ public static function sort_folders($folders) { $pad = ' '; $out = []; $nsnames = ['personal' => [], 'shared' => [], 'other' => []]; $_folders = []; foreach ($folders as $folder) { $_folders[$folder->name] = $folder; $ns = $folder->get_namespace(); $nsnames[$ns][$folder->name] = strtolower(html_entity_decode($folder->get_name(), 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 ?kolab_storage_folder_virtual $tree Reference to the root node of the folder tree * * @return array Flat folders list */ public static function folder_hierarchy($folders, &$tree = null) { if (!self::setup()) { return []; } $_folders = []; $delim = self::$imap->get_hierarchy_delimiter(); $other_ns = rtrim(self::namespace_root('other'), $delim); $tree = new kolab_storage_folder_virtual('', '', ''); // create tree root $refs = ['' => $tree]; foreach ($folders as $idx => $folder) { $path = explode($delim, $folder->name); array_pop($path); $folder->parent = implode($delim, $path); $folder->children = []; // 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 = []; $depth = $folder->get_namespace() == 'personal' ? 1 : 2; while (count($path) >= $depth && ($parent = implode($delim, $path))) { array_pop($path); $parent_parent = implode($delim, $path); if (empty($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; } elseif ($parent_parent == $other_ns) { $refs[$parent] = new kolab_storage_folder_user($parent, $parent_parent); } else { $name = kolab_storage::object_name($parent); $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 = !empty($refs[$parent->parent]) ? $refs[$parent->parent] : $tree; $parent_node->children[] = $parent; $_folders[] = $parent; } } $parent_node = !empty($refs[$folder->parent]) ? $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; } $type_keys = [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 = []; foreach ((array)$skip_ns as $ns) { if ($ns_root = rtrim(self::namespace_root($ns), $delimiter)) { $blacklist[] = $ns_root; } } foreach (['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; } } } } elseif ($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; } return array_map(['kolab_storage', 'folder_select_metadata'], $folderdata); } /** * 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]; } elseif (!empty($types[self::CTYPE_KEY])) { [$ctype, ] = explode('.', $types[self::CTYPE_KEY]); return $ctype; } return null; } /** * Returns type of IMAP folder * * @param string $folder Folder name (UTF7-IMAP) * * @return string|null Folder type */ public static function folder_type($folder) { self::setup(); $metadata = self::$imap->get_metadata($folder, [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 bool True on success */ public static function set_folder_type($folder, $type = 'mail') { self::setup(); [$ctype, $subtype] = strpos($type, '.') !== false ? explode('.', $type) : [$type, null]; $success = self::$imap->set_metadata($folder, [self::CTYPE_KEY => $ctype, self::CTYPE_KEY_PRIVATE => $subtype ? $type : null]); if (!$success) { // fallback: only set private annotation $success |= self::$imap->set_metadata($folder, [self::CTYPE_KEY_PRIVATE => $type]); } return $success; } /** * Check subscription status of this folder * * @param string $folder Folder name * @param bool $temp Include temporary/session subscriptions * * @return bool True if subscribed, false if not */ public 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, $_SESSION['kolab_subscribed_folders'] ?? [])); } /** * Change subscription status of this folder * * @param string $folder Folder name * @param bool $temp Only subscribe temporarily for the current session * * @return bool 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; } elseif (empty($_SESSION['kolab_subscribed_folders']) || !in_array($folder, $_SESSION['kolab_subscribed_folders'])) { $_SESSION['kolab_subscribed_folders'][] = $folder; return true; } } elseif (self::$imap->subscribe($folder)) { self::$subscriptions = null; return true; } return false; } /** * Change subscription status of this folder * * @param string $folder Folder name * @param bool $temp Only remove temporary subscription * * @return bool True on success, false on error */ public static function folder_unsubscribe($folder, $temp = false) { self::setup(); // temporary/session subscription if ($temp) { if (!empty($_SESSION['kolab_subscribed_folders']) && ($i = array_search($folder, $_SESSION['kolab_subscribed_folders'])) !== false) { unset($_SESSION['kolab_subscribed_folders'][$i]); } return true; } elseif (self::$imap->unsubscribe($folder)) { self::$subscriptions = null; return true; } return false; } /** * Check activation status of this folder * * @param string $folder Folder name * * @return bool 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 bool 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 bool 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) : []; } // 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 = (array) self::$subscriptions; $folders = implode('**', self::$states); $rcube->user->save_prefs(['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; } elseif (!$state && $idx !== false) { unset(self::$states[$idx]); } // update user preferences $folders = implode('**', self::$states); return rcube::get_instance()->user->save_prefs(['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 array $props Folder properties (color, etc) * * @return string|null Folder name */ public static function create_default_folder($type, $props = []) { if (!self::setup()) { return null; } $folders = self::$imap->get_metadata('*', [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 (empty($folder)) { if (!$default_name) { $default_name = self::$default_folders[$type]; } if (!$default_name) { return null; } $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 null; } } 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 (color, displayname) */ 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 = [ 'color' => [self::COLOR_KEY_SHARED, self::COLOR_KEY_PRIVATE], 'displayname' => [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, [$metakeys[0] => $prop[$key]]); } if (!$meta_saved) { // try in private namespace $meta_saved = self::$imap->set_metadata($folder, [$metakeys[1] => $prop[$key]]); } if ($meta_saved) { unset($prop[$key]); } // unsetting will prevent fallback to local user prefs } } } /** * Search users in Kolab LDAP storage * * @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 of users */ public static function search_users($query, $mode = 1, $required = [], $limit = 0, &$count = 0) { $query = str_replace('*', '', $query); // requires a working LDAP setup if (!strlen($query) || !($ldap = self::ldap())) { return []; } $root = self::namespace_root('other'); $user_attrib = self::$config->get('kolab_users_id_attrib', self::$config->get('kolab_auth_login', 'mail')); $search_attrib = self::$config->get('kolab_users_search_attrib', ['cn','mail','alias']); // search users using the configured attributes $results = $ldap->dosearch($search_attrib, $query, $mode, $required, $limit, $count); // exclude myself if ($_SESSION['kolab_dn']) { unset($results[$_SESSION['kolab_dn']]); } // resolve to IMAP folder name array_walk($results, function (&$user, $dn) use ($root, $user_attrib) { [$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 User entry from LDAP * @param string $type Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param int $subscribed 1 - subscribed folders only, 0 - all folders, 2 - all non-active * @param array $folderdata Will be filled with folder-types data * * @return array List of folders */ public static function list_user_folders($user, $type, $subscribed = 0, &$folderdata = []) { self::setup(); $folders = []; // 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])) { [$mbox] = explode('@', $user[$user_attrib]); $delimiter = self::$imap->get_hierarchy_delimiter(); $other_ns = self::namespace_root('other'); $prefix = $other_ns . $mbox . $delimiter; $subscribed = (int) $subscribed; $subs = $subscribed < 2 ? (bool) $subscribed : false; $folders = self::list_folders($prefix, '*', $type, $subs, $folderdata); if ($subscribed === 2 && !empty($folders)) { $active = self::get_states(); if (!empty($active)) { $folders = array_diff($folders, $active); } } } return $folders; } /** * Get a list of (virtual) top-level folders from the other users namespace * * @param string $type Data type to list folders for (contact,event,task,journal,file,note,mail,configuration) * @param bool $subscribed 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 = []; 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 = implode($delimiter, array_slice($path, 0, $path_len + 1)); if (empty($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 (empty($folders[$foldername])) { $folders[$foldername] = new kolab_storage_folder($foldername, $type, $folderdata[$foldername] ?? null); $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 = rcube::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); } /** * Get folder METADATA for all supported keys * Do this in one go for better caching performance */ public static function folder_metadata($folder) { if (self::setup()) { $keys = [ // For better performance we skip displayname here, see (self::custom_displayname()) // self::NAME_KEY_PRIVATE, // self::NAME_KEY_SHARED, self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE, self::COLOR_KEY_PRIVATE, self::COLOR_KEY_SHARED, self::UID_KEY_SHARED, self::UID_KEY_CYRUS, ]; $metadata = self::$imap->get_metadata($folder, $keys); return $metadata[$folder]; } } /** * Get user attributes for specified other user (imap) folder identifier. * * @param string $folder_id Folder name w/o path (imap user identifier) * @param bool $as_string Return configured display name attribute value * * @return array|string|null User attributes * @see self::ldap() */ public static function folder_id2user($folder_id, $as_string = false) { static $domain, $cache, $name_attr; $rcube = rcube::get_instance(); if ($domain === null) { [, $domain] = explode('@', $rcube->get_user_name()); } if ($name_attr === null) { $name_attr = (array) ($rcube->config->get('kolab_users_name_field', $rcube->config->get('kolab_auth_name')) ?: 'name'); } $token = $folder_id; if ($domain && strpos($token, '@') === false) { $token .= '@' . $domain; } if ($cache === null) { $cache = $rcube->get_cache_shared('kolab_users') ?: false; } // use value cached in memory for repeated lookups if (!$cache && array_key_exists($token, self::$ldapcache)) { $user = self::$ldapcache[$token]; } if (empty($user) && $cache) { $user = $cache->get($token); } if (empty($user) && ($ldap = self::ldap())) { $user = $ldap->get_user_record($token, $_SESSION['imap_host'] ?? ''); if (!empty($user)) { $keys = ['displayname', 'name', 'mail']; // supported keys $user = array_intersect_key($user, array_flip($keys)); if (!empty($user)) { if ($cache) { $cache->set($token, $user); } else { self::$ldapcache[$token] = $user; } } } } if (!empty($user)) { if ($as_string) { foreach ($name_attr as $attr) { if ($display = $user[$attr]) { break; } } if (empty($display)) { $display = $user['displayname'] ?: $user['name']; } if ($display && $display != $folder_id) { $display = "$display ($folder_id)"; } return $display; } return $user; } return null; } /** * Chwala's 'folder_mod' hook handler for mapping other users folder names */ public static function folder_mod($args) { static $roots; if ($roots === null) { self::setup(); $roots = self::$imap->get_namespace('other'); } // Note: We're working with UTF7-IMAP encoding here if ($args['dir'] == 'in') { foreach ((array) $roots as $root) { if (strpos($args['folder'], $root[0]) === 0) { // remove root and explode folder $delim = $root[1]; $folder = explode($delim, substr($args['folder'], strlen($root[0]))); // compare first (user) part with a regexp, it's supposed // to look like this: "Doe, Jane (uid)", so we can extract the uid // and replace the folder with it if (preg_match('~^[^/]+ \(([^)]+)\)$~', $folder[0], $m)) { $folder[0] = $m[1]; $args['folder'] = $root[0] . implode($delim, $folder); } break; } } } else { // dir == 'out' foreach ((array) $roots as $root) { if (strpos($args['folder'], $root[0]) === 0) { // remove root and explode folder $delim = $root[1]; $folder = explode($delim, substr($args['folder'], strlen($root[0]))); // Replace uid with "Doe, Jane (uid)" if ($user = self::folder_id2user($folder[0], true)) { $user = str_replace($delim, '', $user); $folder[0] = rcube_charset::convert($user, RCUBE_CHARSET, 'UTF7-IMAP'); $args['folder'] = $root[0] . implode($delim, $folder); } break; } } } return $args; } }