| +-----------------------------------------------------------------------+ */ /** * Class to create HTML page output using a skin template * * @package Webmail * @subpackage View */ class rcmail_output_html extends rcmail_output { public $type = 'html'; protected $message; protected $template_name; protected $objects = array(); protected $js_env = array(); protected $js_labels = array(); protected $js_commands = array(); protected $skin_paths = array(); protected $skin_name = ''; protected $scripts_path = ''; protected $script_files = array(); protected $css_files = array(); protected $scripts = array(); protected $meta_tags = array(); protected $link_tags = array('shortcut icon' => ''); protected $header = ''; protected $footer = ''; protected $body = ''; protected $base_path = ''; protected $assets_path; protected $assets_dir = RCUBE_INSTALL_PATH; protected $devel_mode = false; protected $default_template = "\n\n\n"; // deprecated names of templates used before 0.5 protected $deprecated_templates = array( 'contact' => 'showcontact', 'contactadd' => 'addcontact', 'contactedit' => 'editcontact', 'identityedit' => 'editidentity', 'messageprint' => 'printmessage', ); // deprecated names of template objects used before 1.4 protected $deprecated_template_objects = array( 'addressframe' => 'contentframe', 'messagecontentframe' => 'contentframe', 'prefsframe' => 'contentframe', 'folderframe' => 'contentframe', 'identityframe' => 'contentframe', 'responseframe' => 'contentframe', 'keyframe' => 'contentframe', 'filterframe' => 'contentframe', ); /** * Constructor */ public function __construct($task = null, $framed = false) { parent::__construct(); $this->devel_mode = $this->config->get('devel_mode'); $this->set_env('task', $task); $this->set_env('standard_windows', (bool) $this->config->get('standard_windows')); $this->set_env('locale', $_SESSION['language']); $this->set_env('devel_mode', $this->devel_mode); // Version number e.g. 1.4.2 will be 10402 $version = explode('.', preg_replace('/[^0-9.].*/', '', RCMAIL_VERSION)); $this->set_env('rcversion', $version[0] * 10000 + $version[1] * 100 + $version[2]); // add cookie info $this->set_env('cookie_domain', ini_get('session.cookie_domain')); $this->set_env('cookie_path', ini_get('session.cookie_path')); $this->set_env('cookie_secure', filter_var(ini_get('session.cookie_secure'), FILTER_VALIDATE_BOOLEAN)); // Easy way to change skin via GET argument, for developers if ($this->devel_mode && !empty($_GET['skin']) && preg_match('/^[a-z0-9-_]+$/i', $_GET['skin'])) { if ($this->check_skin($_GET['skin'])) { $this->set_skin($_GET['skin']); $this->app->user->save_prefs(array('skin' => $_GET['skin'])); } } // load and setup the skin $this->set_skin($this->config->get('skin')); $this->set_assets_path($this->config->get('assets_path'), $this->config->get('assets_dir')); if (!empty($_REQUEST['_extwin'])) $this->set_env('extwin', 1); if ($this->framed || $framed) $this->set_env('framed', 1); $lic = <<add_script($lic, 'head_top'); $this->add_script('var '.self::JS_OBJECT_NAME.' = new rcube_webmail();', 'head_top'); // don't wait for page onload. Call init at the bottom of the page (delayed) $this->add_script(self::JS_OBJECT_NAME.'.init();', 'docready'); $this->scripts_path = 'program/js/'; $this->include_script('jquery.min.js'); $this->include_script('common.js'); $this->include_script('app.js'); // register common UI objects $this->add_handlers(array( 'loginform' => array($this, 'login_form'), 'preloader' => array($this, 'preloader'), 'username' => array($this, 'current_username'), 'message' => array($this, 'message_container'), 'charsetselector' => array($this, 'charset_selector'), 'aboutcontent' => array($this, 'about_content'), )); // set blankpage (watermark) url $blankpage = $this->config->get('blankpage_url', '/watermark.html'); $this->set_env('blankpage', $blankpage); } /** * Set environment variable * * @param string $name Property name * @param mixed $value Property value * @param boolean $addtojs True if this property should be added * to client environment */ public function set_env($name, $value, $addtojs = true) { $this->env[$name] = $value; if ($addtojs || isset($this->js_env[$name])) { $this->js_env[$name] = $value; } } /** * Parse and set assets path * * @param string $path Assets path URL (relative or absolute) * @param string $fs_dif Assets path in filesystem */ public function set_assets_path($path, $fs_dir = null) { if (empty($path)) { return; } $path = rtrim($path, '/') . '/'; // handle relative assets path if (!preg_match('|^https?://|', $path) && $path[0] != '/') { // save the path to search for asset files later $this->assets_dir = $path; $base = preg_replace('/[?#&].*$/', '', $_SERVER['REQUEST_URI']); $base = rtrim($base, '/'); // remove url token if exists if ($len = intval($this->config->get('use_secure_urls'))) { $_base = explode('/', $base); $last = count($_base) - 1; $length = $len > 1 ? $len : 16; // as in rcube::get_secure_url_token() // we can't use real token here because it // does not exists in unauthenticated state, // hope this will not produce false-positive matches if ($last > -1 && preg_match('/^[a-f0-9]{' . $length . '}$/', $_base[$last])) { $path = '../' . $path; } } } // set filesystem path for assets if ($fs_dir) { if ($fs_dir[0] != '/') { $fs_dir = realpath(RCUBE_INSTALL_PATH . $fs_dir); } // ensure the path ends with a slash $this->assets_dir = rtrim($fs_dir, '/') . '/'; } $this->assets_path = $path; $this->set_env('assets_path', $path); } /** * Getter for the current page title * * @param bool $full Prepend title with product/user name * * @return string The page title */ protected function get_pagetitle($full = true) { if (!empty($this->pagetitle)) { $title = $this->pagetitle; } else if ($this->env['task'] == 'login') { $title = $this->app->gettext(array( 'name' => 'welcome', 'vars' => array('product' => $this->config->get('product_name') ))); } else { $title = ucfirst($this->env['task']); } if ($full) { if ($this->devel_mode && !empty($_SESSION['username'])) { $title = $_SESSION['username'] . ' :: ' . $title; } else if ($prod_name = $this->config->get('product_name')) { $title = $prod_name . ' :: ' . $title; } } return $title; } /** * Getter for the current skin path property */ public function get_skin_path() { return $this->skin_paths[0]; } /** * Set skin * * @param string $skin Skin name */ public function set_skin($skin) { if (!$this->check_skin($skin)) { // If the skin does not exist (could be removed or invalid), // fallback to the skin set in the system configuration (#7271) $skin = $this->config->system_skin; } $skin_path = 'skins/' . $skin; $this->config->set('skin_path', $skin_path); $this->base_path = $skin_path; // register skin path(s) $this->skin_paths = array(); $this->skins = array(); $this->load_skin($skin_path); $this->skin_name = $skin; $this->set_env('skin', $skin); } /** * Check skin validity/existence * * @param string $skin Skin name * * @return bool True if the skin exist and is readable, False otherwise */ public function check_skin($skin) { // Sanity check to prevent from path traversal vulnerability (#1490620) if (strpos($skin, '/') !== false || strpos($skin, "\\") !== false) { rcube::raise_error(array( 'file' => __FILE__, 'line' => __LINE__, 'message' => 'Invalid skin name' ), true, false); return false; } $skins_allowed = $this->config->get('skins_allowed'); if (!empty($skins_allowed) && !in_array($skin, (array) $skins_allowed)) { return false; } $path = RCUBE_INSTALL_PATH . 'skins/'; return !empty($skin) && is_dir($path . $skin) && is_readable($path . $skin); } /** * Helper method to recursively read skin meta files and register search paths */ private function load_skin($skin_path) { $this->skin_paths[] = $skin_path; // read meta file and check for dependencies $meta = @file_get_contents(RCUBE_INSTALL_PATH . $skin_path . '/meta.json'); $meta = @json_decode($meta, true); $meta['path'] = $skin_path; $path_elements = explode('/', $skin_path); $skin_id = end($path_elements); if (!$meta['name']) { $meta['name'] = $skin_id; } $this->skins[$skin_id] = $meta; // Keep skin config for ajax requests (#6613) $_SESSION['skin_config'] = array(); if ($meta['extends']) { $path = RCUBE_INSTALL_PATH . 'skins/'; if (is_dir($path . $meta['extends']) && is_readable($path . $meta['extends'])) { $_SESSION['skin_config'] = $this->load_skin('skins/' . $meta['extends']); } } if (!empty($meta['config'])) { foreach ($meta['config'] as $key => $value) { $this->config->set($key, $value, true); $_SESSION['skin_config'][$key] = $value; } $value = array_merge((array) $this->config->get('dont_override'), array_keys($meta['config'])); $this->config->set('dont_override', $value, true); } if (!empty($meta['localization'])) { $locdir = $meta['localization'] === true ? 'localization' : $meta['localization']; if ($texts = $this->app->read_localization(RCUBE_INSTALL_PATH . $skin_path . '/' . $locdir)) { $this->app->load_language($_SESSION['language'], $texts); } } // Use array_merge() here to allow for global default and extended skins $this->meta_tags = array_merge($this->meta_tags, (array) $meta['meta']); $this->link_tags = array_merge($this->link_tags, (array) $meta['links']); return $_SESSION['skin_config']; } /** * Check if a specific template exists * * @param string $name Template name * * @return boolean True if template exists */ public function template_exists($name) { foreach ($this->skin_paths as $skin_path) { $filename = RCUBE_INSTALL_PATH . $skin_path . '/templates/' . $name . '.html'; if ((is_file($filename) && is_readable($filename)) || ($this->deprecated_templates[$name] && $this->template_exists($this->deprecated_templates[$name])) ) { return true; } } return false; } /** * Find the given file in the current skin path stack * * @param string $file File name/path to resolve (starting with /) * @param string &$skin_path Reference to the base path of the matching skin * @param string $add_path Additional path to search in * @param bool $minified Fallback to a minified version of the file * * @return mixed Relative path to the requested file or False if not found */ public function get_skin_file($file, &$skin_path = null, $add_path = null, $minified = false) { $skin_paths = $this->skin_paths; if ($add_path) { array_unshift($skin_paths, $add_path); $skin_paths = array_unique($skin_paths); } if ($skin_path = $this->find_file_path($file, $skin_paths)) { return $skin_path . $file; } if ($minified && preg_match('/(?find_file_path($file, $skin_paths)) { return $skin_path . $file; } } return false; } /** * Find path of the asset file */ protected function find_file_path($file, $skin_paths) { foreach ($skin_paths as $skin_path) { if ($this->assets_dir != RCUBE_INSTALL_PATH) { if (realpath($this->assets_dir . $skin_path . $file)) { return $skin_path; } } if (realpath(RCUBE_INSTALL_PATH . $skin_path . $file)) { return $skin_path; } } } /** * Register a GUI object to the client script * * @param string $obj Object name * @param string $id Object ID */ public function add_gui_object($obj, $id) { $this->add_script(self::JS_OBJECT_NAME.".gui_object('$obj', '$id');"); } /** * Call a client method * * @param string Method to call * @param ... Additional arguments */ public function command() { $cmd = func_get_args(); if (strpos($cmd[0], 'plugin.') !== false) $this->js_commands[] = array('triggerEvent', $cmd[0], $cmd[1]); else $this->js_commands[] = $cmd; } /** * Add a localized label to the client environment */ public function add_label() { $args = func_get_args(); if (count($args) == 1 && is_array($args[0])) { $args = $args[0]; } foreach ($args as $name) { $this->js_labels[$name] = $this->app->gettext($name); } } /** * Invoke display_message command * * @param string $message Message to display * @param string $type Message type [notice|confirm|error] * @param array $vars Key-value pairs to be replaced in localized text * @param boolean $override Override last set message * @param int $timeout Message display time in seconds * * @uses self::command() */ public function show_message($message, $type='notice', $vars=null, $override=true, $timeout=0) { if ($override || !$this->message) { if ($this->app->text_exists($message)) { if (!empty($vars)) $vars = array_map(array('rcube','Q'), $vars); $msgtext = $this->app->gettext(array('name' => $message, 'vars' => $vars)); } else $msgtext = $message; $this->message = $message; $this->command('display_message', $msgtext, $type, $timeout * 1000); } } /** * Delete all stored env variables and commands * * @param bool $all Reset all env variables (including internal) */ public function reset($all = false) { $framed = $this->framed; $task = $this->env['task']; $env = $all ? null : array_intersect_key($this->env, array('extwin'=>1, 'framed'=>1)); // keep jQuery-UI files $css_files = $script_files = array(); foreach ($this->css_files as $file) { if (strpos($file, 'plugins/jqueryui') === 0) { $css_files[] = $file; } } foreach ($this->script_files as $position => $files) { foreach ($files as $file) { if (strpos($file, 'plugins/jqueryui') === 0) { $script_files[$position][] = $file; } } } parent::reset(); // let some env variables survive $this->env = $this->js_env = $env; $this->framed = $framed || $this->env['framed']; $this->js_labels = array(); $this->js_commands = array(); $this->scripts = array(); $this->header = ''; $this->footer = ''; $this->body = ''; $this->css_files = array(); $this->script_files = array(); // load defaults if (!$all) { $this->__construct(); } // Note: we merge jQuery-UI scripts after jQuery... $this->css_files = array_merge($this->css_files, $css_files); $this->script_files = array_merge_recursive($this->script_files, $script_files); $this->set_env('orig_task', $task); } /** * Redirect to a certain url * * @param mixed $p Either a string with the action or url parameters as key-value pairs * @param int $delay Delay in seconds * @param bool $secure Redirect to secure location (see rcmail::url()) */ public function redirect($p = array(), $delay = 1, $secure = false) { if ($this->env['extwin']) $p['extwin'] = 1; $location = $this->app->url($p, false, false, $secure); header('Location: ' . $location); exit; } /** * Send the request output to the client. * This will either parse a skin template. * * @param string $templ Template name * @param boolean $exit True if script should terminate (default) */ public function send($templ = null, $exit = true) { if ($templ != 'iframe') { // prevent from endless loops if ($exit != 'recur' && $this->app->plugins->is_processing('render_page')) { rcube::raise_error(array('code' => 505, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => 'Recursion alert: ignoring output->send()'), true, false); return; } $this->parse($templ, false); } else { $this->framed = true; $this->write(); } // set output asap ob_flush(); flush(); if ($exit) { exit; } } /** * Process template and write to stdOut * * @param string $template HTML template content */ public function write($template = '') { if (!empty($this->script_files)) { $this->set_env('request_token', $this->app->get_request_token()); } // Fix assets path on blankpage if ($this->js_env['blankpage']) { $this->js_env['blankpage'] = $this->asset_url($this->abs_url($this->js_env['blankpage'], true)); } $commands = $this->get_js_commands($framed); // if all js commands go to parent window we can ignore all // script files and skip rcube_webmail initialization (#1489792) // but not on error pages where skins may need jQuery, etc. if ($framed && empty($this->js_env['server_error'])) { $this->scripts = array(); $this->script_files = array(); $this->header = ''; $this->footer = ''; } // write all javascript commands if (!empty($commands)) { $this->add_script($commands, 'head_top'); } $this->page_headers(); // call super method $this->_write($template); } /** * Send common page headers * For now it only (re)sets X-Frame-Options when needed */ public function page_headers() { if (headers_sent()) { return; } // allow (legal) iframe content to be loaded $framed = $this->framed || $this->env['framed']; if ($framed && ($xopt = $this->app->config->get('x_frame_options', 'sameorigin'))) { if (strtolower($xopt) === 'deny') { header('X-Frame-Options: sameorigin', true); } } } /** * Parse a specific skin template and deliver to stdout (or return) * * @param string $name Template name * @param boolean $exit Exit script * @param boolean $write Don't write to stdout, return parsed content instead * * @link http://php.net/manual/en/function.exit.php */ function parse($name = 'main', $exit = true, $write = true) { $plugin = false; $realname = $name; $skin_dir = ''; $plugin_skin_paths = array(); $this->template_name = $realname; $temp = explode('.', $name, 2); if (count($temp) > 1) { $plugin = $temp[0]; $name = $temp[1]; $skin_dir = $plugin . '/skins/' . $this->config->get('skin'); // apply skin search escalation list to plugin directory foreach ($this->skin_paths as $skin_path) { $plugin_skin_paths[] = $this->app->plugins->url . $plugin . '/' . $skin_path; } // prepend plugin skin paths to search list $this->skin_paths = array_merge($plugin_skin_paths, $this->skin_paths); } // find skin template $path = false; foreach ($this->skin_paths as $skin_path) { // when requesting a plugin template ignore global skin path(s) if ($plugin && strpos($skin_path, $this->app->plugins->url) !== 0) { continue; } $path = RCUBE_INSTALL_PATH . "$skin_path/templates/$name.html"; // fallback to deprecated template names if (!is_readable($path) && ($dname = $this->deprecated_templates[$realname])) { $path = RCUBE_INSTALL_PATH . "$skin_path/templates/$dname.html"; if (is_readable($path)) { rcube::raise_error(array( 'code' => 502, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Using deprecated template '$dname' in $skin_path/templates. Please rename to '$realname'" ), true, false); } } if (is_readable($path)) { $this->config->set('skin_path', $skin_path); // set base_path to core skin directory (not plugin's skin) $this->base_path = preg_replace('!plugins/\w+/!', '', $skin_path); $skin_dir = preg_replace('!^plugins/!', '', $skin_path); break; } else { $path = false; } } // read template file if (!$path || ($templ = @file_get_contents($path)) === false) { rcube::raise_error(array( 'code' => 404, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => 'Error loading template for '.$realname ), true, $write); $this->skin_paths = array_slice($this->skin_paths, count($plugin_skin_paths)); return false; } // replace all path references to plugins/... with the configured plugins dir // and /this/ to the current plugin skin directory if ($plugin) { $templ = preg_replace( array('/\bplugins\//', '/(["\']?)\/this\//'), array($this->app->plugins->url, '\\1'.$this->app->plugins->url.$skin_dir.'/'), $templ ); } // parse for specialtags $output = $this->parse_conditions($templ); $output = $this->parse_xml($output); // trigger generic hook where plugins can put additional content to the page $hook = $this->app->plugins->exec_hook("render_page", array( 'template' => $realname, 'content' => $output, 'write' => $write)); // save some memory $output = $hook['content']; unset($hook['content']); // remove plugin skin paths from current context $this->skin_paths = array_slice($this->skin_paths, count($plugin_skin_paths)); if (!$write) { return $this->postrender($output); } $this->write(trim($output)); if ($exit) { exit; } } /** * Return executable javascript code for all registered commands */ protected function get_js_commands(&$framed = null) { $out = ''; $parent_commands = 0; $parent_prefix = ''; $top_commands = array(); // these should be always on top, // e.g. hide_message() below depends on env.framed if (!$this->framed && !empty($this->js_env)) { $top_commands[] = array('set_env', $this->js_env); } if (!empty($this->js_labels)) { $top_commands[] = array('add_label', $this->js_labels); } // unlock interface after iframe load $unlock = preg_replace('/[^a-z0-9]/i', '', $_REQUEST['_unlock']); if ($this->framed) { $top_commands[] = array('iframe_loaded', $unlock); } else if ($unlock) { $top_commands[] = array('hide_message', $unlock); } $commands = array_merge($top_commands, $this->js_commands); foreach ($commands as $i => $args) { $method = array_shift($args); $parent = $this->framed || preg_match('/^parent\./', $method); foreach ($args as $i => $arg) { $args[$i] = self::json_serialize($arg, $this->devel_mode); } if ($parent) { $parent_commands++; $method = preg_replace('/^parent\./', '', $method); $parent_prefix = 'if (window.parent && parent.' . self::JS_OBJECT_NAME . ') parent.'; $method = $parent_prefix . self::JS_OBJECT_NAME . '.' . $method; } else { $method = self::JS_OBJECT_NAME . '.' . $method; } $out .= sprintf("%s(%s);\n", $method, implode(',', $args)); } $framed = $parent_prefix && $parent_commands == count($commands); // make the output more compact if all commands go to parent window if ($framed) { $out = "if (window.parent && parent." . self::JS_OBJECT_NAME . ") {\n" . str_replace($parent_prefix, "\tparent.", $out) . "}\n"; } return $out; } /** * Make URLs starting with a slash point to skin directory * * @param string $str Input string * @param bool $search_path True if URL should be resolved using the current skin path stack * * @return string URL */ public function abs_url($str, $search_path = false) { if ($str[0] == '/') { if ($search_path && ($file_url = $this->get_skin_file($str))) { return $file_url; } return $this->base_path . $str; } return $str; } /** * Show error page and terminate script execution * * @param int $code Error code * @param string $message Error message */ public function raise_error($code, $message) { global $__page_content, $ERROR_CODE, $ERROR_MESSAGE; $ERROR_CODE = $code; $ERROR_MESSAGE = $message; include RCUBE_INSTALL_PATH . 'program/steps/utils/error.inc'; exit; } /** * Modify path by adding URL prefix if configured * * @param string $path Asset path * @param bool $abs_url Pass to self::abs_url() first * * @return string Asset path */ public function asset_url($path, $abs_url = false) { // iframe content can't be in a different domain // @TODO: check if assests are on a different domain if ($abs_url) { $path = $this->abs_url($path, true); } if (!$this->assets_path || in_array($path[0], array('?', '/', '.')) || strpos($path, '://')) { return $path; } return $this->assets_path . $path; } /***** Template parsing methods *****/ /** * Replace all strings ($varname) * with the content of the according global variable. */ protected function parse_with_globals($input) { $GLOBALS['__version'] = html::quote(RCMAIL_VERSION); $GLOBALS['__comm_path'] = html::quote($this->app->comm_path); $GLOBALS['__skin_path'] = html::quote($this->base_path); return preg_replace_callback('/\$(__[a-z0-9_\-]+)/', array($this, 'globals_callback'), $input); } /** * Callback function for preg_replace_callback() in parse_with_globals() */ protected function globals_callback($matches) { return $GLOBALS[$matches[1]]; } /** * Correct absolute paths in images and other tags (add cache busters) */ protected function fix_paths($output) { return preg_replace_callback( '!(src|href|background|data-src-[a-z]+)=(["\']?)([a-z0-9/_.-]+)(["\'\s>])!i', array($this, 'file_callback'), $output); } /** * Callback function for preg_replace_callback in fix_paths() * * @return string Parsed string */ protected function file_callback($matches) { $file = $matches[3]; $file = preg_replace('!^/this/!', '/', $file); // correct absolute paths if ($file[0] == '/') { $this->get_skin_file($file, $skin_path, $this->base_path); $file = ($skin_path ?: $this->base_path) . $file; } // add file modification timestamp if (preg_match('/\.(js|css|less|ico|png|svg|jpeg)$/', $file)) { $file = $this->file_mod($file); } return $matches[1] . '=' . $matches[2] . $file . $matches[4]; } /** * Correct paths of asset files according to assets_path */ protected function fix_assets_paths($output) { return preg_replace_callback( '!(src|href|background)=(["\']?)([a-z0-9/_.?=-]+)(["\'\s>])!i', array($this, 'assets_callback'), $output); } /** * Callback function for preg_replace_callback in fix_assets_paths() * * @return string Parsed string */ protected function assets_callback($matches) { $file = $this->asset_url($matches[3]); return $matches[1] . '=' . $matches[2] . $file . $matches[4]; } /** * Modify file by adding mtime indicator */ protected function file_mod($file) { $fs = false; $ext = substr($file, strrpos($file, '.') + 1); // use minified file if exists (not in development mode) if (!$this->devel_mode && !preg_match('/\.min\.' . $ext . '$/', $file)) { $minified_file = substr($file, 0, strlen($ext) * -1) . 'min.' . $ext; if ($fs = @filemtime($this->assets_dir . $minified_file)) { return $minified_file . '?s=' . $fs; } } if ($fs = @filemtime($this->assets_dir . $file)) { $file .= '?s=' . $fs; } return $file; } /** * Public wrapper to dipp into template parsing. * * @param string $input Template content * * @return string * @uses rcmail_output_html::parse_xml() * @since 0.1-rc1 */ public function just_parse($input) { $input = $this->parse_conditions($input); $input = $this->parse_xml($input); $input = $this->postrender($input); return $input; } /** * Parse for conditional tags */ protected function parse_conditions($input) { while (preg_match('/]+>(((?!]*>/is', $input, $conditions)) { $result = $this->eval_condition($conditions[0]); $input = str_replace($conditions[0], $result, $input); } return $input; } /** * Process & evaluate conditional tags */ protected function eval_condition($input) { $matches = preg_split('/]*)>\n?/is', $input, 2, PREG_SPLIT_DELIM_CAPTURE); if ($matches && count($matches) == 4) { if (preg_match('/^(else|endif)$/i', $matches[1])) { return $matches[0] . $this->eval_condition($matches[3]); } $attrib = html::parse_attrib_string($matches[2]); if (isset($attrib['condition'])) { $condmet = $this->check_condition($attrib['condition']); $condparts = preg_split('/]*)>\n?/is', $matches[3], 2, PREG_SPLIT_DELIM_CAPTURE); if ($condmet) { $result = $condparts[0]; if ($condparts[2] != 'endif') { $result .= preg_replace('/.*]*>\n?/Uis', '', $condparts[3], 1); } else { $result .= $condparts[3]; } } else { $result = "" . $condparts[3]; } return $matches[0] . $this->eval_condition($result); } rcube::raise_error(array( 'code' => 500, 'line' => __LINE__, 'file' => __FILE__, 'message' => "Unable to parse conditional tag " . $matches[2] ), true, false); } return $input; } /** * Determines if a given condition is met * * @param string $condition Condition statement * * @return boolean True if condition is met, False if not * @todo Extend this to allow real conditions, not just "set" */ protected function check_condition($condition) { return $this->eval_expression($condition); } /** * Inserts hidden field with CSRF-prevention-token into POST forms */ protected function alter_form_tag($matches) { $out = $matches[0]; $attrib = html::parse_attrib_string($matches[1]); if (strtolower($attrib['method']) == 'post') { $hidden = new html_hiddenfield(array('name' => '_token', 'value' => $this->app->get_request_token())); $out .= "\n" . $hidden->show(); } return $out; } /** * Parse & evaluate a given expression and return its result. * * @param string $expression Expression statement * * @return mixed Expression result */ protected function eval_expression($expression) { $expression = preg_replace( array( '/session:([a-z0-9_]+)/i', '/config:([a-z0-9_]+)(:([a-z0-9_]+))?/i', '/env:([a-z0-9_]+)/i', '/request:([a-z0-9_]+)/i', '/cookie:([a-z0-9_]+)/i', '/browser:([a-z0-9_]+)/i', '/template:name/i', ), array( "\$_SESSION['\\1']", "\$this->app->config->get('\\1',rcube_utils::get_boolean('\\3'))", "\$this->env['\\1']", "rcube_utils::get_input_value('\\1', rcube_utils::INPUT_GPC)", "\$_COOKIE['\\1']", "\$this->browser->{'\\1'}", "'{$this->template_name}'", ), $expression ); // Note: We used create_function() before but it's deprecated in PHP 7.2 // and really it was just a wrapper on eval(). return eval("return ($expression);"); } /** * Parse variable strings * * @param string $type Variable type (env, config etc) * @param string $name Variable name * * @return mixed Variable value */ protected function parse_variable($type, $name) { $value = ''; switch ($type) { case 'env': $value = $this->env[$name]; break; case 'config': $value = $this->config->get($name); if (is_array($value) && $value[$_SESSION['storage_host']]) { $value = $value[$_SESSION['storage_host']]; } break; case 'request': $value = rcube_utils::get_input_value($name, rcube_utils::INPUT_GPC); break; case 'session': $value = $_SESSION[$name]; break; case 'cookie': $value = htmlspecialchars($_COOKIE[$name], ENT_COMPAT | ENT_HTML401, RCUBE_CHARSET); break; case 'browser': $value = $this->browser->{$name}; break; } return $value; } /** * Search for special tags in input and replace them * with the appropriate content * * @param string $input Input string to parse * * @return string Altered input string * @todo Use DOM-parser to traverse template HTML * @todo Maybe a cache. */ protected function parse_xml($input) { return preg_replace_callback('/]|\\\\>)+)(?/Ui', array($this, 'xml_command'), $input); } /** * Callback function for parsing an xml command tag * and turn it into real html content * * @param array $matches Matches array of preg_replace_callback * * @return string Tag/Object content */ protected function xml_command($matches) { $command = strtolower($matches[1]); $attrib = html::parse_attrib_string($matches[2]); // empty output if required condition is not met if (!empty($attrib['condition']) && !$this->check_condition($attrib['condition'])) { return ''; } // localize title and summary attributes if ($command != 'button' && !empty($attrib['title']) && $this->app->text_exists($attrib['title'])) { $attrib['title'] = $this->app->gettext($attrib['title']); } if ($command != 'button' && !empty($attrib['summary']) && $this->app->text_exists($attrib['summary'])) { $attrib['summary'] = $this->app->gettext($attrib['summary']); } // execute command switch ($command) { // return a button case 'button': if ($attrib['name'] || $attrib['command']) { return $this->button($attrib); } break; // frame case 'frame': return $this->frame($attrib); break; // show a label case 'label': if ($attrib['expression']) $attrib['name'] = $this->eval_expression($attrib['expression']); if ($attrib['name'] || $attrib['command']) { $vars = $attrib + array('product' => $this->config->get('product_name')); unset($vars['name'], $vars['command']); $label = $this->app->gettext($attrib + array('vars' => $vars)); $quoting = !empty($attrib['quoting']) ? strtolower($attrib['quoting']) : (rcube_utils::get_boolean((string)$attrib['html']) ? 'no' : ''); // 'noshow' can be used in skins to define new labels if ($attrib['noshow']) { return ''; } switch ($quoting) { case 'no': case 'raw': break; case 'javascript': case 'js': $label = rcube::JQ($label); break; default: $label = html::quote($label); break; } return $label; } break; case 'add_label': $this->add_label($attrib['name']); break; // include a file case 'include': if ($attrib['condition'] && !$this->check_condition($attrib['condition'])) { break; } if ($attrib['file'][0] != '/') { $attrib['file'] = '/templates/' . $attrib['file']; } $old_base_path = $this->base_path; $include = ''; if (!empty($attrib['skin_path'])) { $attrib['skinpath'] = $attrib['skin_path']; } if ($path = $this->get_skin_file($attrib['file'], $skin_path, $attrib['skinpath'])) { // set base_path to core skin directory (not plugin's skin) $this->base_path = preg_replace('!plugins/\w+/!', '', $skin_path); $path = realpath(RCUBE_INSTALL_PATH . $path); } if (is_readable($path)) { $allow_php = $this->config->get('skin_include_php'); $include = $allow_php ? $this->include_php($path) : file_get_contents($path); $include = $this->parse_conditions($include); $include = $this->parse_xml($include); $include = $this->fix_paths($include); } $this->base_path = $old_base_path; return $include; case 'plugin.include': $hook = $this->app->plugins->exec_hook("template_plugin_include", $attrib); return $hook['content']; // define a container block case 'container': if ($attrib['name'] && $attrib['id']) { $this->command('gui_container', $attrib['name'], $attrib['id']); // let plugins insert some content here $hook = $this->app->plugins->exec_hook("template_container", $attrib); return $hook['content']; } break; // return code for a specific application object case 'object': $object = strtolower($attrib['name']); $content = ''; // correct deprecated object names if ($this->deprecated_template_objects[$object]) { $object = $this->deprecated_template_objects[$object]; } $handler = $this->object_handlers[$object]; // execute object handler function if (is_callable($handler)) { $this->prepare_object_attribs($attrib); // We assume that objects with src attribute are internal (in most // cases this is a watermark frame). We need this to make sure assets_path // is added to the internal assets paths $external = empty($attrib['src']); $content = call_user_func($handler, $attrib); } else if ($object == 'doctype') { $content = html::doctype($attrib['value']); } else if ($object == 'logo') { $attrib += array('alt' => $this->xml_command(array('', 'object', 'name="productname"'))); if (!empty($attrib['type']) && ($template_logo = $this->get_template_logo($attrib['type'])) !== null) { $attrib['src'] = $template_logo; } else if (($template_logo = $this->get_template_logo()) !== null) { $attrib['src'] = $template_logo; } // process alternative logos (eg for Elastic small screen) foreach ($attrib as $key => $value) { if (preg_match('/data-src-(.*)/', $key, $matches)) { if (($template_logo = $this->get_template_logo($matches[1])) !== null) { $attrib[$key] = $template_logo; } $attrib[$key] = !empty($attrib[$key]) ? $this->abs_url($attrib[$key]) : null; } } if ($attrib['src']) { $content = html::img($attrib); } } else if ($object == 'productname') { $name = $this->config->get('product_name', 'Roundcube Webmail'); $content = html::quote($name); } else if ($object == 'version') { $ver = (string)RCMAIL_VERSION; if (is_file(RCUBE_INSTALL_PATH . '.svn/entries')) { if (preg_match('/Revision:\s(\d+)/', @shell_exec('svn info'), $regs)) $ver .= ' [SVN r'.$regs[1].']'; } else if (is_file(RCUBE_INSTALL_PATH . '.git/index')) { if (preg_match('/Date:\s+([^\n]+)/', @shell_exec('git log -1'), $regs)) { if ($date = date('Ymd.Hi', strtotime($regs[1]))) { $ver .= ' [GIT '.$date.']'; } } } $content = html::quote($ver); } else if ($object == 'steptitle') { $content = html::quote($this->get_pagetitle(false)); } else if ($object == 'pagetitle') { // Deprecated, will be added automatically $content = html::quote($this->get_pagetitle()); } else if ($object == 'contentframe') { if (empty($attrib['id'])) { $attrib['id'] = 'rcm' . $this->env['task'] . 'frame'; } // parse variables if (preg_match('/^(config|env):([a-z0-9_]+)$/i', $attrib['src'], $matches)) { $attrib['src'] = $this->parse_variable($matches[1], $matches[2]); } $content = $this->frame($attrib, true); } else if ($object == 'meta' || $object == 'links') { if ($object == 'meta') { $source = 'meta_tags'; $tag = 'meta'; $key = 'name'; $param = 'content'; } else { $source = 'link_tags'; $tag = 'link'; $key = 'rel'; $param = 'href'; } foreach ($this->$source as $name => $vars) { // $vars can be in many forms: // - string // - array('key' => 'val') // - array(string, string) // - array(array(), string) // - array(array('key' => 'val'), array('key' => 'val')) // normalise this for processing by checking for string array keys $vars = is_array($vars) ? (count(array_filter(array_keys($vars), 'is_string')) > 0 ? array($vars) : $vars) : array($vars); foreach ($vars as $args) { // skip unset headers e.g. when extending a skin and removing a header defined in the parent if ($args === false) { continue; } $args = is_array($args) ? $args : array($param => $args); // special handling for favicon if ($object == 'links' && $name == 'shortcut icon' && empty($args[$param])) { if ($href = $this->get_template_logo('favicon')) { $args[$param] = $href; } else if ($href = $this->config->get('favicon', '/images/favicon.ico')) { $args[$param] = $href; } } $content .= html::tag($tag, array($key => $name, 'nl' => true) + $args); } } } // exec plugin hooks for this template object $hook = $this->app->plugins->exec_hook("template_object_$object", $attrib + array('content' => $content)); if (strlen($hook['content']) && !empty($external)) { $object_id = uniqid('TEMPLOBJECT:', true); $this->objects[$object_id] = $hook['content']; $hook['content'] = $object_id; } return $hook['content']; // return <link> element case 'link': if ($attrib['condition'] && !$this->check_condition($attrib['condition'])) { break; } unset($attrib['condition']); return html::tag('link', $attrib); // return code for a specified eval expression case 'exp': return html::quote($this->eval_expression($attrib['expression'])); // return variable case 'var': $var = explode(':', $attrib['name']); $value = $this->parse_variable($var[0], $var[1]); if (is_array($value)) { $value = implode(', ', $value); } return html::quote($value); case 'form': return $this->form_tag($attrib); } return ''; } /** * Prepares template object attributes * * @param array &$attribs Attributes */ protected function prepare_object_attribs(&$attribs) { foreach ($attribs as $key => &$value) { if (strpos($key, 'data-label-') === 0) { // Localize data-label-* attributes $value = $this->app->gettext($value); } elseif ($key[0] == ':') { // Evaluate attributes with expressions and remove special character from attribute name $attribs[substr($key, 1)] = $this->eval_expression($value); unset($attribs[$key]); } } } /** * Include a specific file and return it's contents * * @param string $file File path * * @return string Contents of the processed file */ protected function include_php($file) { ob_start(); include $file; $out = ob_get_contents(); ob_end_clean(); return $out; } /** * Put objects' content back into template output */ protected function postrender($output) { // insert objects' contents foreach ($this->objects as $key => $val) { $output = str_replace($key, $val, $output, $count); if ($count) { $this->objects[$key] = null; } } // make sure all <form> tags have a valid request token $output = preg_replace_callback('/<form\s+([^>]+)>/Ui', array($this, 'alter_form_tag'), $output); return $output; } /** * Create and register a button * * @param array $attrib Named button attributes * * @return string HTML button * @todo Remove all inline JS calls and use jQuery instead. * @todo Remove all sprintf()'s - they are pretty, but also slow. */ public function button($attrib) { static $s_button_count = 100; static $disabled_actions = null; // these commands can be called directly via url $a_static_commands = array('compose', 'list', 'preferences', 'folders', 'identities'); if (!($attrib['command'] || $attrib['name'] || $attrib['href'])) { return ''; } // try to find out the button type if ($attrib['type']) { $attrib['type'] = strtolower($attrib['type']); if (strpos($attrib['type'], '-menuitem')) { $attrib['type'] = substr($attrib['type'], 0, -9); $menuitem = true; } } else { $attrib['type'] = ($attrib['image'] || $attrib['imagepas'] || $attrib['imageact']) ? 'image' : 'button'; } $command = $attrib['command']; $action = $command ?: $attrib['name']; if ($attrib['task']) { $command = $attrib['task'] . '.' . $command; $element = $attrib['task'] . '.' . $action; } else { $element = ($this->env['task'] ? $this->env['task'] . '.' : '') . $action; } if ($disabled_actions === null) { $disabled_actions = (array) $this->config->get('disabled_actions'); } // remove buttons for disabled actions if (in_array($element, $disabled_actions) || in_array($action, $disabled_actions)) { return ''; } if (!$attrib['image']) { $attrib['image'] = $attrib['imagepas'] ? $attrib['imagepas'] : $attrib['imageact']; } if (!$attrib['id']) { $attrib['id'] = sprintf('rcmbtn%d', $s_button_count++); } // get localized text for labels and titles if ($attrib['title']) { $attrib['title'] = html::quote($this->app->gettext($attrib['title'], $attrib['domain'])); } if ($attrib['label']) { $attrib['label'] = html::quote($this->app->gettext($attrib['label'], $attrib['domain'])); } if ($attrib['alt']) { $attrib['alt'] = html::quote($this->app->gettext($attrib['alt'], $attrib['domain'])); } // set accessibility attributes if (!$attrib['role']) { $attrib['role'] = 'button'; } if (!empty($attrib['class']) && !empty($attrib['classact']) || !empty($attrib['imagepas']) && !empty($attrib['imageact'])) { if (array_key_exists('tabindex', $attrib)) $attrib['data-tabindex'] = $attrib['tabindex']; $attrib['tabindex'] = '-1'; // disable button by default $attrib['aria-disabled'] = 'true'; } // set title to alt attribute for IE browsers if ($this->browser->ie && !$attrib['title'] && $attrib['alt']) { $attrib['title'] = $attrib['alt']; } // add empty alt attribute for XHTML compatibility if (!isset($attrib['alt'])) { $attrib['alt'] = ''; } // register button in the system if ($attrib['command']) { $this->add_script(sprintf( "%s.register_button('%s', '%s', '%s', '%s', '%s', '%s');", self::JS_OBJECT_NAME, $command, $attrib['id'], $attrib['type'], $attrib['imageact'] ? $this->abs_url($attrib['imageact']) : $attrib['classact'], $attrib['imagesel'] ? $this->abs_url($attrib['imagesel']) : $attrib['classsel'], $attrib['imageover'] ? $this->abs_url($attrib['imageover']) : '' )); // make valid href to specific buttons if (in_array($attrib['command'], rcmail::$main_tasks)) { $attrib['href'] = $this->app->url(array('task' => $attrib['command'])); $attrib['onclick'] = sprintf("return %s.command('switch-task','%s',this,event)", self::JS_OBJECT_NAME, $attrib['command']); } else if ($attrib['task'] && in_array($attrib['task'], rcmail::$main_tasks)) { $attrib['href'] = $this->app->url(array('action' => $attrib['command'], 'task' => $attrib['task'])); } else if (in_array($attrib['command'], $a_static_commands)) { $attrib['href'] = $this->app->url(array('action' => $attrib['command'])); } else if (($attrib['command'] == 'permaurl' || $attrib['command'] == 'extwin') && !empty($this->env['permaurl'])) { $attrib['href'] = $this->env['permaurl']; } } // overwrite attributes if (!$attrib['href']) { $attrib['href'] = '#'; } if ($attrib['task']) { if ($attrib['classact']) { $attrib['class'] = $attrib['classact']; } } else if ($command && !$attrib['onclick']) { $attrib['onclick'] = sprintf( "return %s.command('%s','%s',this,event)", self::JS_OBJECT_NAME, $command, $attrib['prop'] ); } $out = ''; $btn_content = null; $link_attrib = array(); // generate image tag if ($attrib['type'] == 'image') { $attrib_str = html::attrib_string( $attrib, array( 'style', 'class', 'id', 'width', 'height', 'border', 'hspace', 'vspace', 'align', 'alt', 'tabindex', 'title' ) ); $btn_content = sprintf('<img src="%s"%s />', $this->abs_url($attrib['image']), $attrib_str); if ($attrib['label']) { $btn_content .= ' '.$attrib['label']; } $link_attrib = array('href', 'onclick', 'onmouseover', 'onmouseout', 'onmousedown', 'onmouseup', 'target'); } else if ($attrib['type'] == 'link') { $btn_content = isset($attrib['content']) ? $attrib['content'] : ($attrib['label'] ? $attrib['label'] : $attrib['command']); $link_attrib = array_merge(html::$common_attrib, array('href', 'onclick', 'tabindex', 'target', 'rel')); if ($attrib['innerclass']) { $btn_content = html::span($attrib['innerclass'], $btn_content); } } else if ($attrib['type'] == 'input') { $attrib['type'] = 'button'; if ($attrib['label']) { $attrib['value'] = $attrib['label']; } if ($attrib['command']) { $attrib['disabled'] = 'disabled'; } $out = html::tag('input', $attrib, null, array('type', 'value', 'onclick', 'id', 'class', 'style', 'tabindex', 'disabled')); } else { if ($attrib['label']) { $attrib['value'] = $attrib['label']; } if ($attrib['command']) { $attrib['disabled'] = 'disabled'; } $content = isset($attrib['content']) ? $attrib['content'] : $attrib['label']; $out = html::tag('button', $attrib, $content, array('type', 'value', 'onclick', 'id', 'class', 'style', 'tabindex', 'disabled')); } // generate html code for button if ($btn_content) { $attrib_str = html::attrib_string($attrib, $link_attrib); $out = sprintf('<a%s>%s</a>', $attrib_str, $btn_content); } if ($attrib['wrapper']) { $out = html::tag($attrib['wrapper'], null, $out); } if (!empty($menuitem)) { $class = $attrib['menuitem-class'] ? ' class="' . $attrib['menuitem-class'] . '"' : ''; $out = '<li role="menuitem"' . $class . '>' . $out . '</li>'; } return $out; } /** * Link an external script file * * @param string $file File URL * @param string $position Target position [head|head_bottom|foot] */ public function include_script($file, $position = 'head', $add_path = true) { if ($add_path && !preg_match('|^https?://|i', $file) && $file[0] != '/') { $file = $this->file_mod($this->scripts_path . $file); } if (!is_array($this->script_files[$position])) { $this->script_files[$position] = array(); } if (!in_array($file, $this->script_files[$position])) { $this->script_files[$position][] = $file; } } /** * Add inline javascript code * * @param string $script JS code snippet * @param string $position Target position [head|head_top|foot|docready] */ public function add_script($script, $position = 'head') { if (!isset($this->scripts[$position])) { $this->scripts[$position] = rtrim($script); } else { $this->scripts[$position] .= "\n" . rtrim($script); } } /** * Link an external css file * * @param string $file File URL */ public function include_css($file) { $this->css_files[] = $file; } /** * Add HTML code to the page header * * @param string $str HTML code */ public function add_header($str) { $this->header .= "\n" . $str; } /** * Add HTML code to the page footer * To be added right befor </body> * * @param string $str HTML code */ public function add_footer($str) { $this->footer .= "\n" . $str; } /** * Process template and write to stdOut * * @param string $output HTML output */ protected function _write($output = '') { $output = trim($output); if (empty($output)) { $output = html::doctype('html5') . "\n" . $this->default_template; $is_empty = true; } $merge_script_files = function($output, $script) { return $output . html::script($script); }; $merge_scripts = function($output, $script) { return $output . html::script(array(), $script); }; // put docready commands into page footer if (!empty($this->scripts['docready'])) { $this->add_script("\$(function() {\n" . $this->scripts['docready'] . "\n});", 'foot'); } $page_header = ''; $page_footer = ''; $meta = ''; // declare page language if (!empty($_SESSION['language'])) { $lang = substr($_SESSION['language'], 0, 2); $output = preg_replace('/<html/', '<html lang="' . html::quote($lang) . '"', $output, 1); if (!headers_sent()) { header('Content-Language: ' . $lang); } } // include meta tag with charset if (!empty($this->charset)) { if (!headers_sent()) { header('Content-Type: text/html; charset=' . $this->charset); } $meta .= html::tag('meta', array( 'http-equiv' => 'content-type', 'content' => "text/html; charset={$this->charset}", 'nl' => true )); } // include page title (after charset specification) $meta .= '<title>' . html::quote($this->get_pagetitle()) . "\n"; $output = preg_replace('/(]*>)\n*/i', "\\1\n{$meta}", $output, 1, $count); if (!$count) { $page_header .= $meta; } // include scripts into header/footer $page_header .= array_reduce((array) $this->script_files['head'], $merge_script_files); $page_header .= array_reduce(array($this->scripts['head_top'] . $this->scripts['head']), $merge_scripts); $page_header .= $this->header . "\n"; $page_header .= array_reduce((array) $this->script_files['head_bottom'], $merge_script_files); $page_footer .= array_reduce((array) $this->script_files['foot'], $merge_script_files); $page_footer .= $this->footer . "\n"; $page_footer .= array_reduce((array) $this->scripts['foot'], $merge_scripts); // find page header if ($hpos = stripos($output, '')) { $page_header .= "\n"; } else { if (!is_numeric($hpos)) { $hpos = stripos($output, '')) || ($fpos = strripos($output, ''))) { // for Elastic: put footer content before "footer scripts" while (($npos = strripos($output, "\n", -strlen($output) + $fpos - 1)) && $npos != $fpos && ($chunk = substr($output, $npos, $fpos - $npos)) !== '' && (trim($chunk) === '' || preg_match('/\s*]+><\/script>\s*/', $chunk)) ) { $fpos = $npos; } $output = substr_replace($output, $page_footer."\n", $fpos, 0); } else { $output .= "\n".$page_footer; } // add css files in head, before scripts, for speed up with parallel downloads if (!empty($this->css_files) && empty($is_empty) && (($pos = stripos($output, '