diff --git a/program/lib/Roundcube/bootstrap.php b/program/lib/Roundcube/bootstrap.php index d84bfc1cc..abdf67159 100644 --- a/program/lib/Roundcube/bootstrap.php +++ b/program/lib/Roundcube/bootstrap.php @@ -1,449 +1,452 @@ | | Author: Aleksander Machniak | +-----------------------------------------------------------------------+ */ /** * Roundcube Framework Initialization * * @package Framework * @subpackage Core */ $config = [ 'error_reporting' => E_ALL & ~E_NOTICE & ~E_STRICT, 'display_errors' => false, 'log_errors' => true, // Some users are not using Installer, so we'll check some // critical PHP settings here. Only these, which doesn't provide // an error/warning in the logs later. See (#1486307). 'mbstring.func_overload' => 0, ]; // check these additional ini settings if not called via CLI if (php_sapi_name() != 'cli') { $config += [ 'suhosin.session.encrypt' => false, 'file_uploads' => true, 'session.auto_start' => false, 'zlib.output_compression' => false, ]; } foreach ($config as $optname => $optval) { $ini_optval = filter_var(ini_get($optname), is_bool($optval) ? FILTER_VALIDATE_BOOLEAN : FILTER_VALIDATE_INT); if ($optval != $ini_optval && @ini_set($optname, $optval) === false) { $optval = !is_bool($optval) ? $optval : ($optval ? 'On' : 'Off'); $error = "ERROR: Wrong '$optname' option value and it wasn't possible to set it to required value ($optval).\n" . "Check your PHP configuration (including php_admin_flag)."; if (defined('STDERR')) fwrite(STDERR, $error); else echo $error; exit(1); } } // framework constants define('RCUBE_VERSION', '1.5-git'); define('RCUBE_CHARSET', 'UTF-8'); define('RCUBE_TEMP_FILE_PREFIX', 'RCMTEMP'); if (!defined('RCUBE_LIB_DIR')) { define('RCUBE_LIB_DIR', __DIR__ . '/'); } if (!defined('RCUBE_INSTALL_PATH')) { define('RCUBE_INSTALL_PATH', RCUBE_LIB_DIR); } if (!defined('RCUBE_CONFIG_DIR')) { define('RCUBE_CONFIG_DIR', RCUBE_INSTALL_PATH . 'config/'); } if (!defined('RCUBE_PLUGINS_DIR')) { define('RCUBE_PLUGINS_DIR', RCUBE_INSTALL_PATH . 'plugins/'); } if (!defined('RCUBE_LOCALIZATION_DIR')) { define('RCUBE_LOCALIZATION_DIR', RCUBE_INSTALL_PATH . 'localization/'); } // set internal encoding for mbstring extension mb_internal_encoding(RCUBE_CHARSET); mb_regex_encoding(RCUBE_CHARSET); // make sure the Roundcube lib directory is in the include_path $rcube_path = realpath(RCUBE_LIB_DIR . '..'); $sep = PATH_SEPARATOR; $regexp = "!(^|$sep)" . preg_quote($rcube_path, '!') . "($sep|\$)!"; $path = ini_get('include_path'); if (!preg_match($regexp, $path)) { set_include_path($path . PATH_SEPARATOR . $rcube_path); } // Register autoloader spl_autoload_register('rcube_autoload'); // set PEAR error handling (will also load the PEAR main class) PEAR::setErrorHandling(PEAR_ERROR_CALLBACK, function($err) { rcube::raise_error($err, true); }); /** * Similar function as in_array() but case-insensitive with multibyte support. * * @param string $needle Needle value * @param array $heystack Array to search in * * @return bool True if found, False if not */ function in_array_nocase($needle, $haystack) { if (!is_string($needle) || !is_array($haystack)) { return false; } // use much faster method for ascii if (is_ascii($needle)) { foreach ((array) $haystack as $value) { if (is_string($value) && strcasecmp($value, $needle) === 0) { return true; } } } else { $needle = mb_strtolower($needle); foreach ((array) $haystack as $value) { if (is_string($value) && $needle === mb_strtolower($value)) { return true; } } } return false; } /** * Parse a human readable string for a number of bytes. * * @param string $str Input string * * @return float Number of bytes */ function parse_bytes($str) { if (is_numeric($str)) { return floatval($str); } $bytes = 0; if (preg_match('/([0-9\.]+)\s*([a-z]*)/i', $str, $regs)) { $bytes = floatval($regs[1]); switch (strtolower($regs[2])) { case 'g': case 'gb': $bytes *= 1073741824; break; case 'm': case 'mb': $bytes *= 1048576; break; case 'k': case 'kb': $bytes *= 1024; break; } } return floatval($bytes); } /** * Make sure the string ends with a slash * * @param string $str A string * * @return string A string ending with a slash */ function slashify($str) { return unslashify($str) . '/'; } /** * Remove slashes at the end of the string * * @param string $str A string * * @return string A string ending with no slash */ function unslashify($str) { return rtrim($str, '/'); } /** * Returns number of seconds for a specified offset string. * * @param string $str String representation of the offset (e.g. 20min, 5h, 2days, 1week) * * @return int Number of seconds */ function get_offset_sec($str) { if (preg_match('/^([0-9]+)\s*([smhdw])/i', $str, $regs)) { $amount = (int) $regs[1]; $unit = strtolower($regs[2]); } else { $amount = (int) $str; $unit = 's'; } switch ($unit) { case 'w': $amount *= 7; case 'd': $amount *= 24; case 'h': $amount *= 60; case 'm': $amount *= 60; } return $amount; } /** * Create a unix timestamp with a specified offset from now. * * @param string $offset_str String representation of the offset (e.g. 20min, 5h, 2days) * @param int $factor Factor to multiply with the offset * * @return int Unix timestamp */ function get_offset_time($offset_str, $factor = 1) { return time() + get_offset_sec($offset_str) * $factor; } /** * Truncate string if it is longer than the allowed length. * Replace the middle or the ending part of a string with a placeholder. * * @param string $str Input string * @param int $maxlength Max. length * @param string $placeholder Replace removed chars with this * @param bool $ending Set to True if string should be truncated from the end * * @return string Abbreviated string */ function abbreviate_string($str, $maxlength, $placeholder = '...', $ending = false) { $length = mb_strlen($str); if ($length > $maxlength) { if ($ending) { return mb_substr($str, 0, $maxlength) . $placeholder; } $placeholder_length = mb_strlen($placeholder); $first_part_length = floor(($maxlength - $placeholder_length)/2); $second_starting_location = $length - $maxlength + $first_part_length + $placeholder_length; $prefix = mb_substr($str, 0, $first_part_length); $suffix = mb_substr($str, $second_starting_location); $str = $prefix . $placeholder . $suffix; } return $str; } /** * Get all keys from array (recursive). * * @param array $array Input array * * @return array List of array keys */ function array_keys_recursive($array) { $keys = []; if (!empty($array) && is_array($array)) { foreach ($array as $key => $child) { $keys[] = $key; foreach (array_keys_recursive($child) as $val) { $keys[] = $val; } } } return $keys; } /** * Get first element from an array * * @param array $array Input array * * @return mixed First element if found, Null otherwise */ function array_first($array) { if (is_array($array)) { reset($array); foreach ($array as $element) { return $element; } } } /** * Remove all non-ascii and non-word chars except ., -, _ * * @param string $str A string * @param bool $css_id The result may be used as CSS identifier * @param string $replace_with Replacement character * - * @return string Clean string + * @return string|null Clean string or null if $str is null */ function asciiwords($str, $css_id = false, $replace_with = '') { + if ($str == null) { + return null; + } $allowed = 'a-z0-9\_\-' . (!$css_id ? '\.' : ''); return preg_replace("/[^$allowed]+/i", $replace_with, $str); } /** * Check if a string contains only ascii characters * * @param string $str String to check * @param bool $control_chars Includes control characters * * @return bool True if the string contains ASCII-only, False otherwise */ function is_ascii($str, $control_chars = true) { $regexp = $control_chars ? '/[^\x00-\x7F]/' : '/[^\x20-\x7E]/'; return preg_match($regexp, $str) ? false : true; } /** * Compose a valid representation of name and e-mail address * * @param string $email E-mail address * @param string $name Person name * * @return string Formatted string */ function format_email_recipient($email, $name = '') { $email = trim($email); if ($name && $name != $email) { // Special chars as defined by RFC 822 need to in quoted string (or escaped). if (preg_match('/[\(\)\<\>\\\.\[\]@,;:"]/', $name)) { $name = '"'.addcslashes($name, '"').'"'; } return "$name <$email>"; } return $email; } /** * Format e-mail address * * @param string $email E-mail address * * @return string Formatted e-mail address */ function format_email($email) { $email = trim($email); $parts = explode('@', $email); $count = count($parts); if ($count > 1) { $parts[$count-1] = mb_strtolower($parts[$count-1]); $email = implode('@', $parts); } return $email; } /** * Fix version number so it can be used correctly in version_compare() * * @param string $version Version number string * * @param return Version number string */ function version_parse($version) { return str_replace( ['-stable', '-git'], ['.0', '.99'], $version ); } /** * Use PHP5 autoload for dynamic class loading * * @param string $classname Class name * * @return bool True when the class file has been found * * @todo Make Zend, PEAR etc play with this * @todo Make our classes conform to a more straight forward CS. */ function rcube_autoload($classname) { if (strpos($classname, 'rcube') === 0) { $classname = preg_replace('/^rcube_(cache|db|session|spellchecker)_/', '\\1/', $classname); $classname = 'Roundcube/' . $classname; } else if (strpos($classname, 'html_') === 0 || $classname === 'html') { $classname = 'Roundcube/html'; } else if (strpos($classname, 'Mail_') === 0) { $classname = 'Mail/' . substr($classname, 5); } else if (strpos($classname, 'Net_') === 0) { $classname = 'Net/' . substr($classname, 4); } else if (strpos($classname, 'Auth_') === 0) { $classname = 'Auth/' . substr($classname, 5); } // Translate PHP namespaces into directories, // i.e. use \Sabre\VObject; $vcf = VObject\Reader::read(...) // -> Sabre/VObject/Reader.php $classname = str_replace('\\', '/', $classname); if ($fp = @fopen("$classname.php", 'r', true)) { fclose($fp); include_once "$classname.php"; return true; } return false; } diff --git a/program/lib/Roundcube/rcube.php b/program/lib/Roundcube/rcube.php index 0b6915f67..e3a488240 100644 --- a/program/lib/Roundcube/rcube.php +++ b/program/lib/Roundcube/rcube.php @@ -1,1846 +1,1846 @@ | +-----------------------------------------------------------------------+ */ /** * Base class of the Roundcube Framework * implemented as singleton * * @package Framework * @subpackage Core */ class rcube { // Init options const INIT_WITH_DB = 1; const INIT_WITH_PLUGINS = 2; // Request status const REQUEST_VALID = 0; const REQUEST_ERROR_URL = 1; const REQUEST_ERROR_TOKEN = 2; const DEBUG_LINE_LENGTH = 4096; /** @var rcube_config Stores instance of rcube_config */ public $config; /** @var rcube_db Instance of database class */ public $db; /** @var Memcache Instance of Memcache class */ public $memcache; /** @var Memcached Instance of Memcached class */ public $memcached; /** @var Redis Instance of Redis class */ public $redis; /** @var rcube_session Instance of rcube_session class */ public $session; /** @var rcube_smtp Instance of rcube_smtp class */ public $smtp; /** @var rcube_storage Instance of rcube_storage class */ public $storage; /** @var rcube_output Instance of rcube_output class */ public $output; /** @var rcube_plugin_api Instance of rcube_plugin_api */ public $plugins; /** @var rcube_user Instance of rcube_user class */ public $user; /** @var int Request status */ public $request_status = 0; /** @var array Localization */ protected $texts; /** @var rcube_cache[] Initialized cache objects */ protected $caches = []; /** @var array Registered shutdown functions */ protected $shutdown_functions = []; /** @var rcube Singleton instance of rcube */ static protected $instance; /** * This implements the 'singleton' design pattern * * @param int $mode Options to initialize with this instance. See rcube::INIT_WITH_* constants * @param string $env Environment name to run (e.g. live, dev, test) * * @return rcube The one and only instance */ static function get_instance($mode = 0, $env = '') { if (!self::$instance) { self::$instance = new rcube($env); self::$instance->init($mode); } return self::$instance; } /** * Private constructor * * @param string $env Environment name to run (e.g. live, dev, test) */ protected function __construct($env = '') { // load configuration $this->config = new rcube_config($env); $this->plugins = new rcube_dummy_plugin_api; register_shutdown_function([$this, 'shutdown']); } /** * Initial startup function * * @param int $mode Options to initialize with this instance. See rcube::INIT_WITH_* constants */ protected function init($mode = 0) { // initialize syslog if ($this->config->get('log_driver') == 'syslog') { $syslog_id = $this->config->get('syslog_id', 'roundcube'); $syslog_facility = $this->config->get('syslog_facility', LOG_USER); openlog($syslog_id, LOG_ODELAY, $syslog_facility); } // connect to database if ($mode & self::INIT_WITH_DB) { $this->get_dbh(); } // create plugin API and load plugins if ($mode & self::INIT_WITH_PLUGINS) { $this->plugins = rcube_plugin_api::get_instance(); } } /** * Get the current database connection * * @return rcube_db Database object */ public function get_dbh() { if (!$this->db) { $this->db = rcube_db::factory( $this->config->get('db_dsnw'), $this->config->get('db_dsnr'), $this->config->get('db_persistent') ); $this->db->set_debug((bool)$this->config->get('sql_debug')); } return $this->db; } /** * Get global handle for memcache access * * @return Memcache The memcache engine */ public function get_memcache() { if (!isset($this->memcache)) { $this->memcache = rcube_cache_memcache::engine(); } return $this->memcache; } /** * Get global handle for memcached access * * @return Memcached The memcached engine */ public function get_memcached() { if (!isset($this->memcached)) { $this->memcached = rcube_cache_memcached::engine(); } return $this->memcached; } /** * Get global handle for redis access * * @return Redis The redis engine */ public function get_redis() { if (!isset($this->redis)) { $this->redis = rcube_cache_redis::engine(); } return $this->redis; } /** * Initialize and get user cache object * * @param string $name Cache identifier * @param string $type Cache type ('db', 'apc', 'memcache', 'redis') * @param string $ttl Expiration time for cache items * @param bool $packed Enables/disables data serialization * @param bool $indexed Use indexed cache * * @return rcube_cache|null User cache object */ public function get_cache($name, $type = 'db', $ttl = 0, $packed = true, $indexed = false) { if (!isset($this->caches[$name]) && ($userid = $this->get_user_id())) { $this->caches[$name] = rcube_cache::factory($type, $userid, $name, $ttl, $packed, $indexed); } - return $this->caches[$name]; + return $this->caches[$name] ?? null; } /** * Initialize and get shared cache object * * @param string $name Cache identifier * @param bool $packed Enables/disables data serialization * * @return rcube_cache Shared cache object */ public function get_cache_shared($name, $packed = true) { $shared_name = "shared_$name"; if (!array_key_exists($shared_name, $this->caches)) { $opt = strtolower($name) . '_cache'; $type = $this->config->get($opt); $ttl = $this->config->get($opt . '_ttl'); if (!$type) { // cache is disabled return $this->caches[$shared_name] = null; } if ($ttl === null) { $ttl = $this->config->get('shared_cache_ttl', '10d'); } $this->caches[$shared_name] = rcube_cache::factory($type, null, $name, $ttl, $packed); } return $this->caches[$shared_name]; } /** * Initialize HTTP client * * @param array $options Configuration options * * @return \GuzzleHttp\Client HTTP client */ public function get_http_client($options = []) { return new \GuzzleHttp\Client($options + $this->config->get('http_client')); } /** * Create SMTP object and connect to server * * @param boolean $connect True if connection should be established */ public function smtp_init($connect = false) { $this->smtp = new rcube_smtp(); if ($connect) { $this->smtp->connect(); } } /** * Initialize and get storage object * * @return rcube_storage Storage object */ public function get_storage() { // already initialized if (!is_object($this->storage)) { $this->storage_init(); } return $this->storage; } /** * Initialize storage object */ public function storage_init() { // already initialized if (is_object($this->storage)) { return; } $driver = $this->config->get('storage_driver', 'imap'); $driver_class = "rcube_{$driver}"; if (!class_exists($driver_class)) { self::raise_error([ 'code' => 700, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Storage driver class ($driver) not found!" ], true, true ); } // Initialize storage object $this->storage = new $driver_class; // for backward compat. (deprecated, will be removed) $this->imap = $this->storage; // set class options $options = [ 'auth_type' => $this->config->get("{$driver}_auth_type", 'check'), 'auth_cid' => $this->config->get("{$driver}_auth_cid"), 'auth_pw' => $this->config->get("{$driver}_auth_pw"), 'debug' => (bool) $this->config->get("{$driver}_debug"), 'force_caps' => (bool) $this->config->get("{$driver}_force_caps"), 'disabled_caps' => $this->config->get("{$driver}_disabled_caps"), 'socket_options' => $this->config->get("{$driver}_conn_options"), 'timeout' => (int) $this->config->get("{$driver}_timeout"), 'skip_deleted' => (bool) $this->config->get('skip_deleted'), 'driver' => $driver, ]; if (!empty($_SESSION['storage_host'])) { $options['language'] = $_SESSION['language']; $options['host'] = $_SESSION['storage_host']; $options['user'] = $_SESSION['username']; $options['port'] = $_SESSION['storage_port']; $options['ssl'] = $_SESSION['storage_ssl']; $options['password'] = $this->decrypt($_SESSION['password']); $_SESSION[$driver.'_host'] = $_SESSION['storage_host']; } $options = $this->plugins->exec_hook("storage_init", $options); // for backward compat. (deprecated, to be removed) $options = $this->plugins->exec_hook("imap_init", $options); $this->storage->set_options($options); $this->set_storage_prop(); // subscribe to 'storage_connected' hook for session logging if ($this->config->get('imap_log_session', false)) { $this->plugins->register_hook('storage_connected', [$this, 'storage_log_session']); } } /** * Set storage parameters. */ protected function set_storage_prop() { $storage = $this->get_storage(); // set pagesize from config $pagesize = $this->config->get('mail_pagesize'); if (!$pagesize) { $pagesize = $this->config->get('pagesize', 50); } $storage->set_pagesize($pagesize); $storage->set_charset($this->config->get('default_charset', RCUBE_CHARSET)); // enable caching of mail data $driver = $this->config->get('storage_driver', 'imap'); $storage_cache = $this->config->get("{$driver}_cache"); $messages_cache = $this->config->get('messages_cache'); // for backward compatibility if ($storage_cache === null && $messages_cache === null && $this->config->get('enable_caching')) { $storage_cache = 'db'; $messages_cache = true; } if ($storage_cache) { $storage->set_caching($storage_cache); } if ($messages_cache) { $storage->set_messages_caching(true); } } /** * Set special folders type association. * This must be done AFTER connecting to the server! */ protected function set_special_folders() { $storage = $this->get_storage(); $folders = $storage->get_special_folders(true); $prefs = []; // check SPECIAL-USE flags on IMAP folders foreach ($folders as $type => $folder) { $idx = $type . '_mbox'; if ($folder !== $this->config->get($idx)) { $prefs[$idx] = $folder; } } // Some special folders differ, update user preferences if (!empty($prefs) && $this->user) { $this->user->save_prefs($prefs); } // create default folders (on login) if ($this->config->get('create_default_folders')) { $storage->create_default_folders(); } } /** * Callback for IMAP connection events to log session identifiers * * @param array $args Callback arguments */ public function storage_log_session($args) { if (!empty($args['session']) && session_id()) { $this->write_log('imap_session', $args['session']); } } /** * Create session object and start the session. */ public function session_init() { // Ignore in CLI mode or when session started (Installer?) if (empty($_SERVER['REMOTE_ADDR']) || session_id()) { return; } $storage = $this->config->get('session_storage', 'db'); $sess_name = $this->config->get('session_name'); $sess_domain = $this->config->get('session_domain'); $sess_path = $this->config->get('session_path'); $sess_samesite = $this->config->get('session_samesite'); $lifetime = $this->config->get('session_lifetime', 0) * 60; $is_secure = $this->config->get('use_https') || rcube_utils::https_check(); // set session domain if ($sess_domain) { ini_set('session.cookie_domain', $sess_domain); } // set session path if ($sess_path) { ini_set('session.cookie_path', $sess_path); } // set session samesite attribute // requires PHP >= 7.3.0, see https://wiki.php.net/rfc/same-site-cookie for more info if (version_compare(PHP_VERSION, '7.3.0', '>=') && $sess_samesite) { ini_set('session.cookie_samesite', $sess_samesite); } // set session garbage collecting time according to session_lifetime if ($lifetime) { ini_set('session.gc_maxlifetime', $lifetime * 2); } // set session cookie lifetime so it never expires (#5961) ini_set('session.cookie_lifetime', 0); ini_set('session.cookie_secure', $is_secure); ini_set('session.name', $sess_name ?: 'roundcube_sessid'); ini_set('session.use_cookies', 1); ini_set('session.use_only_cookies', 1); ini_set('session.cookie_httponly', 1); // Make sure session garbage collector is enabled when using custom handlers (#6560) // Note: Use session.gc_divisor to control accuracy if ($storage != 'php' && !ini_get('session.gc_probability')) { ini_set('session.gc_probability', 1); } // Start the session $this->session = rcube_session::factory($this->config); $this->session->register_gc_handler([$this, 'gc']); $this->session->start(); } /** * Garbage collector - cache/temp cleaner */ public function gc() { rcube_cache::gc(); $this->get_storage()->cache_gc(); $this->gc_temp(); } /** * Garbage collector function for temp files. * Removes temporary files older than temp_dir_ttl. */ public function gc_temp() { $tmp = unslashify($this->config->get('temp_dir')); // expire in 48 hours by default $temp_dir_ttl = $this->config->get('temp_dir_ttl', '48h'); $temp_dir_ttl = get_offset_sec($temp_dir_ttl); if ($temp_dir_ttl < 6*3600) { $temp_dir_ttl = 6*3600; // 6 hours sensible lower bound. } $expire = time() - $temp_dir_ttl; if ($tmp && ($dir = opendir($tmp))) { while (($fname = readdir($dir)) !== false) { if (strpos($fname, RCUBE_TEMP_FILE_PREFIX) !== 0) { continue; } if (@filemtime("$tmp/$fname") < $expire) { @unlink("$tmp/$fname"); } } closedir($dir); } } /** * Runs garbage collector with probability based on * session settings. This is intended for environments * without a session. */ public function gc_run() { $probability = (int) ini_get('session.gc_probability'); $divisor = (int) ini_get('session.gc_divisor'); if ($divisor > 0 && $probability > 0) { $random = mt_rand(1, $divisor); if ($random <= $probability) { $this->gc(); } } } /** * Get localized text in the desired language * * @param mixed $attrib Named parameters array or label name * @param string $domain Label domain (plugin) name * * @return string Localized text */ public function gettext($attrib, $domain = null) { // load localization files if not done yet if (empty($this->texts)) { $this->load_language(); } // extract attributes if (is_string($attrib)) { $attrib = ['name' => $attrib]; } $name = (string) $attrib['name']; // attrib contain text values: use them from now $slang = !empty($_SESSION['language']) ? strtolower($_SESSION['language']) : 'en_us'; if (isset($attrib[$slang])) { $this->texts[$name] = $attrib[$slang]; } else if ($slang != 'en_us' && isset($attrib['en_us'])) { $this->texts[$name] = $attrib['en_us']; } // check for text with domain if ($domain && isset($this->texts["$domain.$name"])) { $text = $this->texts["$domain.$name"]; } else if (isset($this->texts[$name])) { $text = $this->texts[$name]; } // text does not exist if (!isset($text)) { return "[$name]"; } // replace vars in text if (!empty($attrib['vars']) && is_array($attrib['vars'])) { foreach ($attrib['vars'] as $var_key => $var_value) { $text = str_replace($var_key[0] != '$' ? '$'.$var_key : $var_key, $var_value, $text); } } // replace \n with real line break $text = strtr($text, ['\n' => "\n"]); // case folding if ((!empty($attrib['uppercase']) && strtolower($attrib['uppercase']) == 'first') || !empty($attrib['ucfirst'])) { $case_mode = MB_CASE_TITLE; } else if (!empty($attrib['uppercase'])) { $case_mode = MB_CASE_UPPER; } else if (!empty($attrib['lowercase'])) { $case_mode = MB_CASE_LOWER; } if (isset($case_mode)) { $text = mb_convert_case($text, $case_mode); } return $text; } /** * Check if the given text label exists * * @param string $name Label name * @param string $domain Label domain (plugin) name or '*' for all domains * @param string &$ref_domain Sets domain name if label is found * * @return bool True if text exists (either in the current language or in en_US) */ public function text_exists($name, $domain = null, &$ref_domain = null) { // load localization files if not done yet if (empty($this->texts)) { $this->load_language(); } if (isset($this->texts[$name])) { $ref_domain = ''; return true; } // any of loaded domains (plugins) if ($domain == '*') { foreach ($this->plugins->loaded_plugins() as $domain) { if (isset($this->texts[$domain.'.'.$name])) { $ref_domain = $domain; return true; } } } // specified domain else if ($domain && isset($this->texts[$domain.'.'.$name])) { $ref_domain = $domain; return true; } return false; } /** * Load a localization package * * @param string $lang Language ID * @param array $add Additional text labels/messages * @param array $merge Additional text labels/messages to merge */ public function load_language($lang = null, $add = [], $merge = []) { $sess_lang = !empty($_SESSION['language']) ? $_SESSION['language'] : 'en_US'; $lang = $this->language_prop($lang ?: $sess_lang); // load localized texts if (empty($this->texts) || $lang != $sess_lang) { // get english labels (these should be complete) $files = [ RCUBE_LOCALIZATION_DIR . 'en_US/labels.inc', RCUBE_LOCALIZATION_DIR . 'en_US/messages.inc', ]; // include user language files if ($lang != 'en' && $lang != 'en_US' && is_dir(RCUBE_LOCALIZATION_DIR . $lang)) { $files[] = RCUBE_LOCALIZATION_DIR . $lang . '/labels.inc'; $files[] = RCUBE_LOCALIZATION_DIR . $lang . '/messages.inc'; } $this->texts = []; foreach ($files as $file) { $this->texts = self::read_localization_file($file, $this->texts); } $_SESSION['language'] = $lang; } // append additional texts (from plugin) if (is_array($add) && !empty($add)) { $this->texts += $add; } // merge additional texts (from plugin) if (is_array($merge) && !empty($merge)) { $this->texts = array_merge($this->texts, $merge); } } /** * Read localized texts from an additional location (plugins, skins). * Then you can use the result as 2nd arg to load_language(). * * @param string $dir Directory to search in * @param string|null $lang Language code to read * * @return array Localization labels/messages */ public function read_localization($dir, $lang = null) { if ($lang == null) { $lang = $_SESSION['language']; } $langs = array_unique(['en_US', $lang]); $locdir = slashify($dir); $texts = []; // Language aliases used to find localization in similar lang, see below $aliases = [ 'de_CH' => 'de_DE', 'es_AR' => 'es_ES', 'fa_AF' => 'fa_IR', 'nl_BE' => 'nl_NL', 'pt_BR' => 'pt_PT', 'zh_CN' => 'zh_TW', ]; foreach ($langs as $lng) { $fpath = $locdir . $lng . '.inc'; $_texts = self::read_localization_file($fpath); if (!empty($_texts)) { $texts = array_merge($texts, $_texts); } // Fallback to a localization in similar language (#1488401) else if ($lng != 'en_US') { $alias = null; if (!empty($aliases[$lng])) { $alias = $aliases[$lng]; } else if ($key = array_search($lng, $aliases)) { $alias = $key; } if (!empty($alias)) { $fpath = $locdir . $alias . '.inc'; $texts = self::read_localization_file($fpath, $texts); } } } return $texts; } /** * Load localization file * * @param string $file File location * @param array $texts Additional texts to merge with * * @return array Localization labels/messages */ public static function read_localization_file($file, $texts = []) { if (is_file($file) && is_readable($file)) { $labels = []; $messages = []; // use buffering to handle empty lines/spaces after closing PHP tag ob_start(); include $file; ob_end_clean(); if (!empty($labels)) { $texts = array_merge($texts, $labels); } if (!empty($messages)) { $texts = array_merge($texts, $messages); } } return $texts; } /** * Check the given string and return a valid language code * * @param string $lang Language code * * @return string Valid language code */ protected function language_prop($lang) { static $rcube_languages, $rcube_language_aliases; // user HTTP_ACCEPT_LANGUAGE if no language is specified if ((empty($lang) || $lang == 'auto') && !empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { $accept_langs = explode(',', $_SERVER['HTTP_ACCEPT_LANGUAGE']); $lang = $accept_langs[0]; if (preg_match('/^([a-z]+)[_-]([a-z]+)$/i', $lang, $m)) { $lang = $m[1] . '_' . strtoupper($m[2]); } } if (empty($rcube_languages)) { @include(RCUBE_LOCALIZATION_DIR . 'index.inc'); } // check if we have an alias for that language if (!isset($rcube_languages[$lang]) && isset($rcube_language_aliases[$lang])) { $lang = $rcube_language_aliases[$lang]; } // try the first two chars - else if (!isset($rcube_languages[$lang])) { + else if ($lang && !isset($rcube_languages[$lang])) { $short = substr($lang, 0, 2); // check if we have an alias for the short language code if (!isset($rcube_languages[$short]) && isset($rcube_language_aliases[$short])) { $lang = $rcube_language_aliases[$short]; } // expand 'nn' to 'nn_NN' else if (!isset($rcube_languages[$short])) { $lang = $short.'_'.strtoupper($short); } } if (!isset($rcube_languages[$lang]) || !is_dir(RCUBE_LOCALIZATION_DIR . $lang)) { $lang = 'en_US'; } return $lang; } /** * Read directory program/localization and return a list of available languages * * @return array List of available localizations */ public function list_languages() { static $sa_languages = []; if (!count($sa_languages)) { @include(RCUBE_LOCALIZATION_DIR . 'index.inc'); if ($dh = @opendir(RCUBE_LOCALIZATION_DIR)) { while (($name = readdir($dh)) !== false) { if ($name[0] == '.' || !is_dir(RCUBE_LOCALIZATION_DIR . $name)) { continue; } if (isset($rcube_languages[$name])) { $sa_languages[$name] = $rcube_languages[$name]; } } closedir($dh); } } return $sa_languages; } /** * Encrypt a string * * @param string $clear Clear text input * @param string $key Encryption key to retrieve from the configuration, defaults to 'des_key' * @param bool $base64 Whether or not to base64_encode() the result before returning * * @return string|false Encrypted text, false on error */ public function encrypt($clear, $key = 'des_key', $base64 = true) { if (!is_string($clear) || !strlen($clear)) { return ''; } $ckey = $this->config->get_crypto_key($key); $method = $this->config->get_crypto_method(); $opts = defined('OPENSSL_RAW_DATA') ? OPENSSL_RAW_DATA : true; $iv = rcube_utils::random_bytes(openssl_cipher_iv_length($method), true); $cipher = openssl_encrypt($clear, $method, $ckey, $opts, $iv); if ($cipher === false) { self::raise_error([ 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to encrypt data with configured cipher method: $method!" ], true, false); return false; } $cipher = $iv . $cipher; return $base64 ? base64_encode($cipher) : $cipher; } /** * Decrypt a string * * @param string $cipher Encrypted text * @param string $key Encryption key to retrieve from the configuration, defaults to 'des_key' * @param bool $base64 Whether or not input is base64-encoded * * @return string|false Decrypted text, false on error */ public function decrypt($cipher, $key = 'des_key', $base64 = true) { if (strlen($cipher) == 0) { return false; } if ($base64) { $cipher = base64_decode($cipher); if ($cipher === false) { return false; } } $ckey = $this->config->get_crypto_key($key); $method = $this->config->get_crypto_method(); $opts = defined('OPENSSL_RAW_DATA') ? OPENSSL_RAW_DATA : true; $iv_size = openssl_cipher_iv_length($method); $iv = substr($cipher, 0, $iv_size); // session corruption? (#1485970) if (strlen($iv) < $iv_size) { return false; } $cipher = substr($cipher, $iv_size); $clear = openssl_decrypt($cipher, $method, $ckey, $opts, $iv); return $clear; } /** * Returns session token for secure URLs * * @param bool $generate Generate token if not exists in session yet * * @return string|bool Token string, False when disabled */ public function get_secure_url_token($generate = false) { if ($len = $this->config->get('use_secure_urls')) { - if (empty($_SESSION['secure_token']) && $generate) { + if (empty($_SESSION['secure_token'] ?? null) && $generate) { // generate x characters long token $length = $len > 1 ? $len : 16; $token = rcube_utils::random_bytes($length); $plugin = $this->plugins->exec_hook('secure_token', ['value' => $token, 'length' => $length]); $_SESSION['secure_token'] = $plugin['value']; } - return $_SESSION['secure_token']; + return $_SESSION['secure_token'] ?? false; } return false; } /** * Generate a unique token to be used in a form request * * @return string The request token */ public function get_request_token() { if (empty($_SESSION['request_token'])) { $plugin = $this->plugins->exec_hook('request_token', ['value' => rcube_utils::random_bytes(32)]); $_SESSION['request_token'] = $plugin['value']; } return $_SESSION['request_token']; } /** * Check if the current request contains a valid token. * Empty requests aren't checked until use_secure_urls is set. * * @param int $mode Request method * * @return bool True if request token is valid false if not */ public function check_request($mode = rcube_utils::INPUT_POST) { // check secure token in URL if enabled if ($token = $this->get_secure_url_token()) { foreach (explode('/', preg_replace('/[?#&].*$/', '', $_SERVER['REQUEST_URI'])) as $tok) { if ($tok == $token) { return true; } } $this->request_status = self::REQUEST_ERROR_URL; return false; } $sess_tok = $this->get_request_token(); // ajax requests if (rcube_utils::request_header('X-Roundcube-Request') === $sess_tok) { return true; } // skip empty requests if (($mode == rcube_utils::INPUT_POST && empty($_POST)) || ($mode == rcube_utils::INPUT_GET && empty($_GET)) ) { return true; } // default method of securing requests $token = rcube_utils::get_input_value('_token', $mode); if (empty($_COOKIE[ini_get('session.name')]) || $token !== $sess_tok) { $this->request_status = self::REQUEST_ERROR_TOKEN; return false; } return true; } /** * Build a valid URL to this instance of Roundcube * * @param mixed $p Either a string with the action or url parameters as key-value pairs * * @return string Valid application URL */ public function url($p) { // STUB: should be overloaded by the application return ''; } /** * Function to be executed in script shutdown * Registered with register_shutdown_function() */ public function shutdown() { foreach ($this->shutdown_functions as $function) { call_user_func($function); } // write session data as soon as possible and before // closing database connection, don't do this before // registered shutdown functions, they may need the session // Note: this will run registered gc handlers (ie. cache gc) if (!empty($_SERVER['REMOTE_ADDR']) && is_object($this->session)) { $this->session->write_close(); } if (is_object($this->smtp)) { $this->smtp->disconnect(); } foreach ($this->caches as $cache) { if (is_object($cache)) { $cache->close(); } } if (is_object($this->storage)) { $this->storage->close(); } if ($this->config->get('log_driver') == 'syslog') { closelog(); } } /** * Registers shutdown function to be executed on shutdown. * The functions will be executed before destroying any * objects like smtp, imap, session, etc. * * @param callback $function Function callback */ public function add_shutdown_function($function) { $this->shutdown_functions[] = $function; } /** * When you're going to sleep the script execution for a longer time * it is good to close all external connections (sql, memcache, redis, SMTP, IMAP). * * No action is required on wake up, all connections will be * re-established automatically. */ public function sleep() { foreach ($this->caches as $cache) { if (is_object($cache)) { $cache->close(); } } if ($this->storage) { $this->storage->close(); } if ($this->db) { $this->db->closeConnection(); } if ($this->memcache) { $this->memcache->close(); } if ($this->memcached) { $this->memcached->quit(); } if ($this->smtp) { $this->smtp->disconnect(); } if ($this->redis) { $this->redis->close(); } } /** * Quote a given string. * Shortcut function for rcube_utils::rep_specialchars_output() * * @param string $str A string to quote * @param string $mode Replace mode for tags: show|remove|strict * @param bool $newlines Convert newlines * * @return string HTML-quoted string */ public static function Q($str, $mode = 'strict', $newlines = true) { return rcube_utils::rep_specialchars_output($str, 'html', $mode, $newlines); } /** * Quote a given string for javascript output. * Shortcut function for rcube_utils::rep_specialchars_output() * * @param string $str A string to quote * * @return string JS-quoted string */ public static function JQ($str) { return rcube_utils::rep_specialchars_output($str, 'js'); } /** * Quote a given string, remove new-line characters, use strict mode. * Shortcut function for rcube_utils::rep_specialchars_output() * * @param string $str A string to quote * * @return string HTML-quoted string */ public static function SQ($str) { return rcube_utils::rep_specialchars_output($str, 'html', 'strict', false); } /** * Construct shell command, execute it and return output as string. * Keywords {keyword} are replaced with arguments * * @param string $cmd Format string with {keywords} to be replaced * @param mixed $values,... (zero, one or more arrays can be passed) * * @return string Output of command. Shell errors not detectable */ public static function exec(/* $cmd, $values1 = [], ... */) { $args = func_get_args(); $cmd = array_shift($args); $values = $replacements = []; // merge values into one array foreach ($args as $arg) { $values += (array) $arg; } preg_match_all('/({(-?)([a-z]\w*)})/', $cmd, $matches, PREG_SET_ORDER); foreach ($matches as $tags) { list(, $tag, $option, $key) = $tags; $parts = []; if ($option) { foreach ((array) $values["-$key"] as $key => $value) { if ($value === true || $value === false || $value === null) { $parts[] = $value ? $key : ""; } else { foreach ((array)$value as $val) { $parts[] = "$key " . escapeshellarg($val); } } } } else { foreach ((array) $values[$key] as $value) { $parts[] = escapeshellarg($value); } } $replacements[$tag] = implode(' ', $parts); } // use strtr behaviour of going through source string once $cmd = strtr($cmd, $replacements); return (string) shell_exec($cmd); } /** * Print or write debug messages * * @param mixed Debug message or data */ public static function console() { $args = func_get_args(); if (class_exists('rcube', false)) { $rcube = self::get_instance(); $plugin = $rcube->plugins->exec_hook('console', ['args' => $args]); if ($plugin['abort']) { return; } $args = $plugin['args']; } $msg = []; foreach ($args as $arg) { $msg[] = !is_string($arg) ? var_export($arg, true) : $arg; } self::write_log('console', implode(";\n", $msg)); } /** * Append a line to a logfile in the logs directory. * Date will be added automatically to the line. * * @param string $name Name of the log file * @param mixed $line Line to append * * @return bool True on success, False on failure */ public static function write_log($name, $line) { if (!is_string($line)) { $line = var_export($line, true); } $date_format = $log_driver = $session_key = null; if (self::$instance) { $date_format = self::$instance->config->get('log_date_format'); $log_driver = self::$instance->config->get('log_driver'); $session_key = intval(self::$instance->config->get('log_session_id', 8)); } $date = rcube_utils::date_format($date_format); // trigger logging hook if (is_object(self::$instance) && is_object(self::$instance->plugins)) { $log = self::$instance->plugins->exec_hook('write_log', ['name' => $name, 'date' => $date, 'line' => $line] ); $name = $log['name']; $line = $log['line']; $date = $log['date']; if (!empty($log['abort'])) { return true; } } // add session ID to the log if ($session_key > 0 && ($sess = session_id())) { $line = '<' . substr($sess, 0, $session_key) . '> ' . $line; } if ($log_driver == 'syslog') { $prio = $name == 'errors' ? LOG_ERR : LOG_INFO; return syslog($prio, $line); } // write message with file name when configured to log to STDOUT if ($log_driver == 'stdout') { $stdout = "php://stdout"; $line = "$name: $line\n"; return file_put_contents($stdout, $line, FILE_APPEND) !== false; } // log_driver == 'file' is assumed here $line = sprintf("[%s]: %s\n", $date, $line); // per-user logging is activated if (self::$instance && self::$instance->config->get('per_user_logging') && self::$instance->get_user_id() && !in_array($name, ['userlogins', 'sendmail']) ) { $log_dir = self::$instance->get_user_log_dir(); if (empty($log_dir) && $name !== 'errors') { return false; } } if (empty($log_dir)) { if (!empty($log['dir'])) { $log_dir = $log['dir']; } else if (self::$instance) { $log_dir = self::$instance->config->get('log_dir'); } } if (empty($log_dir)) { $log_dir = RCUBE_INSTALL_PATH . 'logs'; } if (self::$instance) { $name .= self::$instance->config->get('log_file_ext', '.log'); } else { $name .= '.log'; } return file_put_contents("$log_dir/$name", $line, FILE_APPEND) !== false; } /** * Throw system error (and show error page). * * @param array $arg Named parameters * - code: Error code (required) * - type: Error type [php|db|imap|javascript] * - message: Error message * - file: File where error occurred * - line: Line where error occurred * @param bool $log True to log the error * @param bool $terminate Terminate script execution */ public static function raise_error($arg = [], $log = false, $terminate = false) { // handle PHP exceptions if ($arg instanceof Exception) { $arg = [ 'code' => $arg->getCode(), 'line' => $arg->getLine(), 'file' => $arg->getFile(), 'message' => $arg->getMessage(), ]; } else if ($arg instanceof PEAR_Error) { $info = $arg->getUserInfo(); $arg = [ 'code' => $arg->getCode(), 'message' => $arg->getMessage() . ($info ? ': ' . $info : ''), ]; } else if (is_string($arg)) { $arg = ['message' => $arg]; } if (empty($arg['code'])) { $arg['code'] = 500; } $cli = php_sapi_name() == 'cli'; $arg['cli'] = $cli; $arg['log'] = $log; $arg['terminate'] = $terminate; // send error to external error tracking tool if (self::$instance) { $arg = self::$instance->plugins->exec_hook('raise_error', $arg); } // installer if (!$cli && class_exists('rcmail_install', false)) { $rci = rcmail_install::get_instance(); $rci->raise_error($arg); return; } if (!isset($arg['message'])) { $arg['message'] = ''; } if (($log || $terminate) && !$cli && $arg['message']) { $arg['fatal'] = $terminate; self::log_bug($arg); } if ($cli) { fwrite(STDERR, 'ERROR: ' . trim($arg['message']) . "\n"); } else if ($terminate && is_object(self::$instance->output)) { self::$instance->output->raise_error($arg['code'], $arg['message']); } else if ($terminate) { header("HTTP/1.0 500 Internal Error"); } // terminate script if ($terminate) { if (defined('ROUNDCUBE_TEST_MODE') && ROUNDCUBE_TEST_MODE) { throw new Exception('Error raised'); } exit(1); } } /** * Log an error * * @param array $arg_arr Named parameters * @see self::raise_error() */ public static function log_bug($arg_arr) { $program = !empty($arg_arr['type']) ? strtoupper($arg_arr['type']) : 'PHP'; $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''; // write error to local log file if ($_SERVER['REQUEST_METHOD'] == 'POST') { $post_query = []; foreach (['_task', '_action'] as $arg) { if (isset($_POST[$arg]) && !isset($_GET[$arg])) { $post_query[$arg] = $_POST[$arg]; } } if (!empty($post_query)) { $uri .= (strpos($uri, '?') != false ? '&' : '?') . http_build_query($post_query, '', '&'); } } $log_entry = sprintf("%s Error: %s%s (%s %s)", $program, $arg_arr['message'], !empty($arg_arr['file']) ? sprintf(' in %s on line %d', $arg_arr['file'], $arg_arr['line']) : '', $_SERVER['REQUEST_METHOD'], $uri ); if (!self::write_log('errors', $log_entry)) { // send error to PHPs error handler if write_log didn't succeed trigger_error($arg_arr['message'], E_USER_WARNING); } } /** * Write debug info to the log * * @param string $engine Engine type - file name (memcache, apc, redis) * @param string $data Data string to log * @param bool $result Operation result */ public static function debug($engine, $data, $result = null) { static $debug_counter; $line = '[' . (++$debug_counter[$engine]) . '] ' . $data; if (($len = strlen($line)) > self::DEBUG_LINE_LENGTH) { $diff = $len - self::DEBUG_LINE_LENGTH; $line = substr($line, 0, self::DEBUG_LINE_LENGTH) . "... [truncated $diff bytes]"; } if ($result !== null) { $line .= ' [' . ($result ? 'TRUE' : 'FALSE') . ']'; } self::write_log($engine, $line); } /** * Returns current time (with microseconds). * * @return float Current time in seconds since the Unix */ public static function timer() { return microtime(true); } /** * Logs time difference according to provided timer * * @param float $timer Timer (self::timer() result) * @param string $label Log line prefix * @param string $dest Log file name * * @see self::timer() */ public static function print_timer($timer, $label = 'Timer', $dest = 'console') { static $print_count = 0; $print_count++; $now = self::timer(); $diff = $now - $timer; if (empty($label)) { $label = 'Timer '.$print_count; } self::write_log($dest, sprintf("%s: %0.4f sec", $label, $diff)); } /** * Setter for system user object * * @param rcube_user Current user instance */ public function set_user($user) { if (is_object($user)) { $this->user = $user; // overwrite config with user preferences $this->config->set_user_prefs((array)$this->user->get_prefs()); } } /** * Getter for logged user ID. * * @return mixed User identifier */ public function get_user_id() { if (is_object($this->user)) { return $this->user->ID; } else if (isset($_SESSION['user_id'])) { return $_SESSION['user_id']; } } /** * Getter for logged user name. * * @return string User name */ public function get_user_name() { if (is_object($this->user)) { return $this->user->get_username(); } else if (isset($_SESSION['username'])) { return $_SESSION['username']; } } /** * Getter for logged user email (derived from user name not identity). * * @return string User email address */ public function get_user_email() { if (!empty($this->user_email)) { return $this->user_email; } if (is_object($this->user)) { return $this->user->get_username('mail'); } } /** * Getter for logged user password. * * @return string User password */ public function get_user_password() { if (!empty($this->password)) { return $this->password; } if (isset($_SESSION['password'])) { return $this->decrypt($_SESSION['password']); } } /** * Get the per-user log directory * * @return string|false Per-user log directory if it exists and is writable, False otherwise */ protected function get_user_log_dir() { $log_dir = $this->config->get('log_dir', RCUBE_INSTALL_PATH . 'logs'); $user_name = $this->get_user_name(); $user_log_dir = $log_dir . '/' . $user_name; return !empty($user_name) && is_writable($user_log_dir) ? $user_log_dir : false; } /** * Getter for logged user language code. * * @return string User language code */ public function get_user_language() { if (is_object($this->user)) { return $this->user->language; } else if (isset($_SESSION['language'])) { return $_SESSION['language']; } } /** * Unique Message-ID generator. * * @param string $sender Optional sender e-mail address * * @return string Message-ID */ public function gen_message_id($sender = null) { $local_part = md5(uniqid('rcube'.mt_rand(), true)); $domain_part = ''; if ($sender && preg_match('/@([^\s]+\.[a-z0-9-]+)/', $sender, $m)) { $domain_part = $m[1]; } else { $domain_part = $this->user->get_username('domain'); } // Try to find FQDN, some spamfilters doesn't like 'localhost' (#1486924) if (!preg_match('/\.[a-z0-9-]+$/i', $domain_part)) { foreach ([$_SERVER['HTTP_HOST'], $_SERVER['SERVER_NAME']] as $host) { $host = preg_replace('/:[0-9]+$/', '', $host); if ($host && preg_match('/\.[a-z]+$/i', $host)) { $domain_part = $host; break; } } } return sprintf('<%s@%s>', $local_part, $domain_part); } /** * Send the given message using the configured method. * * @param Mail_Mime &$message Reference to Mail_MIME object * @param string $from Sender address string * @param array|string $mailto Either a comma-separated list of recipients (RFC822 compliant), * or an array of recipients, each RFC822 valid * @param array|string &$error SMTP error array or (deprecated) string * @param string &$body_file Location of file with saved message body, * used when delay_file_io is enabled * @param array $options SMTP options (e.g. DSN request) * @param bool $disconnect Close SMTP connection ASAP * * @return bool Send status. */ public function deliver_message(&$message, $from, $mailto, &$error, &$body_file = null, $options = null, $disconnect = false) { $plugin = $this->plugins->exec_hook('message_before_send', [ 'message' => $message, 'from' => $from, 'mailto' => $mailto, 'options' => $options, ]); if ($plugin['abort']) { if (!empty($plugin['error'])) { $error = $plugin['error']; } if (!empty($plugin['body_file'])) { $body_file = $plugin['body_file']; } return isset($plugin['result']) ? $plugin['result'] : false; } $from = $plugin['from']; $mailto = $plugin['mailto']; $options = $plugin['options']; $message = $plugin['message']; $headers = $message->headers(); // generate list of recipients $a_recipients = (array) $mailto; if (!empty($headers['Cc'])) { $a_recipients[] = $headers['Cc']; } if (!empty($headers['Bcc'])) { $a_recipients[] = $headers['Bcc']; } // remove Bcc header and get the whole head of the message as string $smtp_headers = $message->txtHeaders(['Bcc' => null], true); if ($message->getParam('delay_file_io')) { // use common temp dir $body_file = rcube_utils::temp_filename('msg'); $mime_result = $message->saveMessageBody($body_file); if (is_a($mime_result, 'PEAR_Error')) { self::raise_error([ 'code' => 650, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not create message: ".$mime_result->getMessage() ], true, false ); return false; } $msg_body = fopen($body_file, 'r'); } else { $msg_body = $message->get(); } // initialize SMTP connection if (!is_object($this->smtp)) { $this->smtp_init(true); } // send message $sent = $this->smtp->send_mail($from, $a_recipients, $smtp_headers, $msg_body, $options); $response = $this->smtp->get_response(); $error = $this->smtp->get_error(); if (!$sent) { self::raise_error([ 'code' => 800, 'type' => 'smtp', 'line' => __LINE__, 'file' => __FILE__, 'message' => implode("\n", $response) ], true, false); // allow plugins to catch sending errors with the same parameters as in 'message_before_send' $plugin = $this->plugins->exec_hook('message_send_error', $plugin + ['error' => $error]); $error = $plugin['error']; } else { $this->plugins->exec_hook('message_sent', ['headers' => $headers, 'body' => $msg_body, 'message' => $message]); // remove MDN/DSN headers after sending unset($headers['Return-Receipt-To'], $headers['Disposition-Notification-To']); if ($this->config->get('smtp_log')) { // get all recipient addresses $mailto = implode(',', $a_recipients); $mailto = rcube_mime::decode_address_list($mailto, null, false, null, true); self::write_log('sendmail', sprintf("User %s [%s]; Message %s for %s; %s", $this->user->get_username(), rcube_utils::remote_addr(), $headers['Message-ID'], implode(', ', $mailto), !empty($response) ? implode('; ', $response) : '') ); } } if (is_resource($msg_body)) { fclose($msg_body); } if ($disconnect) { $this->smtp->disconnect(); } // Add Bcc header back if (!empty($headers['Bcc'])) { $message->headers(['Bcc' => $headers['Bcc']], true); } return $sent; } } /** * Lightweight plugin API class serving as a dummy if plugins are not enabled * * @package Framework * @subpackage Core */ class rcube_dummy_plugin_api { /** * Triggers a plugin hook. * * @param string $hook Hook name * @param array $args Hook arguments * * @return array Hook arguments * @see rcube_plugin_api::exec_hook() */ public function exec_hook($hook, $args = []) { return $args; } } diff --git a/program/lib/Roundcube/rcube_imap_cache.php b/program/lib/Roundcube/rcube_imap_cache.php index e2320deea..90da252eb 100644 --- a/program/lib/Roundcube/rcube_imap_cache.php +++ b/program/lib/Roundcube/rcube_imap_cache.php @@ -1,1263 +1,1263 @@ | | Author: Aleksander Machniak | +-----------------------------------------------------------------------+ */ /** * Interface class for accessing Roundcube messages cache * * @package Framework * @subpackage Storage */ class rcube_imap_cache { const MODE_INDEX = 1; const MODE_MESSAGE = 2; /** * Instance of rcube_imap * * @var rcube_imap */ private $imap; /** * Instance of rcube_db * * @var rcube_db */ private $db; /** * User ID * * @var int */ private $userid; /** * Expiration time in seconds * * @var int */ private $ttl; /** * Maximum cached message size * * @var int */ private $threshold; /** * Internal (in-memory) cache * * @var array */ private $icache = []; private $skip_deleted = false; private $mode; private $index_table; private $thread_table; private $messages_table; /** * List of known flags. Thanks to this we can handle flag changes * with good performance. Bad thing is we need to know used flags. */ public $flags = [ 1 => 'SEEN', // RFC3501 2 => 'DELETED', // RFC3501 4 => 'ANSWERED', // RFC3501 8 => 'FLAGGED', // RFC3501 16 => 'DRAFT', // RFC3501 32 => 'MDNSENT', // RFC3503 64 => 'FORWARDED', // RFC5550 128 => 'SUBMITPENDING', // RFC5550 256 => 'SUBMITTED', // RFC5550 512 => 'JUNK', 1024 => 'NONJUNK', 2048 => 'LABEL1', 4096 => 'LABEL2', 8192 => 'LABEL3', 16384 => 'LABEL4', 32768 => 'LABEL5', 65536 => 'HASATTACHMENT', 131072 => 'HASNOATTACHMENT', ]; /** * Object constructor. * * @param rcube_db $db DB handler * @param rcube_imap $imap IMAP handler * @param int $userid User identifier * @param bool $skip_deleted skip_deleted flag * @param string $ttl Expiration time of memcache/apc items * @param int $threshold Maximum cached message size */ function __construct($db, $imap, $userid, $skip_deleted, $ttl = 0, $threshold = 0) { // convert ttl string to seconds $ttl = get_offset_sec($ttl); if ($ttl > 2592000) $ttl = 2592000; $this->db = $db; $this->imap = $imap; $this->userid = $userid; $this->skip_deleted = $skip_deleted; $this->ttl = $ttl; $this->threshold = $threshold; // cache all possible information by default $this->mode = self::MODE_INDEX | self::MODE_MESSAGE; // database tables $this->index_table = $db->table_name('cache_index', true); $this->thread_table = $db->table_name('cache_thread', true); $this->messages_table = $db->table_name('cache_messages', true); } /** * Cleanup actions (on shutdown). */ public function close() { $this->save_icache(); $this->icache = null; } /** * Set cache mode * * @param int $mode Cache mode */ public function set_mode($mode) { $this->mode = $mode; } /** * Return (sorted) messages index (UIDs). * If index doesn't exist or is invalid, will be updated. * * @param string $mailbox Folder name * @param string $sort_field Sorting column * @param string $sort_order Sorting order (ASC|DESC) * @param bool $exiting Skip index initialization if it doesn't exist in DB * * @return array Messages index */ function get_index($mailbox, $sort_field = null, $sort_order = null, $existing = false) { if (empty($this->icache[$mailbox])) { $this->icache[$mailbox] = []; } $sort_order = strtoupper($sort_order) == 'ASC' ? 'ASC' : 'DESC'; // Seek in internal cache if (array_key_exists('index', $this->icache[$mailbox])) { // The index was fetched from database already, but not validated yet if (empty($this->icache[$mailbox]['index']['validated'])) { $index = $this->icache[$mailbox]['index']; } // We've got a valid index else if ($sort_field == 'ANY' || $this->icache[$mailbox]['index']['sort_field'] == $sort_field) { $result = $this->icache[$mailbox]['index']['object']; if ($result->get_parameters('ORDER') != $sort_order) { $result->revert(); } return $result; } } // Get index from DB (if DB wasn't already queried) if (empty($index) && empty($this->icache[$mailbox]['index_queried'])) { $index = $this->get_index_row($mailbox); // set the flag that DB was already queried for index // this way we'll be able to skip one SELECT, when // get_index() is called more than once $this->icache[$mailbox]['index_queried'] = true; } $data = null; // @TODO: Think about skipping validation checks. // If we could check only every 10 minutes, we would be able to skip // expensive checks, mailbox selection or even IMAP connection, this would require // additional logic to force cache invalidation in some cases // and many rcube_imap changes to connect when needed // Entry exists, check cache status if (!empty($index)) { $exists = true; $modseq = isset($index['modseq']) ? $index['modseq'] : null; if ($sort_field == 'ANY') { - $sort_field = $index['sort_field']; + $sort_field = $index['sort_field'] ?? null; } - if ($sort_field != $index['sort_field']) { + if ($sort_field != ($index['sort_field'] ?? null)) { $is_valid = false; } else { $is_valid = $this->validate($mailbox, $index, $exists); } if ($is_valid) { $data = $index['object']; // revert the order if needed if ($data->get_parameters('ORDER') != $sort_order) { $data->revert(); } } } else { if ($existing) { return null; } if ($sort_field == 'ANY') { $sort_field = ''; } // Got it in internal cache, so the row already exist $exists = array_key_exists('index', $this->icache[$mailbox]); $modseq = null; } // Index not found, not valid or sort field changed, get index from IMAP server if ($data === null) { // Get mailbox data (UIDVALIDITY, counters, etc.) for status check $mbox_data = $this->imap->folder_data($mailbox); $data = $this->get_index_data($mailbox, $sort_field, $sort_order, $mbox_data); if (isset($mbox_data['HIGHESTMODSEQ'])) { $modseq = $mbox_data['HIGHESTMODSEQ']; } // insert/update $this->add_index_row($mailbox, $sort_field, $data, $mbox_data, $exists, $modseq); } $this->icache[$mailbox]['index'] = [ 'validated' => true, 'object' => $data, 'sort_field' => $sort_field, 'modseq' => $modseq ]; return $data; } /** * Return messages thread. * If threaded index doesn't exist or is invalid, will be updated. * * @param string $mailbox Folder name * * @return array Messages threaded index */ function get_thread($mailbox) { if (empty($this->icache[$mailbox])) { $this->icache[$mailbox] = []; } // Seek in internal cache if (array_key_exists('thread', $this->icache[$mailbox])) { return $this->icache[$mailbox]['thread']['object']; } $index = null; // Get thread from DB (if DB wasn't already queried) if (empty($this->icache[$mailbox]['thread_queried'])) { $index = $this->get_thread_row($mailbox); // set the flag that DB was already queried for thread // this way we'll be able to skip one SELECT, when // get_thread() is called more than once or after clear() $this->icache[$mailbox]['thread_queried'] = true; } // Entry exist, check cache status if (!empty($index)) { $exists = true; $is_valid = $this->validate($mailbox, $index, $exists); if (!$is_valid) { $index = null; } } // Index not found or not valid, get index from IMAP server if ($index === null) { // Get mailbox data (UIDVALIDITY, counters, etc.) for status check $mbox_data = $this->imap->folder_data($mailbox); // Get THREADS result $index['object'] = $this->get_thread_data($mailbox, $mbox_data); // insert/update $this->add_thread_row($mailbox, $index['object'], $mbox_data, !empty($exists)); } $this->icache[$mailbox]['thread'] = $index; return $index['object']; } /** * Returns list of messages (headers). See rcube_imap::fetch_headers(). * * @param string $mailbox Folder name * @param array $msgs Message UIDs * * @return array The list of messages (rcube_message_header) indexed by UID */ function get_messages($mailbox, $msgs = []) { $result = []; if (empty($msgs)) { return $result; } if ($this->mode & self::MODE_MESSAGE) { // Fetch messages from cache $sql_result = $this->db->query( "SELECT `uid`, `data`, `flags`" ." FROM {$this->messages_table}" ." WHERE `user_id` = ?" ." AND `mailbox` = ?" ." AND `uid` IN (".$this->db->array2list($msgs, 'integer').")", $this->userid, $mailbox); $msgs = array_flip($msgs); while ($sql_arr = $this->db->fetch_assoc($sql_result)) { $uid = intval($sql_arr['uid']); $result[$uid] = $this->build_message($sql_arr); if (!empty($result[$uid])) { // save memory, we don't need message body here (?) $result[$uid]->body = null; unset($msgs[$uid]); } } $this->db->reset(); $msgs = array_flip($msgs); } // Fetch not found messages from IMAP server if (!empty($msgs)) { $messages = $this->imap->fetch_headers($mailbox, $msgs, false, true); // Insert to DB and add to result list if (!empty($messages)) { foreach ($messages as $msg) { if ($this->mode & self::MODE_MESSAGE) { $this->add_message($mailbox, $msg, !array_key_exists($msg->uid, $result)); } $result[$msg->uid] = $msg; } } } return $result; } /** * Returns message data. * * @param string $mailbox Folder name * @param int $uid Message UID * @param bool $update If message doesn't exists in cache it will be fetched * from IMAP server * @param bool $no_cache Enables internal cache usage * * @return rcube_message_header Message data */ function get_message($mailbox, $uid, $update = true, $cache = true) { // Check internal cache if (!empty($this->icache['__message']) && $this->icache['__message']['mailbox'] == $mailbox && $this->icache['__message']['object']->uid == $uid ) { return $this->icache['__message']['object']; } $message = null; $found = false; if ($this->mode & self::MODE_MESSAGE) { $sql_result = $this->db->query( "SELECT `flags`, `data`" ." FROM {$this->messages_table}" ." WHERE `user_id` = ?" ." AND `mailbox` = ?" ." AND `uid` = ?", $this->userid, $mailbox, (int)$uid); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $message = $this->build_message($sql_arr); $found = true; } } // Get the message from IMAP server if (empty($message) && $update) { $message = $this->imap->get_message_headers($uid, $mailbox, true); // cache will be updated in close(), see below } if (!($this->mode & self::MODE_MESSAGE)) { return $message; } // Save the message in internal cache, will be written to DB in close() // Common scenario: user opens unseen message // - get message (SELECT) // - set message headers/structure (INSERT or UPDATE) // - set \Seen flag (UPDATE) // This way we can skip one UPDATE if (!empty($message) && $cache) { // Save current message from internal cache $this->save_icache(); $this->icache['__message'] = [ 'object' => $message, 'mailbox' => $mailbox, 'exists' => $found, 'md5sum' => md5(serialize($message)), ]; } return $message; } /** * Saves the message in cache. * * @param string $mailbox Folder name * @param rcube_message_header $message Message data * @param bool $force Skips message in-cache existence check */ function add_message($mailbox, $message, $force = false) { if (!is_object($message) || empty($message->uid)) { return; } if (!($this->mode & self::MODE_MESSAGE)) { return; } $flags = 0; $msg = clone $message; if (!empty($message->flags)) { foreach ($this->flags as $idx => $flag) { if (!empty($message->flags[$flag])) { $flags += $idx; } } } unset($msg->flags); $msg = $this->db->encode($msg, true); $expires = $this->db->param($this->ttl ? $this->db->now($this->ttl) : 'NULL', rcube_db::TYPE_SQL); $this->db->insert_or_update( $this->messages_table, ['user_id' => $this->userid, 'mailbox' => $mailbox, 'uid' => (int) $message->uid], ['flags', 'expires', 'data'], [$flags, $expires, $msg] ); } /** * Sets the flag for specified message. * * @param string $mailbox Folder name * @param array $uids Message UIDs or null to change flag * of all messages in a folder * @param string $flag The name of the flag * @param bool $enabled Flag state */ function change_flag($mailbox, $uids, $flag, $enabled = false) { if (empty($uids)) { return; } if (!($this->mode & self::MODE_MESSAGE)) { return; } $flag = strtoupper($flag); $idx = (int) array_search($flag, $this->flags); $uids = (array) $uids; if (!$idx) { return; } // Internal cache update if ( !empty($this->icache['__message']) && ($message = $this->icache['__message']) && $message['mailbox'] === $mailbox && in_array($message['object']->uid, $uids) ) { $message['object']->flags[$flag] = $enabled; if (count($uids) == 1) { return; } } $binary_check = $this->db->db_provider == 'oracle' ? "BITAND(`flags`, %d)" : "(`flags` & %d)"; $this->db->query( "UPDATE {$this->messages_table}" ." SET `expires` = ". ($this->ttl ? $this->db->now($this->ttl) : 'NULL') .", `flags` = `flags` ".($enabled ? "+ $idx" : "- $idx") ." WHERE `user_id` = ?" ." AND `mailbox` = ?" .(!empty($uids) ? " AND `uid` IN (".$this->db->array2list($uids, 'integer').")" : "") ." AND " . sprintf($binary_check, $idx) . ($enabled ? " = 0" : " = $idx"), $this->userid, $mailbox ); } /** * Removes message(s) from cache. * * @param string $mailbox Folder name * @param array $uids Message UIDs, NULL removes all messages */ function remove_message($mailbox = null, $uids = null) { if (!($this->mode & self::MODE_MESSAGE)) { return; } if (!strlen($mailbox)) { $this->db->query( "DELETE FROM {$this->messages_table}" ." WHERE `user_id` = ?", $this->userid); } else { // Remove the message from internal cache if ( !empty($uids) && !empty($this->icache['__message']) && ($message = $this->icache['__message']) && $message['mailbox'] === $mailbox && in_array($message['object']->uid, (array) $uids) ) { $this->icache['__message'] = null; } $this->db->query( "DELETE FROM {$this->messages_table}" ." WHERE `user_id` = ?" ." AND `mailbox` = ?" .($uids !== null ? " AND `uid` IN (".$this->db->array2list((array)$uids, 'integer').")" : ""), $this->userid, $mailbox ); } } /** * Clears index cache. * * @param string $mailbox Folder name * @param bool $remove Enable to remove the DB row */ function remove_index($mailbox = null, $remove = false) { if (!($this->mode & self::MODE_INDEX)) { return; } // The index should be only removed from database when // UIDVALIDITY was detected or the mailbox is empty // otherwise use 'valid' flag to not loose HIGHESTMODSEQ value if ($remove) { $this->db->query( "DELETE FROM {$this->index_table}" ." WHERE `user_id` = ?" .(strlen($mailbox) ? " AND `mailbox` = ".$this->db->quote($mailbox) : ""), $this->userid ); } else { $this->db->query( "UPDATE {$this->index_table}" ." SET `valid` = 0" ." WHERE `user_id` = ?" .(strlen($mailbox) ? " AND `mailbox` = ".$this->db->quote($mailbox) : ""), $this->userid ); } if (strlen($mailbox)) { unset($this->icache[$mailbox]['index']); // Index removed, set flag to skip SELECT query in get_index() $this->icache[$mailbox]['index_queried'] = true; } else { $this->icache = []; } } /** * Clears thread cache. * * @param string $mailbox Folder name */ function remove_thread($mailbox = null) { if (!($this->mode & self::MODE_INDEX)) { return; } $this->db->query( "DELETE FROM {$this->thread_table}" ." WHERE `user_id` = ?" .(strlen($mailbox) ? " AND `mailbox` = ".$this->db->quote($mailbox) : ""), $this->userid ); if (strlen($mailbox)) { unset($this->icache[$mailbox]['thread']); // Thread data removed, set flag to skip SELECT query in get_thread() $this->icache[$mailbox]['thread_queried'] = true; } else { $this->icache = []; } } /** * Clears the cache. * * @param string $mailbox Folder name * @param array $uids Message UIDs, NULL removes all messages in a folder */ function clear($mailbox = null, $uids = null) { $this->remove_index($mailbox, true); $this->remove_thread($mailbox); $this->remove_message($mailbox, $uids); } /** * Delete expired cache entries */ static function gc() { $rcube = rcube::get_instance(); $db = $rcube->get_dbh(); $now = $db->now(); $db->query("DELETE FROM " . $db->table_name('cache_messages', true) ." WHERE `expires` < $now"); $db->query("DELETE FROM " . $db->table_name('cache_index', true) ." WHERE `expires` < $now"); $db->query("DELETE FROM ".$db->table_name('cache_thread', true) ." WHERE `expires` < $now"); } /** * Fetches index data from database */ private function get_index_row($mailbox) { if (!($this->mode & self::MODE_INDEX)) { return; } // Get index from DB $sql_result = $this->db->query( "SELECT `data`, `valid`" ." FROM {$this->index_table}" ." WHERE `user_id` = ?" ." AND `mailbox` = ?", $this->userid, $mailbox ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $data = explode('@', $sql_arr['data']); $index = $this->db->decode($data[0], true); unset($data[0]); if (empty($index)) { $index = new rcube_result_index($mailbox); } return [ 'valid' => $sql_arr['valid'], 'object' => $index, 'sort_field' => $data[1], 'deleted' => $data[2], 'validity' => $data[3], 'uidnext' => $data[4], 'modseq' => $data[5], ]; } } /** * Fetches thread data from database */ private function get_thread_row($mailbox) { if (!($this->mode & self::MODE_INDEX)) { return; } // Get thread from DB $sql_result = $this->db->query( "SELECT `data`" ." FROM {$this->thread_table}" ." WHERE `user_id` = ?" ." AND `mailbox` = ?", $this->userid, $mailbox); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $data = explode('@', $sql_arr['data']); $thread = $this->db->decode($data[0], true); unset($data[0]); if (empty($thread)) { $thread = new rcube_result_thread($mailbox); } return [ 'object' => $thread, 'deleted' => $data[1], 'validity' => $data[2], 'uidnext' => $data[3], ]; } } /** * Saves index data into database */ private function add_index_row($mailbox, $sort_field, $data, $mbox_data = [], $exists = false, $modseq = null) { if (!($this->mode & self::MODE_INDEX)) { return; } $data = [ $this->db->encode($data, true), $sort_field, (int) $this->skip_deleted, (int) $mbox_data['UIDVALIDITY'], (int) $mbox_data['UIDNEXT'], $modseq ?: (isset($mbox_data['HIGHESTMODSEQ']) ? $mbox_data['HIGHESTMODSEQ'] : ''), ]; $data = implode('@', $data); $expires = $this->db->param($this->ttl ? $this->db->now($this->ttl) : 'NULL', rcube_db::TYPE_SQL); $this->db->insert_or_update( $this->index_table, ['user_id' => $this->userid, 'mailbox' => $mailbox], ['valid', 'expires', 'data'], [1, $expires, $data] ); } /** * Saves thread data into database */ private function add_thread_row($mailbox, $data, $mbox_data = [], $exists = false) { if (!($this->mode & self::MODE_INDEX)) { return; } $data = [ $this->db->encode($data, true), (int) $this->skip_deleted, (int) $mbox_data['UIDVALIDITY'], (int) $mbox_data['UIDNEXT'], ]; $data = implode('@', $data); $expires = $this->db->param($this->ttl ? $this->db->now($this->ttl) : 'NULL', rcube_db::TYPE_SQL); $this->db->insert_or_update( $this->thread_table, ['user_id' => $this->userid, 'mailbox' => $mailbox], ['expires', 'data'], [$expires, $data] ); } /** * Checks index/thread validity */ private function validate($mailbox, $index, &$exists = true) { $object = $index['object']; $is_thread = is_a($object, 'rcube_result_thread'); // sanity check if (empty($object)) { return false; } $index['validated'] = true; // Get mailbox data (UIDVALIDITY, counters, etc.) for status check $mbox_data = $this->imap->folder_data($mailbox); // @TODO: Think about skipping validation checks. // If we could check only every 10 minutes, we would be able to skip // expensive checks, mailbox selection or even IMAP connection, this would require // additional logic to force cache invalidation in some cases // and many rcube_imap changes to connect when needed // Check UIDVALIDITY - if ($index['validity'] != $mbox_data['UIDVALIDITY']) { + if (($index['validity'] ?? null) != ($mbox_data['UIDVALIDITY'] ?? null)) { $this->clear($mailbox); $exists = false; return false; } // Folder is empty but cache isn't if (empty($mbox_data['EXISTS'])) { if (!$object->is_empty()) { $this->clear($mailbox); $exists = false; return false; } } // Folder is not empty but cache is else if ($object->is_empty()) { unset($this->icache[$mailbox][$is_thread ? 'thread' : 'index']); return false; } // Validation flag if (!$is_thread && empty($index['valid'])) { unset($this->icache[$mailbox]['index']); return false; } // Index was created with different skip_deleted setting if ($this->skip_deleted != $index['deleted']) { return false; } // Check HIGHESTMODSEQ if (!empty($index['modseq']) && !empty($mbox_data['HIGHESTMODSEQ']) && $index['modseq'] == $mbox_data['HIGHESTMODSEQ'] ) { return true; } // Check UIDNEXT if ($index['uidnext'] != $mbox_data['UIDNEXT']) { unset($this->icache[$mailbox][$is_thread ? 'thread' : 'index']); return false; } // @TODO: find better validity check for threaded index if ($is_thread) { // check messages number... if (!$this->skip_deleted && $mbox_data['EXISTS'] != $object->count_messages()) { return false; } return true; } // The rest of checks, more expensive if (!empty($this->skip_deleted)) { // compare counts if available if (!empty($mbox_data['UNDELETED']) && $mbox_data['UNDELETED']->count() != $object->count() ) { return false; } // compare UID sets if (!empty($mbox_data['UNDELETED'])) { $uids_new = $mbox_data['UNDELETED']->get(); $uids_old = $object->get(); if (count($uids_new) != count($uids_old)) { return false; } sort($uids_new, SORT_NUMERIC); sort($uids_old, SORT_NUMERIC); if ($uids_old != $uids_new) { return false; } } else if ($object->is_empty()) { // We have to run ALL UNDELETED search anyway for this case, so we can // return early to skip the following search command. return false; } else { // get all undeleted messages excluding cached UIDs $existing = rcube_imap_generic::compressMessageSet($object->get()); $ids = $this->imap->search_once($mailbox, "ALL UNDELETED NOT UID $existing"); if (!$ids->is_empty()) { return false; } } } else { // check messages number... if ($mbox_data['EXISTS'] != $object->count()) { return false; } // ... and max UID if ($object->max() != $this->imap->id2uid($mbox_data['EXISTS'], $mailbox)) { return false; } } return true; } /** * Synchronizes the mailbox. * * @param string $mailbox Folder name */ function synchronize($mailbox) { // RFC4549: Synchronization Operations for Disconnected IMAP4 Clients // RFC4551: IMAP Extension for Conditional STORE Operation // or Quick Flag Changes Resynchronization // RFC5162: IMAP Extensions for Quick Mailbox Resynchronization // @TODO: synchronize with other methods? $qresync = $this->imap->get_capability('QRESYNC'); $condstore = $qresync ? true : $this->imap->get_capability('CONDSTORE'); if (!$qresync && !$condstore) { return; } // Get stored index $index = $this->get_index_row($mailbox); // database is empty if (empty($index)) { // set the flag that DB was already queried for index // this way we'll be able to skip one SELECT in get_index() $this->icache[$mailbox]['index_queried'] = true; return; } $this->icache[$mailbox]['index'] = $index; // no last HIGHESTMODSEQ value if (empty($index['modseq'])) { return; } if (!$this->imap->check_connection()) { return; } // Enable QRESYNC $res = $this->imap->conn->enable($qresync ? 'QRESYNC' : 'CONDSTORE'); if ($res === false) { return; } // Close mailbox if already selected to get most recent data if ($this->imap->conn->selected == $mailbox) { $this->imap->conn->close(); } // Get mailbox data (UIDVALIDITY, HIGHESTMODSEQ, counters, etc.) $mbox_data = $this->imap->folder_data($mailbox); if (empty($mbox_data)) { return; } // Check UIDVALIDITY if ($index['validity'] != $mbox_data['UIDVALIDITY']) { $this->clear($mailbox); return; } // QRESYNC not supported on specified mailbox if (!empty($mbox_data['NOMODSEQ']) || empty($mbox_data['HIGHESTMODSEQ'])) { return; } // Nothing new if ($mbox_data['HIGHESTMODSEQ'] == $index['modseq']) { return; } $uids = []; $removed = []; // Get known UIDs if ($this->mode & self::MODE_MESSAGE) { $sql_result = $this->db->query( "SELECT `uid`" ." FROM {$this->messages_table}" ." WHERE `user_id` = ?" ." AND `mailbox` = ?", $this->userid, $mailbox ); while ($sql_arr = $this->db->fetch_assoc($sql_result)) { $uids[] = $sql_arr['uid']; } } // Synchronize messages data if (!empty($uids)) { // Get modified flags and vanished messages // UID FETCH 1:* (FLAGS) (CHANGEDSINCE 0123456789 VANISHED) $result = $this->imap->conn->fetch($mailbox, $uids, true, ['FLAGS'], $index['modseq'], $qresync); if (!empty($result)) { foreach ($result as $msg) { $uid = $msg->uid; // Remove deleted message if ($this->skip_deleted && !empty($msg->flags['DELETED'])) { $removed[] = $uid; // Invalidate index $index['valid'] = false; continue; } $flags = 0; if (!empty($msg->flags)) { foreach ($this->flags as $idx => $flag) { if (!empty($msg->flags[$flag])) { $flags += $idx; } } } $this->db->query( "UPDATE {$this->messages_table}" ." SET `flags` = ?, `expires` = " . ($this->ttl ? $this->db->now($this->ttl) : 'NULL') ." WHERE `user_id` = ?" ." AND `mailbox` = ?" ." AND `uid` = ?" ." AND `flags` <> ?", $flags, $this->userid, $mailbox, $uid, $flags ); } } // VANISHED found? if ($qresync) { $mbox_data = $this->imap->folder_data($mailbox); // Removed messages found $uids = isset($mbox_data['VANISHED']) ? rcube_imap_generic::uncompressMessageSet($mbox_data['VANISHED']) : null; if (!empty($uids)) { $removed = array_merge($removed, $uids); // Invalidate index $index['valid'] = false; } } // remove messages from database if (!empty($removed)) { $this->remove_message($mailbox, $removed); } } $sort_field = $index['sort_field']; $sort_order = $index['object']->get_parameters('ORDER'); $exists = true; // Validate index if (!$this->validate($mailbox, $index, $exists)) { // Invalidate (remove) thread index // if $exists=false it was already removed in validate() if ($exists) { $this->remove_thread($mailbox); } // Update index $data = $this->get_index_data($mailbox, $sort_field, $sort_order, $mbox_data); } else { $data = $index['object']; } // update index and/or HIGHESTMODSEQ value $this->add_index_row($mailbox, $sort_field, $data, $mbox_data, $exists); // update internal cache for get_index() $this->icache[$mailbox]['index']['object'] = $data; } /** * Converts cache row into message object. * * @param array $sql_arr Message row data * * @return rcube_message_header Message object */ private function build_message($sql_arr) { $message = $this->db->decode($sql_arr['data'], true); if ($message) { $message->flags = []; foreach ($this->flags as $idx => $flag) { if (($sql_arr['flags'] & $idx) == $idx) { $message->flags[$flag] = true; } } } return $message; } /** * Saves message stored in internal cache */ private function save_icache() { // Save current message from internal cache if (!empty($this->icache['__message'])) { $message = $this->icache['__message']; // clean up some object's data $this->message_object_prepare($message['object']); // calculate current md5 sum $md5sum = md5(serialize($message['object'])); if ($message['md5sum'] != $md5sum) { $this->add_message($message['mailbox'], $message['object'], !$message['exists']); } $this->icache['__message']['md5sum'] = $md5sum; } } /** * Prepares message object to be stored in database. * * @param rcube_message_header|rcube_message_part */ private function message_object_prepare(&$msg, &$size = 0) { // Remove body too big if (isset($msg->body)) { $length = strlen($msg->body); if (!empty($msg->body_modified) || $size + $length > $this->threshold * 1024) { unset($msg->body); } else { $size += $length; } } // Fix mimetype which might be broken by some code when message is displayed // Another solution would be to use object's copy in rcube_message class // to prevent related issues, however I'm not sure which is better if (!empty($msg->mimetype)) { list($msg->ctype_primary, $msg->ctype_secondary) = explode('/', $msg->mimetype); } unset($msg->replaces); if (!empty($msg->structure) && is_object($msg->structure)) { $this->message_object_prepare($msg->structure, $size); } if (!empty($msg->parts) && is_array($msg->parts)) { foreach ($msg->parts as $part) { $this->message_object_prepare($part, $size); } } } /** * Fetches index data from IMAP server */ private function get_index_data($mailbox, $sort_field, $sort_order, $mbox_data = []) { if (empty($mbox_data)) { $mbox_data = $this->imap->folder_data($mailbox); } if ($mbox_data['EXISTS']) { // fetch sorted sequence numbers $index = $this->imap->index_direct($mailbox, $sort_field, $sort_order); } else { $index = new rcube_result_index($mailbox, '* SORT'); } return $index; } /** * Fetches thread data from IMAP server */ private function get_thread_data($mailbox, $mbox_data = []) { if (empty($mbox_data)) { $mbox_data = $this->imap->folder_data($mailbox); } if ($mbox_data['EXISTS']) { // get all threads (default sort order) return $this->imap->threads_direct($mailbox); } return new rcube_result_thread($mailbox, '* THREAD'); } } diff --git a/program/lib/Roundcube/rcube_ldap.php b/program/lib/Roundcube/rcube_ldap.php index ce051aec6..2997a480c 100644 --- a/program/lib/Roundcube/rcube_ldap.php +++ b/program/lib/Roundcube/rcube_ldap.php @@ -1,2284 +1,2284 @@ | | Andreas Dick | | Aleksander Machniak | +-----------------------------------------------------------------------+ */ /** * Model class to access an LDAP address directory * * @package Framework * @subpackage Addressbook */ class rcube_ldap extends rcube_addressbook { // public properties public $primary_key = 'ID'; public $groups = false; public $readonly = true; public $ready = false; public $group_id = 0; public $coltypes = []; public $export_groups = false; // private properties protected $ldap; protected $formats = []; protected $prop = []; protected $fieldmap = []; protected $filter = ''; protected $sub_filter; protected $result; protected $ldap_result; protected $mail_domain = ''; protected $debug = false; /** * Group objectclass (lowercase) to member attribute mapping * * @var array */ private $group_types = [ 'group' => 'member', 'groupofnames' => 'member', 'kolabgroupofnames' => 'member', 'groupofuniquenames' => 'uniqueMember', 'kolabgroupofuniquenames' => 'uniqueMember', 'univentiongroup' => 'uniqueMember', 'groupofurls' => null, ]; private $base_dn = ''; private $groups_base_dn = ''; private $group_data; private $group_search_cache; private $cache; /** * Object constructor * * @param array $p LDAP connection properties * @param bool $debug Enables debug mode * @param string $mail_domain Current user mail domain name */ function __construct($p, $debug = false, $mail_domain = null) { $this->prop = $p; $fetch_attributes = ['objectClass']; // check if groups are configured if (!empty($p['groups']) && is_array($p['groups'])) { $this->groups = true; // set member field if (!empty($p['groups']['member_attr'])) { $this->prop['member_attr'] = strtolower($p['groups']['member_attr']); } else if (empty($p['member_attr'])) { $this->prop['member_attr'] = 'member'; } // set default name attribute to cn if (empty($this->prop['groups']['name_attr'])) { $this->prop['groups']['name_attr'] = 'cn'; } if (empty($this->prop['groups']['scope'])) { $this->prop['groups']['scope'] = 'sub'; } // extend group objectclass => member attribute mapping if (!empty($this->prop['groups']['class_member_attr'])) { $this->group_types = array_merge($this->group_types, $this->prop['groups']['class_member_attr']); } // add group name attrib to the list of attributes to be fetched $fetch_attributes[] = $this->prop['groups']['name_attr']; } if (isset($p['group_filters']) && is_array($p['group_filters'])) { $this->groups = $this->groups || count($p['group_filters']) > 0; foreach ($p['group_filters'] as $k => $group_filter) { // set default name attribute to cn if (empty($group_filter['name_attr']) && empty($this->prop['groups']['name_attr'])) { $this->prop['group_filters'][$k]['name_attr'] = $group_filter['name_attr'] = 'cn'; } if (!empty($group_filter['name_attr'])) { $fetch_attributes[] = $group_filter['name_attr']; } } } // fieldmap property is given if (isset($p['fieldmap']) && is_array($p['fieldmap'])) { $p['fieldmap'] = array_filter($p['fieldmap']); foreach ($p['fieldmap'] as $rf => $lf) { $this->fieldmap[$rf] = $this->_attr_name($lf); } } else if (!empty($p)) { // read deprecated *_field properties to remain backwards compatible foreach ($p as $prop => $value) { if (!empty($value) && preg_match('/^(.+)_field$/', $prop, $matches)) { $this->fieldmap[$matches[1]] = $this->_attr_name($value); } } } // use fieldmap to advertise supported coltypes to the application foreach ($this->fieldmap as $colv => $lfv) { - list($col, $type) = explode(':', $colv); + list($col, $type) = array_pad(explode(':', $colv), 2, null); $params = explode(':', $lfv); $lf = array_shift($params); $limit = 1; foreach ($params as $idx => $param) { // field format specification if (preg_match('/^(date)\[(.+)\]$/i', $param, $m)) { $this->formats[$lf] = ['type' => strtolower($m[1]), 'format' => $m[2]]; } // first argument is a limit else if ($idx === 0) { if ($param == '*') $limit = null; else $limit = max(1, intval($param)); } // second is a composite field separator else if ($idx === 1 && $param) { $this->coltypes[$col]['serialized'][$type] = $param; } } - if (!is_array($this->coltypes[$col])) { + if (!is_array($this->coltypes[$col] ?? null)) { $subtypes = $type ? [$type] : null; $this->coltypes[$col] = ['limit' => $limit, 'subtypes' => $subtypes, 'attributes' => [$lf]]; } elseif ($type) { $this->coltypes[$col]['subtypes'][] = $type; $this->coltypes[$col]['attributes'][] = $lf; $this->coltypes[$col]['limit'] += $limit; } $this->fieldmap[$colv] = $lf; } // support for composite address if (!empty($this->coltypes['street']) && !empty($this->coltypes['locality'])) { $this->coltypes['address'] = [ 'limit' => max(1, $this->coltypes['locality']['limit'] + $this->coltypes['address']['limit']), 'subtypes' => array_merge((array)$this->coltypes['address']['subtypes'], (array)$this->coltypes['locality']['subtypes']), 'childs' => [], 'attributes' => [], ] + (array)$this->coltypes['address']; foreach (['street','locality','zipcode','region','country'] as $childcol) { if ($this->coltypes[$childcol]) { $this->coltypes['address']['childs'][$childcol] = ['type' => 'text']; $this->coltypes['address']['attributes'] = array_merge($this->coltypes['address']['attributes'], $this->coltypes[$childcol]['attributes']); unset($this->coltypes[$childcol]); // remove address child col from global coltypes list } } // at least one address type must be specified if (empty($this->coltypes['address']['subtypes'])) { $this->coltypes['address']['subtypes'] = ['home']; } } else if (!empty($this->coltypes['address'])) { $this->coltypes['address'] += ['type' => 'textarea', 'childs' => null, 'size' => 40]; // 'serialized' means the UI has to present a composite address field if (!empty($this->coltypes['address']['serialized'])) { $childprop = ['type' => 'text']; $this->coltypes['address']['type'] = 'composite'; $this->coltypes['address']['childs'] = [ 'street' => $childprop, 'locality' => $childprop, 'zipcode' => $childprop, 'country' => $childprop ]; } } // make sure 'required_fields' is an array if (!isset($this->prop['required_fields'])) { $this->prop['required_fields'] = []; } else if (!is_array($this->prop['required_fields'])) { $this->prop['required_fields'] = (array) $this->prop['required_fields']; } // make sure LDAP_rdn field is required if ( !empty($this->prop['LDAP_rdn']) && !in_array($this->prop['LDAP_rdn'], $this->prop['required_fields']) - && !in_array($this->prop['LDAP_rdn'], array_keys((array)$this->prop['autovalues'])) + && !in_array($this->prop['LDAP_rdn'], array_keys((array)($this->prop['autovalues'] ?? []))) ) { $this->prop['required_fields'][] = $this->prop['LDAP_rdn']; } foreach ($this->prop['required_fields'] as $key => $val) { $this->prop['required_fields'][$key] = $this->_attr_name($val); } // Build sub_fields filter if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) { $this->sub_filter = ''; foreach ($this->prop['sub_fields'] as $class) { if (!empty($class)) { $class = is_array($class) ? array_pop($class) : $class; $this->sub_filter .= '(objectClass=' . $class . ')'; } } if (count($this->prop['sub_fields']) > 1) { $this->sub_filter = '(|' . $this->sub_filter . ')'; } } if (!empty($p['sort'])) { $this->sort_col = is_array($p['sort']) ? $p['sort'][0] : $p['sort']; } $this->debug = $debug; $this->mail_domain = $this->prop['mail_domain'] = $mail_domain; // initialize cache $rcube = rcube::get_instance(); if ($cache_type = $rcube->config->get('ldap_cache', 'db')) { $cache_ttl = $rcube->config->get('ldap_cache_ttl', '10m'); $cache_name = 'LDAP.' . (!empty($this->prop['name']) ? asciiwords($this->prop['name']) : 'unnamed'); $this->cache = $rcube->get_cache($cache_name, $cache_type, $cache_ttl); } // determine which attributes to fetch $this->prop['list_attributes'] = array_unique($fetch_attributes); $this->prop['attributes'] = array_merge(array_values($this->fieldmap), $fetch_attributes); foreach ($rcube->config->get('contactlist_fields') as $col) { $this->prop['list_attributes'] = array_merge($this->prop['list_attributes'], $this->_map_field($col)); } // initialize ldap wrapper object $this->ldap = new rcube_ldap_generic($this->prop); $this->ldap->config_set(['cache' => $this->cache, 'debug' => $this->debug]); $this->_connect(); } /** * Establish a connection to the LDAP server */ private function _connect() { $rcube = rcube::get_instance(); if ($this->ready) { return true; } if (empty($this->prop['hosts'])) { $this->prop['hosts'] = []; } // try to connect + bind for every host configured // with OpenLDAP 2.x ldap_connect() always succeeds but ldap_bind will fail if host isn't reachable // see http://www.php.net/manual/en/function.ldap-connect.php foreach ((array) $this->prop['hosts'] as $host) { // skip host if connection failed if (!$this->ldap->connect($host)) { continue; } // See if the directory is writeable. if (!empty($this->prop['writable'])) { $this->readonly = false; } // trigger post-connect hook $rcube = rcube::get_instance(); $conf = $rcube->plugins->exec_hook('ldap_connected', $this->prop + ['host' => $host]); $bind_pass = $conf['bind_pass']; - $bind_user = $conf['bind_user']; + $bind_user = $conf['bind_user'] ?? null; $bind_dn = $conf['bind_dn']; - $auth_method = $conf['auth_method']; + $auth_method = $conf['auth_method'] ?? null; $this->base_dn = $conf['base_dn']; - $this->groups_base_dn = $conf['groups']['base_dn'] ?: $this->base_dn; + $this->groups_base_dn = $conf['groups']['base_dn'] ?? $this->base_dn; // User specific access, generate the proper values to use. if (!empty($conf['user_specific'])) { // No password set, use the session password if (empty($bind_pass)) { $bind_pass = $rcube->get_user_password(); } // Get the pieces needed for variable replacement. if ($fu = ($rcube->get_user_email() ?: $conf['username'])) { list($u, $d) = explode('@', $fu); } else { $u = ''; $d = $this->mail_domain; } $dc = 'dc='.strtr($d, ['.' => ',dc=']); // hierarchal domain string // resolve $dc through LDAP if ( !empty($conf['domain_filter']) && !empty($conf['search_bind_dn']) && method_exists($this->ldap, 'domain_root_dn') ) { $this->ldap->bind($conf['search_bind_dn'], $conf['search_bind_pw']); $dc = $this->ldap->domain_root_dn($d); } $replaces = ['%dn' => '', '%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u]; // Search for the dn to use to authenticate if (!empty($conf['search_base_dn']) && !empty($conf['search_filter']) && (strstr($bind_dn, '%dn') || strstr($this->base_dn, '%dn') || strstr($this->groups_base_dn, '%dn')) ) { $search_attribs = ['uid']; $search_bind_attrib = null; if (!empty($conf['search_bind_attrib'])) { $search_bind_attrib = (array) $conf['search_bind_attrib']; foreach ($search_bind_attrib as $r => $attr) { $search_attribs[] = $attr; $replaces[$r] = ''; } } $search_bind_dn = strtr($conf['search_bind_dn'], $replaces); $search_base_dn = strtr($conf['search_base_dn'], $replaces); $search_filter = strtr($conf['search_filter'], $replaces); $cache_key = rcube_cache::key_name('DN', [$host, $search_bind_dn, $search_base_dn, $search_filter, $conf['search_bind_pw']]); if ($this->cache && ($dn = $this->cache->get($cache_key))) { $replaces['%dn'] = $dn; } else { $ldap = $this->ldap; if (!empty($search_bind_dn) && !empty($conf['search_bind_pw'])) { // To protect from "Critical extension is unavailable" error // we need to use a separate LDAP connection if (!empty($conf['vlv'])) { $ldap = new rcube_ldap_generic($conf); $ldap->config_set(['cache' => $this->cache, 'debug' => $this->debug]); if (!$ldap->connect($host)) { continue; } } if (!$ldap->bind($search_bind_dn, $conf['search_bind_pw'])) { continue; // bind failed, try next host } } $res = $ldap->search($search_base_dn, $search_filter, 'sub', $search_attribs); if ($res) { $res->rewind(); $replaces['%dn'] = key($res->entries(true)); // add more replacements from 'search_bind_attrib' config if (!empty($search_bind_attrib)) { $res = $res->current(); foreach ($search_bind_attrib as $r => $attr) { $replaces[$r] = $res[$attr][0]; } } } if ($ldap != $this->ldap) { $ldap->close(); } } // DN not found if (empty($replaces['%dn'])) { if (!empty($conf['search_dn_default'])) { $replaces['%dn'] = $conf['search_dn_default']; } else { rcube::raise_error([ 'code' => 100, 'type' => 'ldap', 'file' => __FILE__, 'line' => __LINE__, 'message' => "DN not found using LDAP search." ], true ); continue; } } if ($this->cache && !empty($replaces['%dn'])) { $this->cache->set($cache_key, $replaces['%dn']); } } // Replace the bind_dn and base_dn variables. $bind_dn = strtr($bind_dn, $replaces); $this->base_dn = strtr($this->base_dn, $replaces); $this->groups_base_dn = strtr($this->groups_base_dn, $replaces); // replace placeholders in filter settings if (!empty($conf['filter'])) { $this->prop['filter'] = strtr($conf['filter'], $replaces); } foreach (['base_dn', 'filter', 'member_filter'] as $k) { if (!empty($conf['groups'][$k])) { $this->prop['groups'][$k] = strtr($conf['groups'][$k], $replaces); } } if (!empty($conf['group_filters']) && is_array($conf['group_filters'])) { foreach ($conf['group_filters'] as $i => $gf) { if (!empty($gf['base_dn'])) { $this->prop['group_filters'][$i]['base_dn'] = strtr($gf['base_dn'], $replaces); } if (!empty($gf['filter'])) { $this->prop['group_filters'][$i]['filter'] = strtr($gf['filter'], $replaces); } } } if (empty($bind_user)) { $bind_user = $u; } } if (empty($bind_pass) && strcasecmp($auth_method, 'GSSAPI') != 0) { $this->ready = true; } else { if (!empty($conf['auth_cid'])) { $this->ready = $this->ldap->sasl_bind($conf['auth_cid'], $bind_pass, $bind_dn); } else if (!empty($bind_dn)) { $this->ready = $this->ldap->bind($bind_dn, $bind_pass); } else { $this->ready = $this->ldap->sasl_bind($bind_user, $bind_pass); } } // connection established, we're done here if ($this->ready) { break; } } // end foreach hosts if (!is_resource($this->ldap->conn)) { rcube::raise_error([ 'code' => 100, 'type' => 'ldap', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Could not connect to any LDAP server" ], true ); return false; } return $this->ready; } /** * Close connection to LDAP server */ function close() { if ($this->ldap) { $this->ldap->close(); } } /** * Returns address book name * * @return string Address book name */ function get_name() { return $this->prop['name']; } /** * Set internal list page * * @param number Page number to list */ function set_page($page) { $this->list_page = (int) $page; $this->ldap->set_vlv_page($this->list_page, $this->page_size); } /** * Set internal page size * * @param number Number of records to display on one page */ function set_pagesize($size) { $this->page_size = (int) $size; $this->ldap->set_vlv_page($this->list_page, $this->page_size); } /** * Set internal sort settings * * @param string $sort_col Sort column * @param string $sort_order Sort order */ function set_sort_order($sort_col, $sort_order = null) { if (!empty($this->coltypes[$sort_col]['attributes'])) { $this->sort_col = $this->coltypes[$sort_col]['attributes'][0]; } } /** * Save a search string for future listings * * @param string $filter Filter string */ function set_search_set($filter) { $this->filter = $filter; } /** * Getter for saved search properties * * @return mixed Search properties used by this class */ function get_search_set() { return $this->filter; } /** * Reset all saved results and search parameters */ function reset() { $this->result = null; $this->ldap_result = null; $this->filter = ''; } /** * List the current set of contact records * * @param array $cols List of cols to show * @param int $subset Only return this number of records * @param bool $nocount True to skip the count query (Not used) * * @return array Indexed list of contact records, each a hash array */ function list_records($cols = null, $subset = 0, $nocount = false) { if (!empty($this->prop['searchonly']) && empty($this->filter) && !$this->group_id) { $this->result = new rcube_result_set(0); $this->result->searchonly = true; return $this->result; } // fetch group members recursively if ($this->group_id && !empty($this->group_data['dn'])) { $entries = $this->list_group_members($this->group_data['dn']); // make list of entries unique and sort it $seen = []; foreach ($entries as $i => $rec) { if (!empty($seen[$rec['dn']])) { unset($entries[$i]); } $seen[$rec['dn']] = true; } usort($entries, [$this, '_entry_sort_cmp']); $entries['count'] = count($entries); $this->result = new rcube_result_set($entries['count'], ($this->list_page-1) * $this->page_size); } else { // exec LDAP search if no result resource is stored if ($this->ready && $this->ldap_result === null) { $this->ldap_result = $this->extended_search(); } // count contacts for this user $this->result = $this->count(); $entries = $this->ldap_result; } // end else // start and end of the page $start_row = $this->ldap->vlv_active ? 0 : $this->result->first; $start_row = $subset < 0 ? $start_row + $this->page_size + $subset : $start_row; $last_row = $this->result->first + $this->page_size; $last_row = $subset != 0 ? $start_row + abs($subset) : $last_row; // filter entries for this page - for ($i = $start_row; $i < min($entries['count'], $last_row); $i++) { + for ($i = $start_row; $i < min($entries['count'] ?? null, $last_row); $i++) { if (!empty($entries[$i])) { $this->result->add($this->_ldap2result($entries[$i])); } } return $this->result; } /** * Get all members of the given group * * @param string $dn Group DN * @param bool $count Count only * @param array $entries Group entries (if called recursively) * * @return array Accumulated group members */ function list_group_members($dn, $count = false, $entries = null) { $group_members = []; // fetch group object if (empty($entries)) { $attribs = array_merge(['dn', 'objectClass', 'memberURL'], array_values($this->group_types)); $entries = $this->ldap->read_entries($dn, '(objectClass=*)', $attribs); if ($entries === false) { return $group_members; } } for ($i=0; $i < $entries['count']; $i++) { $entry = $entries[$i]; $attrs = []; foreach ((array) $entry['objectclass'] as $objectclass) { if (($member_attr = $this->get_group_member_attr([$objectclass], '')) && ($member_attr = strtolower($member_attr)) && !in_array($member_attr, $attrs) ) { $members = $this->_list_group_members($dn, $entry, $member_attr, $count); $group_members = array_merge($group_members, $members); $attrs[] = $member_attr; } else if (!empty($entry['memberurl'])) { $members = $this->_list_group_memberurl($dn, $entry, $count); $group_members = array_merge($group_members, $members); } if (!empty($this->prop['sizelimit']) && count($group_members) > $this->prop['sizelimit']) { break 2; } } } return array_filter($group_members); } /** * Fetch members of the given group entry from server * * @param string $dn Group DN * @param array $entry Group entry * @param string $attr Member attribute to use * @param bool $count Count only * * @return array Accumulated group members */ private function _list_group_members($dn, $entry, $attr, $count) { // Use the member attributes to return an array of member ldap objects // NOTE that the member attribute is supposed to contain a DN $group_members = []; if (empty($entry[$attr])) { return $group_members; } // read these attributes for all members $attrib = $count ? ['dn', 'objectClass'] : $this->prop['list_attributes']; $attrib = array_merge($attrib, array_values($this->group_types)); $attrib[] = 'memberURL'; $filter = !empty($this->prop['groups']['member_filter']) ? $this->prop['groups']['member_filter'] : '(objectclass=*)'; for ($i=0; $i < $entry[$attr]['count']; $i++) { if (empty($entry[$attr][$i])) { continue; } $members = $this->ldap->read_entries($entry[$attr][$i], $filter, $attrib); if ($members == false) { $members = []; } // for nested groups, call recursively $nested_group_members = $this->list_group_members($entry[$attr][$i], $count, $members); unset($members['count']); $group_members = array_merge($group_members, array_filter($members), $nested_group_members); } return $group_members; } /** * List members of group class groupOfUrls * * @param string $dn Group DN * @param array $entry Group entry * @param bool $count True if only used for counting * * @return array Accumulated group members */ private function _list_group_memberurl($dn, $entry, $count) { $group_members = []; for ($i = 0; $i < $entry['memberurl']['count']; $i++) { // extract components from url if (!preg_match('!ldap://[^/]*/([^\?]+)\?\?(\w+)\?(.*)$!', $entry['memberurl'][$i], $m)) { continue; } // add search filter if any $filter = $this->filter ? '(&(' . $m[3] . ')(' . $this->filter . '))' : $m[3]; $attrs = $count ? ['dn', 'objectClass'] : $this->prop['list_attributes']; if ($result = $this->ldap->search($m[1], $filter, $m[2], $attrs, $this->group_data)) { $entries = $result->entries(); for ($j = 0; $j < $entries['count']; $j++) { if ($this->is_group_entry($entries[$j]) && ($nested_group_members = $this->list_group_members($entries[$j]['dn'], $count))) { $group_members = array_merge($group_members, $nested_group_members); } else { $group_members[] = $entries[$j]; } } } } return $group_members; } /** * Callback for sorting entries */ function _entry_sort_cmp($a, $b) { return strcmp($a[$this->sort_col][0], $b[$this->sort_col][0]); } /** * Search contacts * * @param mixed $fields The field name of array of field names to search in * @param mixed $value Search value (or array of values when $fields is array) * @param int $mode Matching mode. Sum of rcube_addressbook::SEARCH_* * @param bool $select True if results are requested, False if count only * @param bool $nocount (Not used) * @param array $required List of fields that cannot be empty * * @return rcube_result_set List of contact records */ function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = []) { $mode = intval($mode); // special treatment for ID-based search if ($fields == 'ID' || $fields == $this->primary_key) { $ids = !is_array($value) ? explode(',', $value) : $value; $result = new rcube_result_set(); foreach ($ids as $id) { if ($rec = $this->get_record($id, true)) { $result->add($rec); $result->count++; } } return $result; } $rcube = rcube::get_instance(); $list_fields = $rcube->config->get('contactlist_fields'); $fuzzy_search = intval(!empty($this->prop['fuzzy_search']) && !($mode & rcube_addressbook::SEARCH_STRICT)); // use VLV pseudo-search for autocompletion if (!empty($this->prop['vlv_search']) && $this->ready && implode(',', (array)$fields) == implode(',', $list_fields) ) { $this->result = new rcube_result_set(0); $this->ldap->config_set('fuzzy_search', $fuzzy_search); $ldap_data = $this->ldap->search($this->base_dn, $this->prop['filter'], $this->prop['scope'], $this->prop['attributes'], ['search' => $value /*, 'sort' => $this->prop['sort'] */]); if ($ldap_data === false) { return $this->result; } // get all entries of this page and post-filter those that really match the query $search = mb_strtolower($value); foreach ($ldap_data as $entry) { $rec = $this->_ldap2result($entry); foreach ($fields as $f) { if (!empty($rec[$f])) { foreach ((array)$rec[$f] as $val) { if ($this->compare_search_value($f, $val, $search, $mode)) { $this->result->add($rec); $this->result->count++; break 2; } } } } } return $this->result; } // advanced per-attribute search if (is_array($value)) { // use AND operator for advanced searches $filter = '(&'; // set wildcards $wp = $ws = ''; if ($fuzzy_search) { $ws = '*'; if (!($mode & rcube_addressbook::SEARCH_PREFIX)) { $wp = '*'; } } foreach ((array) $fields as $idx => $field) { $val = $value[$idx]; if (!strlen($val)) { continue; } if ($attrs = $this->_map_field($field)) { if (count($attrs) > 1) { $filter .= '(|'; } foreach ($attrs as $f) { $filter .= "($f=$wp" . rcube_ldap_generic::quote_string($val) . "$ws)"; } if (count($attrs) > 1) { $filter .= ')'; } } } $filter .= ')'; } else { if ($fields == '*') { // search_fields are required for fulltext search if (empty($this->prop['search_fields'])) { $this->set_error(self::ERROR_SEARCH, 'nofulltextsearch'); $this->result = new rcube_result_set(); return $this->result; } $attributes = (array) $this->prop['search_fields']; } else { // map address book fields into ldap attributes $attributes = []; foreach ((array) $fields as $field) { if (!empty($this->coltypes[$field]) && !empty($this->coltypes[$field]['attributes'])) { $attributes = array_merge($attributes, (array) $this->coltypes[$field]['attributes']); } } } // compose a full-text-like search filter $filter = rcube_ldap_generic::fulltext_search_filter($value, $attributes, $mode & ~rcube_addressbook::SEARCH_GROUPS); } // add required (non empty) fields filter $req_filter = ''; foreach ((array) $required as $field) { if (in_array($field, (array) $fields)) { // required field is already in search filter continue; } if ($attrs = $this->_map_field($field)) { if (count($attrs) > 1) { $req_filter .= '(|'; } foreach ($attrs as $f) { $req_filter .= "($f=*)"; } if (count($attrs) > 1) { $req_filter .= ')'; } } } if (!empty($req_filter)) { $filter = '(&' . $req_filter . $filter . ')'; } // avoid double-wildcard if $value is empty $filter = preg_replace('/\*+/', '*', $filter); if ($mode & rcube_addressbook::SEARCH_GROUPS) { $filter = 'e:' . $filter; } // Reset the previous search result $this->reset(); // set filter string and execute search $this->set_search_set($filter); if ($select) { $this->list_records(); } else { $this->result = $this->count(); } return $this->result; } /** * Count number of available contacts in database * * @return object rcube_result_set Resultset with values for 'count' and 'first' */ function count() { $count = 0; if (!empty($this->ldap_result)) { $count = $this->ldap_result['count']; } else if ($this->group_id && !empty($this->group_data['dn'])) { $count = count($this->list_group_members($this->group_data['dn'], true)); } // We have a connection but no result set, attempt to get one. else if ($this->ready) { $count = $this->extended_search(true); } return new rcube_result_set($count, ($this->list_page-1) * $this->page_size); } /** * Wrapper on LDAP searches with group_filters support, which * allows searching for contacts AND groups. * * @param bool $count Return count instead of the records * * @return int|array Count of records or the result array (with 'count' item) */ protected function extended_search($count = false) { $prop = $this->group_id ? $this->group_data : $this->prop; $base_dn = $this->group_id ? $prop['base_dn'] : $this->base_dn; $attrs = $count ? ['dn'] : $this->prop['attributes']; // Use global search filter if ($filter = $this->filter) { if ($filter[0] == 'e' && $filter[1] == ':') { $filter = substr($filter, 2); $is_extended_search = !$this->group_id; } $prop['filter'] = $filter; // add general filter to query if (!empty($this->prop['filter'])) { $prop['filter'] = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $prop['filter'] . ')'; } } $result = $this->ldap->search($base_dn, $prop['filter'], $prop['scope'], $attrs, $prop, $count); $result_count = 0; // we have a search result resource, get all entries if (!$count && $result) { $result_count = $result->count(); $result = $result->entries(); unset($result['count']); } // search for groups if (!empty($is_extended_search) && !empty($this->prop['group_filters']) && is_array($this->prop['group_filters']) && !empty($this->prop['groups']['filter']) ) { $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['groups']['filter']) . ')' . $filter . ')'; // for groups we may use cn instead of displayname... if ($this->prop['fieldmap']['name'] != $this->prop['groups']['name_attr']) { $filter = str_replace(strtolower($this->prop['fieldmap']['name']) . '=', $this->prop['groups']['name_attr'] . '=', $filter); } $name_attr = $this->prop['groups']['name_attr']; $email_attr = $this->prop['groups']['email_attr'] ?: 'mail'; $attrs = array_unique(['dn', 'objectClass', $name_attr, $email_attr]); $res = $this->ldap->search($this->groups_base_dn, $filter, $this->prop['groups']['scope'], $attrs, $prop, $count); if ($count && $res) { $result += $res; } else if (!$count && $res && ($res_count = $res->count())) { $res = $res->entries(); unset($res['count']); $result = array_merge($result, $res); $result_count += $res_count; } } if (!$count && $result) { // sorting if ($this->sort_col && $prop['scope'] !== 'base' && !$this->ldap->vlv_active) { usort($result, [$this, '_entry_sort_cmp']); } $result['count'] = $result_count; } return $result; } /** * Return the last result set * * @return object rcube_result_set Current resultset or NULL if nothing selected yet */ function get_result() { return $this->result; } /** * Get a specific contact record * * @param mixed $dn Record identifier * @param bool $assoc Return as associative array * * @return mixed Hash array or rcube_result_set with all record fields */ function get_record($dn, $assoc = false) { $res = $this->result = null; if ($this->ready && $dn) { $dn = self::dn_decode($dn); if ($rec = $this->ldap->get_entry($dn, $this->prop['attributes'])) { $rec = array_change_key_case($rec, CASE_LOWER); } // Use ldap_list to get subentries like country (c) attribute (#1488123) if (!empty($rec) && $this->sub_filter) { if ($entries = $this->ldap->list_entries($dn, $this->sub_filter, array_keys($this->prop['sub_fields']))) { foreach ($entries as $entry) { $lrec = array_change_key_case($entry, CASE_LOWER); $rec = array_merge($lrec, $rec); } } } if (!empty($rec)) { // Add in the dn for the entry. $rec['dn'] = $dn; $res = $this->_ldap2result($rec); $this->result = new rcube_result_set(1); $this->result->add($res); } } return $assoc ? $res : $this->result; } /** * Returns the last error occurred (e.g. when updating/inserting failed) * * @return array Hash array with the following fields: type, message */ function get_error() { $err = $this->error; // check ldap connection for errors if (!$err && $this->ldap->get_error()) { $err = [self::ERROR_SEARCH, $this->ldap->get_error()]; } return $err; } /** * Check the given data before saving. * If input not valid, the message to display can be fetched using get_error() * * @param array &$save_data Associative array with data to save * @param bool $autofix Try to fix/complete record automatically * * @return bool True if input is valid, False if not. */ public function validate(&$save_data, $autofix = false) { // validate e-mail addresses if (!parent::validate($save_data, $autofix)) { return false; } // check for name input if (empty($save_data['name'])) { $this->set_error(self::ERROR_VALIDATE, 'nonamewarning'); return false; } // Verify that the required fields are set. $missing = []; $ldap_data = $this->_map_data($save_data); foreach ($this->prop['required_fields'] as $fld) { if (!isset($ldap_data[$fld]) || $ldap_data[$fld] === '') { $missing[$fld] = 1; } } if (!empty($missing)) { // try to complete record automatically if ($autofix) { $sn_field = $this->fieldmap['surname']; $fn_field = $this->fieldmap['firstname']; $mail_field = $this->fieldmap['email']; // try to extract surname and firstname from displayname $name_parts = preg_split('/[\s,.]+/', $save_data['name']); if ($sn_field && $missing[$sn_field]) { $save_data['surname'] = array_pop($name_parts); unset($missing[$sn_field]); } if ($fn_field && $missing[$fn_field]) { $save_data['firstname'] = array_shift($name_parts); unset($missing[$fn_field]); } // try to fix missing e-mail, very often on import // from vCard we have email:other only defined if ($mail_field && $missing[$mail_field]) { $emails = $this->get_col_values('email', $save_data, true); if (!empty($emails) && ($email = array_first($emails))) { $save_data['email'] = $email; unset($missing[$mail_field]); } } } // TODO: generate message saying which fields are missing if (!empty($missing)) { $this->set_error(self::ERROR_VALIDATE, 'formincomplete'); return false; } } return true; } /** * Create a new contact record * * @param array $save_cols Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values * @param bool $check True to check for duplicates first * * @return mixed The created record ID on success, False on error */ function insert($save_cols, $check = false) { // Map out the column names to their LDAP ones to build the new entry. $newentry = $this->_map_data($save_cols); $newentry['objectClass'] = $this->prop['LDAP_Object_Classes']; // add automatically generated attributes $this->add_autovalues($newentry); // Verify that the required fields are set. $missing = null; foreach ($this->prop['required_fields'] as $fld) { if (!isset($newentry[$fld])) { $missing[] = $fld; } } // abort process if required fields are missing // TODO: generate message saying which fields are missing if ($missing) { $this->set_error(self::ERROR_VALIDATE, 'formincomplete'); return false; } // Build the new entries DN. $dn = $this->prop['LDAP_rdn'].'='.rcube_ldap_generic::quote_string($newentry[$this->prop['LDAP_rdn']], true).','.$this->base_dn; // Remove attributes that need to be added separately (child objects) $xfields = []; if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) { foreach (array_keys($this->prop['sub_fields']) as $xf) { if (!empty($newentry[$xf])) { $xfields[$xf] = $newentry[$xf]; unset($newentry[$xf]); } } } if (!$this->ldap->add_entry($dn, $newentry)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return false; } foreach ($xfields as $xidx => $xf) { $xdn = $xidx.'='.rcube_ldap_generic::quote_string($xf).','.$dn; $xf = [ $xidx => $xf, 'objectClass' => (array) $this->prop['sub_fields'][$xidx], ]; $this->ldap->add_entry($xdn, $xf); } $dn = self::dn_encode($dn); // add new contact to the selected group if ($this->group_id) { $this->add_to_group($this->group_id, $dn); } return $dn; } /** * Update a specific contact record * * @param mixed $id Record identifier * @param array $save_cols Hash array with save data * * @return bool True on success, False on error */ function update($id, $save_cols) { $record = $this->get_record($id, true); $newdata = []; $replacedata = []; $deletedata = []; $subdata = []; $subdeldata = []; $subnewdata = []; $ldap_data = $this->_map_data($save_cols); $old_data = $record['_raw_attrib']; // special handling of photo col if ($photo_fld = $this->fieldmap['photo']) { // undefined means keep old photo if (!array_key_exists('photo', $save_cols)) { $ldap_data[$photo_fld] = $record['photo']; } } foreach ($this->fieldmap as $fld) { if ($fld) { $val = $ldap_data[$fld]; $old = $old_data[$fld]; // remove empty array values if (is_array($val)) { $val = array_filter($val); } // $this->_map_data() result and _raw_attrib use different format // make sure comparing array with one element with a string works as expected if (is_array($old) && count($old) == 1 && !is_array($val)) { $old = array_pop($old); } if (is_array($val) && count($val) == 1 && !is_array($old)) { $val = array_pop($val); } // Subentries must be handled separately if (!empty($this->prop['sub_fields']) && isset($this->prop['sub_fields'][$fld])) { if ($old != $val) { if ($old !== null) { $subdeldata[$fld] = $old; } if ($val) { $subnewdata[$fld] = $val; } } else if ($old !== null) { $subdata[$fld] = $old; } continue; } // The field does exist compare it to the ldap record. if ($old != $val) { // Changed, but find out how. if ($old === null) { // Field was not set prior, need to add it. $newdata[$fld] = $val; } else if ($val == '') { // Field supplied is empty, verify that it is not required. if (!in_array($fld, $this->prop['required_fields'])) { // ...It is not, safe to clear. // #1488420: Workaround "ldap_mod_del(): Modify: Inappropriate matching in..." // jpegPhoto attribute require an array here. It looks to me that it works for other attribs too $deletedata[$fld] = []; //$deletedata[$fld] = $old_data[$fld]; } } else { // The data was modified, save it out. $replacedata[$fld] = $val; } } } } // console($old_data, $ldap_data, '----', $newdata, $replacedata, $deletedata, '----', $subdata, $subnewdata, $subdeldata); $dn = self::dn_decode($id); // Update the entry as required. if (!empty($deletedata)) { // Delete the fields. if (!$this->ldap->mod_del($dn, $deletedata)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return false; } } if (!empty($replacedata)) { // Handle RDN change if (!empty($replacedata[$this->prop['LDAP_rdn']])) { $newdn = $this->prop['LDAP_rdn'] . '=' . rcube_ldap_generic::quote_string($replacedata[$this->prop['LDAP_rdn']], true) . ',' . $this->base_dn; if ($dn != $newdn) { $newrdn = $this->prop['LDAP_rdn'] . '=' . rcube_ldap_generic::quote_string($replacedata[$this->prop['LDAP_rdn']], true); unset($replacedata[$this->prop['LDAP_rdn']]); } } // Replace the fields. if (!empty($replacedata)) { if (!$this->ldap->mod_replace($dn, $replacedata)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return false; } } } // RDN change, we need to remove all sub-entries if (!empty($newrdn)) { $subdeldata = array_merge($subdeldata, $subdata); $subnewdata = array_merge($subnewdata, $subdata); } // remove sub-entries if (!empty($subdeldata)) { foreach ($subdeldata as $fld => $val) { $subdn = $fld.'='.rcube_ldap_generic::quote_string($val).','.$dn; if (!$this->ldap->delete_entry($subdn)) { return false; } } } if (!empty($newdata)) { // Add the fields. if (!$this->ldap->mod_add($dn, $newdata)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return false; } } // Handle RDN change if (!empty($newrdn) && !empty($newdn)) { if (!$this->ldap->rename($dn, $newrdn, null, true)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return false; } $dn = self::dn_encode($dn); $newdn = self::dn_encode($newdn); // change the group membership of the contact if ($this->groups) { $group_ids = $this->get_record_groups($dn); foreach (array_keys($group_ids) as $group_id) { $this->remove_from_group($group_id, $dn); $this->add_to_group($group_id, $newdn); } } $dn = self::dn_decode($newdn); } // add sub-entries if (!empty($subnewdata)) { foreach ($subnewdata as $fld => $val) { $subdn = $fld.'='.rcube_ldap_generic::quote_string($val).','.$dn; $xf = [ $fld => $val, 'objectClass' => (array) $this->prop['sub_fields'][$fld], ]; $this->ldap->add_entry($subdn, $xf); } } return isset($newdn) ? $newdn : true; } /** * Mark one or more contact records as deleted * * @param array $ids Record identifiers * @param bool $force Remove record(s) irreversible (unsupported) * * @return int|bool Number of deleted records on success, False on error */ function delete($ids, $force = true) { if (!is_array($ids)) { // Not an array, break apart the encoded DNs. $ids = explode(',', $ids); } foreach ($ids as $id) { $dn = self::dn_decode($id); // Need to delete all sub-entries first if ($this->sub_filter) { if ($entries = $this->ldap->list_entries($dn, $this->sub_filter)) { foreach ($entries as $entry) { if (!$this->ldap->delete_entry($entry['dn'])) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return false; } } } } // Delete the record. if (!$this->ldap->delete_entry($dn)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return false; } // remove contact from all groups where he was a member if ($this->groups) { $dn = self::dn_encode($dn); $group_ids = $this->get_record_groups($dn); foreach (array_keys($group_ids) as $group_id) { $this->remove_from_group($group_id, $dn); } } } return count($ids); } /** * Remove all contact records * * @param bool $with_groups Delete also groups if enabled */ function delete_all($with_groups = false) { // searching for contact entries $dn_list = $this->ldap->list_entries($this->base_dn, $this->prop['filter'] ?: '(objectclass=*)'); if (!empty($dn_list)) { foreach ($dn_list as $idx => $entry) { $dn_list[$idx] = self::dn_encode($entry['dn']); } $this->delete($dn_list); } if ($with_groups && $this->groups && ($groups = $this->_fetch_groups()) && count($groups)) { foreach ($groups as $group) { $this->ldap->delete_entry($group['dn']); } if ($this->cache) { $this->cache->remove('groups'); } } } /** * Generate missing attributes as configured * * @param array &$attrs LDAP record attributes */ protected function add_autovalues(&$attrs) { if (empty($this->prop['autovalues'])) { return; } $attrvals = []; foreach ($attrs as $k => $v) { $attrvals['{'.$k.'}'] = is_array($v) ? $v[0] : $v; } foreach ((array) $this->prop['autovalues'] as $lf => $templ) { if (empty($attrs[$lf])) { if (strpos($templ, '(') !== false) { // replace {attr} placeholders with (escaped!) attribute values to be safely eval'd $code = preg_replace('/\{\w+\}/', '', strtr($templ, array_map('addslashes', $attrvals))); $res = false; try { $res = eval("return ($code);"); } catch (ParseError $e) { // ignore } if ($res === false) { rcube::raise_error([ 'code' => 505, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Expression parse error on: ($code)" ], true, false); continue; } $attrs[$lf] = $res; } else { // replace {attr} placeholders with concrete attribute values $attrs[$lf] = preg_replace('/\{\w+\}/', '', strtr($templ, $attrvals)); } } } } /** * Converts LDAP entry into an array */ private function _ldap2result($rec) { $out = ['_type' => 'person']; $fieldmap = $this->fieldmap; if (!empty($rec['dn'])) { $out[$this->primary_key] = self::dn_encode($rec['dn']); } // determine record type if ($this->is_group_entry($rec)) { $out['_type'] = 'group'; $out['readonly'] = true; $fieldmap['name'] = $this->group_data['name_attr'] ?: $this->prop['groups']['name_attr']; } // assign object type from object class mapping if (!empty($this->prop['class_type_map'])) { foreach (array_map('strtolower', (array)$rec['objectclass']) as $objcls) { if (!empty($this->prop['class_type_map'][$objcls])) { $out['_type'] = $this->prop['class_type_map'][$objcls]; break; } } } foreach ($fieldmap as $rf => $lf) { // we might be dealing with normalized and non-normalized data - $entry = $rec[$lf]; + $entry = $rec[$lf] ?? null; if (!is_array($entry) || !isset($entry['count'])) { $entry = (array) $entry; $entry['count'] = count($entry); } for ($i=0; $i < $entry['count']; $i++) { if (!($value = $entry[$i])) { continue; } - list($col, $subtype) = explode(':', $rf); + list($col, $subtype) = array_pad(explode(':', $rf), 2, null); $out['_raw_attrib'][$lf][$i] = $value; if ($col == 'email' && $this->mail_domain && !strpos($value, '@')) { $out[$rf][] = sprintf('%s@%s', $value, $this->mail_domain); } else if (in_array($col, ['street', 'zipcode', 'locality', 'country', 'region'])) { $out['address' . ($subtype ? ':' : '') . $subtype][$i][$col] = $value; } else if ($col == 'address' && strpos($value, '$') !== false) { // address data is represented as string separated with $ list($out[$rf][$i]['street'], $out[$rf][$i]['locality'], $out[$rf][$i]['zipcode'], $out[$rf][$i]['country']) = explode('$', $value); } else if ($entry['count'] > 1) { $out[$rf][] = $value; } else { $out[$rf] = $value; } } // Make sure name fields aren't arrays (#1488108) - if (is_array($out[$rf]) && in_array($rf, ['name', 'surname', 'firstname', 'middlename', 'nickname'])) { + if (is_array($out[$rf] ?? null) && in_array($rf, ['name', 'surname', 'firstname', 'middlename', 'nickname'])) { $out[$rf] = $out['_raw_attrib'][$lf] = $out[$rf][0]; } } return $out; } /** * Return LDAP attribute(s) for the given field */ private function _map_field($field) { if (isset($this->coltypes[$field]['attributes'])) { return (array) $this->coltypes[$field]['attributes']; } return []; } /** * Convert a record data set into LDAP field attributes */ private function _map_data($save_cols) { // flatten composite fields first foreach ($this->coltypes as $col => $colprop) { if (!empty($colprop['childs']) && is_array($colprop['childs'])) { foreach ($this->get_col_values($col, $save_cols, false) as $subtype => $childs) { $subtype = $subtype ? ':'.$subtype : ''; foreach ($childs as $i => $child_values) { foreach ((array)$child_values as $childcol => $value) { $save_cols[$childcol.$subtype][$i] = $value; } } } } // if addresses are to be saved as serialized string, do so if (!empty($colprop['serialized']) && is_array($colprop['serialized'])) { foreach ($colprop['serialized'] as $subtype => $delim) { $key = $col.':'.$subtype; foreach ((array)$save_cols[$key] as $i => $val) { $values = [$val['street'], $val['locality'], $val['zipcode'], $val['country']]; $save_cols[$key][$i] = count(array_filter($values)) ? implode($delim, $values) : null; } } } } $ldap_data = []; foreach ($this->fieldmap as $rf => $fld) { $val = $save_cols[$rf]; // check for value in base field (e.g. email instead of email:foo) list($col, $subtype) = explode(':', $rf); if (!$val && !empty($save_cols[$col])) { $val = $save_cols[$col]; unset($save_cols[$col]); // use this value only once } else if (!$val && !$subtype) { // extract values from subtype cols $val = $this->get_col_values($col, $save_cols, true); } if (is_array($val)) { $val = array_filter($val); // remove empty entries } if ($fld && $val) { // The field does exist, add it to the entry. $ldap_data[$fld] = $val; } } foreach ($this->formats as $fld => $format) { if (empty($ldap_data[$fld])) { continue; } switch ($format['type']) { case 'date': if ($dt = rcube_utils::anytodatetime($ldap_data[$fld])) { $ldap_data[$fld] = $dt->format($format['format']); } break; } } return $ldap_data; } /** * Returns unified attribute name (resolving aliases) */ private static function _attr_name($namev) { // list of known attribute aliases static $aliases = [ 'gn' => 'givenname', 'rfc822mailbox' => 'email', 'userid' => 'uid', 'emailaddress' => 'email', 'pkcs9email' => 'email', ]; - list($name, $limit) = explode(':', $namev, 2); + list($name, $limit) = array_pad(explode(':', $namev, 2), 2, null); $suffix = $limit ? ':'.$limit : ''; $name = strtolower($name); return (isset($aliases[$name]) ? $aliases[$name] : $name) . $suffix; } /** * Determines whether the given LDAP entry is a group record */ private function is_group_entry($entry) { if (empty($entry['objectclass'])) { return false; } $classes = array_map('strtolower', (array)$entry['objectclass']); return count(array_intersect(array_keys($this->group_types), $classes)) > 0; } /** * Activate/deactivate debug mode * * @param bool $dbg True if LDAP commands should be logged */ function set_debug($dbg = true) { $this->debug = $dbg; if ($this->ldap) { $this->ldap->config_set('debug', $dbg); } } /** * Setter for the current group * * @param mixed $group_id Group identifier */ function set_group($group_id) { if ($group_id) { $this->group_id = $group_id; $this->group_data = $this->get_group_entry($group_id); } else { $this->group_id = 0; $this->group_data = null; } } /** * List all active contact groups of this source * * @param string $search Optional search string to match group name * @param int $mode Matching mode. Sum of rcube_addressbook::SEARCH_* * * @return array Indexed list of contact groups, each a hash array */ function list_groups($search = null, $mode = 0) { if (!$this->groups) { return []; } $group_cache = $this->_fetch_groups($search, $mode); $groups = []; if ($search) { foreach ($group_cache as $group) { if ($this->compare_search_value('name', $group['name'], mb_strtolower($search), $mode)) { $groups[] = $group; } } } else { $groups = $group_cache; } return array_values($groups); } /** * Fetch groups from server */ private function _fetch_groups($search = null, $mode = 0, $vlv_page = null) { // reset group search cache if ($search !== null && $vlv_page === null) { $this->group_search_cache = null; } // return in-memory cache from previous search results else if (is_array($this->group_search_cache) && $vlv_page === null) { return $this->group_search_cache; } // special case: list groups from 'group_filters' config if ($vlv_page === null && $search === null && !empty($this->prop['group_filters'])) { $groups = []; $rcube = rcube::get_instance(); // list regular groups configuration as special filter if (!empty($this->prop['groups']['filter'])) { $id = '__groups__'; $groups[$id] = ['ID' => $id, 'name' => $rcube->gettext('groups'), 'virtual' => true] + $this->prop['groups']; } foreach ($this->prop['group_filters'] as $id => $prop) { $groups[$id] = $prop + ['ID' => $id, 'name' => ucfirst($id), 'virtual' => true, 'base_dn' => $this->base_dn]; } return $groups; } if ($this->cache && $search === null && $vlv_page === null && ($groups = $this->cache->get('groups')) !== null) { return $groups; } $base_dn = $this->groups_base_dn; $filter = $this->prop['groups']['filter']; $scope = $this->prop['groups']['scope']; $name_attr = $this->prop['groups']['name_attr']; - $email_attr = $this->prop['groups']['email_attr'] ?: 'mail'; - $sort_attrs = (array) ($this->prop['groups']['sort'] ? $this->prop['groups']['sort'] : $name_attr); + $email_attr = $this->prop['groups']['email_attr'] ?? 'mail'; + $sort_attrs = (array) (($this->prop['groups']['sort'] ?? false) ? $this->prop['groups']['sort'] : $name_attr); $sort_attr = $sort_attrs[0]; $page_size = 200; $ldap = $this->ldap; // use vlv to list groups if (!empty($this->prop['groups']['vlv'])) { if (empty($this->prop['groups']['sort'])) { $this->prop['groups']['sort'] = $sort_attrs; } $ldap = clone $this->ldap; $ldap->config_set($this->prop['groups']); $ldap->set_vlv_page($vlv_page+1, $page_size); } - $props = ['sort' => $this->prop['groups']['sort']]; + $props = ['sort' => $this->prop['groups']['sort'] ?? null]; $attrs = array_unique(['dn', 'objectClass', $name_attr, $email_attr, $sort_attr]); // add search filter if ($search !== null) { // set wildcards $wp = $ws = ''; if (!empty($this->prop['fuzzy_search']) && !($mode & rcube_addressbook::SEARCH_STRICT)) { $ws = '*'; if (!($mode & rcube_addressbook::SEARCH_PREFIX)) { $wp = '*'; } } $filter = "(&$filter($name_attr=$wp" . rcube_ldap_generic::quote_string($search) . "$ws))"; $props['search'] = $wp . $search . $ws; } $ldap_data = $ldap->search($base_dn, $filter, $scope, $attrs, $props); if ($ldap_data === false) { return []; } $groups = []; $group_sortnames = []; $group_count = $ldap_data->count(); foreach ($ldap_data as $entry) { // DN is mandatory if (empty($entry['dn'])) { $entry['dn'] = $ldap_data->get_dn(); } $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr]; $group_id = self::dn_encode($entry['dn']); $classes = !empty($entry['objectclass']) ? $entry['objectclass'] : []; $groups[$group_id]['ID'] = $group_id; $groups[$group_id]['dn'] = $entry['dn']; $groups[$group_id]['name'] = $group_name; $groups[$group_id]['member_attr'] = $this->get_group_member_attr($classes); // list email attributes of a group for ($j=0; $entry[$email_attr] && $j < $entry[$email_attr]['count']; $j++) { if (strpos($entry[$email_attr][$j], '@') > 0) $groups[$group_id]['email'][] = $entry[$email_attr][$j]; } $group_sortnames[] = mb_strtolower($entry[$sort_attr][0]); } // recursive call can exit here if ($vlv_page > 0) { return $groups; } // call recursively until we have fetched all groups if (!empty($this->prop['groups']['vlv'])) { while ($group_count == $page_size) { $next_page = $this->_fetch_groups($search, $mode, ++$vlv_page); $groups = array_merge($groups, $next_page); $group_count = count($next_page); } } // when using VLV the list of groups is already sorted else { array_multisort($group_sortnames, SORT_ASC, SORT_STRING, $groups); } // cache this if ($this->cache && $search === null) { $this->cache->set('groups', $groups); } else if ($search !== null) { $this->group_search_cache = $groups; } return $groups; } /** * Fetch a group entry from LDAP and save in local cache */ private function get_group_entry($group_id) { $group_cache = $this->_fetch_groups(); // add group record to cache if it isn't yet there if (!isset($group_cache[$group_id])) { $name_attr = $this->prop['groups']['name_attr']; $dn = self::dn_decode($group_id); $attrs = ['dn','objectClass','member','uniqueMember','memberURL',$name_attr,$this->fieldmap['email']]; if ($list = $this->ldap->read_entries($dn, '(objectClass=*)', $attrs)) { $entry = $list[0]; $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr]; $classes = !empty($entry['objectclass']) ? $entry['objectclass'] : []; $group_cache[$group_id]['ID'] = $group_id; $group_cache[$group_id]['dn'] = $dn; $group_cache[$group_id]['name'] = $group_name; $group_cache[$group_id]['member_attr'] = $this->get_group_member_attr($classes); } else { $group_cache[$group_id] = false; } if ($this->cache) { $this->cache->set('groups', $group_cache); } } return $group_cache[$group_id]; } /** * Get group properties such as name and email address(es) * * @param string $group_id Group identifier * * @return array Group properties as hash array */ function get_group($group_id) { $group_data = $this->get_group_entry($group_id); unset($group_data['dn'], $group_data['member_attr']); return $group_data; } /** * Create a contact group with the given name * * @param string $group_name The group name * * @return mixed False on error, array with record props in success */ function create_group($group_name) { $new_dn = 'cn=' . rcube_ldap_generic::quote_string($group_name, true) . ',' . $this->groups_base_dn; $new_gid = self::dn_encode($new_dn); $member_attr = $this->get_group_member_attr(); $name_attr = $this->prop['groups']['name_attr'] ?: 'cn'; $new_entry = [ 'objectClass' => $this->prop['groups']['object_classes'], $name_attr => $group_name, $member_attr => '', ]; if (!$this->ldap->add_entry($new_dn, $new_entry)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return false; } if ($this->cache) { $this->cache->remove('groups'); } return ['id' => $new_gid, 'name' => $group_name]; } /** * Delete the given group and all linked group members * * @param string $group_id Group identifier * * @return bool True on success, false if no data was changed */ function delete_group($group_id) { $group_cache = $this->_fetch_groups(); $del_dn = $group_cache[$group_id]['dn']; if (!$this->ldap->delete_entry($del_dn)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return false; } if ($this->cache) { unset($group_cache[$group_id]); $this->cache->set('groups', $group_cache); } return true; } /** * Rename a specific contact group * * @param string $group_id Group identifier * @param string $new_name New name to set for this group * @param string &$new_gid New group identifier (if changed, otherwise don't set) * * @return bool New name on success, false if no data was changed */ function rename_group($group_id, $new_name, &$new_gid) { $group_cache = $this->_fetch_groups(); $old_dn = $group_cache[$group_id]['dn']; $new_rdn = "cn=" . rcube_ldap_generic::quote_string($new_name, true); $new_gid = self::dn_encode($new_rdn . ',' . $this->groups_base_dn); if (!$this->ldap->rename($old_dn, $new_rdn, null, true)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return false; } if ($this->cache) { $this->cache->remove('groups'); } return $new_name; } /** * Add the given contact records the a certain group * * @param string $group_id Group identifier * @param array|string $contact_ids List of contact identifiers to be added * * @return int Number of contacts added */ function add_to_group($group_id, $contact_ids) { $group_cache = $this->_fetch_groups(); $member_attr = $group_cache[$group_id]['member_attr']; $group_dn = $group_cache[$group_id]['dn']; $new_attrs = []; if (!is_array($contact_ids)) { $contact_ids = explode(',', $contact_ids); } foreach ($contact_ids as $id) { $new_attrs[$member_attr][] = self::dn_decode($id); } if (!$this->ldap->mod_add($group_dn, $new_attrs)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return 0; } if ($this->cache) { $this->cache->remove('groups'); } return count($new_attrs[$member_attr]); } /** * Remove the given contact records from a certain group * * @param string $group_id Group identifier * @param array|string $contact_ids List of contact identifiers to be removed * * @return int Number of deleted group members */ function remove_from_group($group_id, $contact_ids) { $group_cache = $this->_fetch_groups(); $member_attr = $group_cache[$group_id]['member_attr']; $group_dn = $group_cache[$group_id]['dn']; $del_attrs = []; if (!is_array($contact_ids)) { $contact_ids = explode(',', $contact_ids); } foreach ($contact_ids as $id) { $del_attrs[$member_attr][] = self::dn_decode($id); } if (!$this->ldap->mod_del($group_dn, $del_attrs)) { $this->set_error(self::ERROR_SAVING, 'errorsaving'); return 0; } if ($this->cache) { $this->cache->remove('groups'); } return count($del_attrs[$member_attr]); } /** * Get group assignments of a specific contact record * * @param mixed $contact_id Record identifier * * @return array List of assigned groups as ID=>Name pairs * @since 0.5-beta */ function get_record_groups($contact_id) { if (!$this->groups) { return []; } $base_dn = $this->groups_base_dn; $contact_dn = self::dn_decode($contact_id); $name_attr = $this->prop['groups']['name_attr'] ?: 'cn'; $member_attr = $this->get_group_member_attr(); $add_filter = ''; if ($member_attr != 'member' && $member_attr != 'uniqueMember') { $add_filter = "($member_attr=$contact_dn)"; } $filter = strtr("(|(member=$contact_dn)(uniqueMember=$contact_dn)$add_filter)", ["\\" => "\\\\"]); $ldap_data = $this->ldap->search($base_dn, $filter, 'sub', ['dn', $name_attr]); if ($ldap_data === false) { return []; } $groups = []; foreach ($ldap_data as $entry) { if (empty($entry['dn'])) { $entry['dn'] = $ldap_data->get_dn(); } $group_name = $entry[$name_attr][0]; $group_id = self::dn_encode($entry['dn']); $groups[$group_id] = $group_name; } return $groups; } /** * Detects group member attribute name */ private function get_group_member_attr($object_classes = [], $default = 'member') { if (empty($object_classes)) { $object_classes = $this->prop['groups']['object_classes']; } if (!empty($object_classes)) { foreach ((array) $object_classes as $oc) { if (!empty($this->group_types[strtolower($oc)])) { return $this->group_types[strtolower($oc)]; } } } if (!empty($this->prop['groups']['member_attr'])) { return $this->prop['groups']['member_attr']; } return $default; } /** * HTML-safe DN string encoding * * @param string $str DN string * * @return string Encoded HTML identifier string */ static function dn_encode($str) { // @TODO: to make output string shorter we could probably // remove dc=* items from it return rtrim(strtr(base64_encode($str), '+/', '-_'), '='); } /** * Decodes DN string encoded with _dn_encode() * * @param string $str Encoded HTML identifier string * * @return string DN string */ static function dn_decode($str) { $str = str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT); return base64_decode($str); } } diff --git a/program/lib/Roundcube/rcube_ldap_generic.php b/program/lib/Roundcube/rcube_ldap_generic.php index f696566f5..cfac2e9a2 100644 --- a/program/lib/Roundcube/rcube_ldap_generic.php +++ b/program/lib/Roundcube/rcube_ldap_generic.php @@ -1,356 +1,356 @@ | | Aleksander Machniak | +-----------------------------------------------------------------------+ */ /** * Model class to access an LDAP directories * * @package Framework * @subpackage LDAP */ class rcube_ldap_generic extends Net_LDAP3 { /** private properties */ protected $cache = null; protected $attributes = ['dn']; protected $error; /** * Class constructor * * @param array $config Configuration */ function __construct($config = null) { parent::__construct($config); $this->config_set('log_hook', [$this, 'log']); } /** * Establish a connection to the LDAP server */ public function connect($host = null) { // Net_LDAP3 does not support IDNA yet // also parse_host() here is very Roundcube specific - $host = rcube_utils::parse_host($host, $this->config['mail_domain']); + $host = rcube_utils::parse_host($host, $this->config['mail_domain'] ?? null); $host = rcube_utils::idn_to_ascii($host); return parent::connect($host); } /** * Prints debug/error info to the log */ public function log($level, $msg) { $msg = implode("\n", $msg); switch ($level) { case LOG_DEBUG: case LOG_INFO: case LOG_NOTICE: if (!empty($this->config['debug'])) { rcube::write_log('ldap', $msg); } break; case LOG_EMERG: case LOG_ALERT: case LOG_CRIT: rcube::raise_error($msg, true, true); break; case LOG_ERR: case LOG_WARNING: $this->error = $msg; rcube::raise_error($msg, true, false); break; } } /** * Returns the last LDAP error occurred * * @return mixed Error message string or null if no error occurred */ function get_error() { return $this->error; } /** * @deprecated */ public function set_debug($dbg = true) { $this->config['debug'] = (bool) $dbg; } /** * @deprecated */ public function set_cache($cache_engine) { $this->config['cache'] = $cache_engine; } /** * @deprecated */ public static function scope2func($scope, &$ns_function = null) { return self::scope_to_function($scope, $ns_function); } /** * @deprecated */ public function set_config($opt, $val = null) { $this->config_set($opt, $val); } /** * @deprecated */ public function add($dn, $entry) { return $this->add_entry($dn, $entry); } /** * @deprecated */ public function delete($dn) { return $this->delete_entry($dn); } /** * Wrapper for ldap_mod_replace() * * @see ldap_mod_replace() */ public function mod_replace($dn, $entry) { $this->_debug("C: Replace $dn: ".print_r($entry, true)); if (!ldap_mod_replace($this->conn, $dn, $entry)) { $this->_error("ldap_mod_replace() failed with " . ldap_error($this->conn)); return false; } $this->_debug("S: OK"); return true; } /** * Wrapper for ldap_mod_add() * * @see ldap_mod_add() */ public function mod_add($dn, $entry) { $this->_debug("C: Add $dn: ".print_r($entry, true)); if (!ldap_mod_add($this->conn, $dn, $entry)) { $this->_error("ldap_mod_add() failed with " . ldap_error($this->conn)); return false; } $this->_debug("S: OK"); return true; } /** * Wrapper for ldap_mod_del() * * @see ldap_mod_del() */ public function mod_del($dn, $entry) { $this->_debug("C: Delete $dn: ".print_r($entry, true)); if (!ldap_mod_del($this->conn, $dn, $entry)) { $this->_error("ldap_mod_del() failed with " . ldap_error($this->conn)); return false; } $this->_debug("S: OK"); return true; } /** * Wrapper for ldap_rename() * * @see ldap_rename() */ public function rename($dn, $newrdn, $newparent = null, $deleteoldrdn = true) { $this->_debug("C: Rename $dn to $newrdn"); if (!ldap_rename($this->conn, $dn, $newrdn, $newparent, $deleteoldrdn)) { $this->_error("ldap_rename() failed with " . ldap_error($this->conn)); return false; } $this->_debug("S: OK"); return true; } /** * Wrapper for ldap_list() + ldap_get_entries() * * @see ldap_list() * @see ldap_get_entries() */ public function list_entries($dn, $filter, $attributes = ['dn']) { $this->_debug("C: List $dn [{$filter}]"); if ($result = ldap_list($this->conn, $dn, $filter, $attributes)) { $list = ldap_get_entries($this->conn, $result); if ($list === false) { $this->_error("ldap_get_entries() failed with " . ldap_error($this->conn)); return []; } $count = $list['count']; unset($list['count']); $this->_debug("S: $count record(s)"); } else { $list = []; $this->_error("ldap_list() failed with " . ldap_error($this->conn)); } return $list; } /** * Wrapper for ldap_read() + ldap_get_entries() * * @see ldap_read() * @see ldap_get_entries() */ public function read_entries($dn, $filter, $attributes = null) { $this->_debug("C: Read $dn [{$filter}]"); if ($this->conn && $dn) { $result = @ldap_read($this->conn, $dn, $filter, $attributes, 0, (int)$this->config['sizelimit'], (int)$this->config['timelimit']); if ($result === false) { $this->_error("ldap_read() failed with " . ldap_error($this->conn)); return false; } $this->_debug("S: OK"); return ldap_get_entries($this->conn, $result); } return false; } /** * Turn an LDAP entry into a regular PHP array with attributes as keys. * * @param array $entry Attributes array as retrieved from ldap_get_attributes() or ldap_get_entries() * @param bool $flat Convert one-element-array values into strings (not implemented) * * @return array Hash array with attributes as keys */ public static function normalize_entry($entry, $flat = false) { if (!isset($entry['count'])) { return $entry; } $rec = []; for ($i=0; $i < $entry['count']; $i++) { $attr = $entry[$i]; if ($entry[$attr]['count'] == 1) { switch ($attr) { case 'objectclass': $rec[$attr] = [strtolower($entry[$attr][0])]; break; default: $rec[$attr] = $entry[$attr][0]; break; } } else { for ($j=0; $j < $entry[$attr]['count']; $j++) { $rec[$attr][$j] = $entry[$attr][$j]; } } } return $rec; } /** * Compose an LDAP filter string matching all words from the search string * in the given list of attributes. * * @param string $value Search value * @param mixed $attrs List of LDAP attributes to search * @param int $mode Matching mode: * 0 - partial (*abc*), * 1 - strict (=), * 2 - prefix (abc*) * @return string LDAP filter */ public static function fulltext_search_filter($value, $attributes, $mode = 1) { if (empty($attributes)) { $attributes = ['cn']; } $groups = []; $value = str_replace('*', '', $value); $words = $mode == 0 ? rcube_utils::tokenize_string($value, 1) : [$value]; // set wildcards $wp = $ws = ''; if ($mode != 1) { $ws = '*'; $wp = !$mode ? '*' : ''; } // search each word in all listed attributes foreach ($words as $word) { $parts = []; foreach ($attributes as $attr) { $parts[] = "($attr=$wp" . self::quote_string($word) . "$ws)"; } $groups[] = '(|' . implode('', $parts) . ')'; } return count($groups) > 1 ? '(&' . implode('', $groups) . ')' : implode('', $groups); } }