| +-----------------------------------------------------------------------+ */ /** * Class to create HTML page output using a skin template */ class rcmail_output_html extends rcmail_output { public $type = 'html'; protected $message; protected $template_name; protected $objects = []; protected $js_env = []; protected $js_labels = []; protected $js_commands = []; protected $skin_paths = []; protected $skin_extends = []; protected $skin_name = ''; protected $scripts_path = ''; protected $script_files = []; protected $css_files = []; protected $scripts = []; protected $task; protected $meta_tags = []; protected $link_tags = ['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 = [ 'contact' => 'showcontact', 'contactadd' => 'addcontact', 'contactedit' => 'editcontact', 'identityedit' => 'editidentity', 'messageprint' => 'printmessage', ]; // deprecated names of template objects used before 1.4 protected $deprecated_template_objects = [ '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->task = $task; $this->init($framed); } /** * Initialization */ protected function init($framed = false) { $this->set_env('task', $this->task); $this->set_env('standard_windows', (bool) $this->config->get('standard_windows')); $this->set_env('locale', !empty($_SESSION['language']) ? $_SESSION['language'] : 'en_US'); $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', intval($version[0]) * 10000 + intval($version[1]) * 100 + ($version[2] ?? 0)); // 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(['skin' => $_GET['skin']]); } } // load and setup the skin $this->set_skin($this->config->get('skin')); $this->set_assets_path($this->config->get('assets_path')); if (!empty($_REQUEST['_extwin'])) { $this->set_env('extwin', 1); } if ($this->framed || $framed) { $this->set_env('framed', 1); } $lic = <<<'EOF' /* @licstart The following is the entire license notice for the JavaScript code in this page. Copyright (C) The Roundcube Dev Team The JavaScript code in this page is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. The code is distributed WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU GPL for more details. @licend The above is the entire license notice for the JavaScript code in this page. */ EOF; // add common javascripts $this->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([ 'loginform' => [$this, 'login_form'], 'preloader' => [$this, 'preloader'], 'username' => [$this, 'current_username'], 'message' => [$this, 'message_container'], 'charsetselector' => [$this, 'charset_selector'], 'aboutcontent' => [$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 bool $addtojs True if this property should be added * to client environment */ #[Override] 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) */ public function set_assets_path($path) { // set absolute path for assets if /index.php/foo/bar url is used if (empty($path) && !empty($_SERVER['PATH_INFO'])) { $path = preg_replace('/\?_task=[a-z]+/', '', $this->app->url([], true)); } 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 (preg_match('/^[a-f0-9]{' . $length . '}$/', $_base[$last])) { $path = '../' . $path; } } } $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; } elseif (isset($this->env['task'])) { if ($this->env['task'] == 'login') { $title = $this->app->gettext([ 'name' => 'welcome', 'vars' => ['product' => $this->config->get('product_name')], ]); } else { $title = ucfirst($this->env['task']); } } else { $title = ''; } if ($full && $title) { if ($this->devel_mode && !empty($_SESSION['username'])) { $title = $_SESSION['username'] . ' :: ' . $title; } elseif ($prod_name = $this->config->get('product_name')) { $title = $prod_name . ' :: ' . $title; } } return $title; } /** * Getter for the current skin path property */ #[Override] 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 = []; $this->skins = []; $this->load_skin($skin); $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) // @phpstan-ignore-next-line if (!is_string($skin) || strpos($skin, '/') !== false || strpos($skin, '\\') !== false) { rcube::raise_error('Invalid skin name', true); 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_name) { $skin_path = 'skins/' . $skin_name; $this->skin_paths[] = $skin_path; // read meta file and check for dependencies $meta = $this->get_skin_info($skin_name); $meta['path'] = $skin_path; $path_elements = explode('/', $skin_path); $skin_id = end($path_elements); if (empty($meta['name'])) { $meta['name'] = $skin_id; } $this->skins[$skin_id] = $meta; // Keep skin config for ajax requests (#6613) $_SESSION['skin_config'] = []; if (!empty($meta['extends'])) { $path = RCUBE_INSTALL_PATH . 'skins/'; if (is_dir($path . $meta['extends']) && is_readable($path . $meta['extends'])) { $_SESSION['skin_config'] = $this->load_skin($meta['extends']); $this->skin_extends[] = $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 if (!empty($meta['meta'])) { $this->meta_tags = array_merge($this->meta_tags, (array) $meta['meta']); } if (!empty($meta['links'])) { $this->link_tags = array_merge($this->link_tags, (array) $meta['links']); } if (!empty($this->skin_extends)) { $this->set_env('skin_extends', $this->skin_extends); } $this->set_env('dark_mode_support', (bool) $this->config->get('dark_mode_support')); return $_SESSION['skin_config']; } /** * Check if a specific template exists * * @param string $name Template name * * @return bool True if template exists, False otherwise */ 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)) || (!empty($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 string|false 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 ($file[0] != '/') { $file = '/' . $file; } 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 $cmd Method to call * @param mixed ...$args Method arguments */ #[Override] public function command($cmd, ...$args) { if (strpos($cmd, 'plugin.') !== false) { $this->js_commands[] = ['triggerEvent', $cmd, $args[0]]; } else { array_unshift($args, $cmd); $this->js_commands[] = $args; } } /** * Add a localized label to the client environment * * @param mixed ...$args Labels (an array of strings, or many string arguments) */ #[Override] public function add_label(...$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 bool $override Override last set message * @param int $timeout Message display time in seconds * * @uses self::command() */ #[Override] 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(['rcube', 'Q'], $vars); } $msgtext = $this->app->gettext(['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) */ #[Override] public function reset($all = false) { $framed = $this->framed; $task = $this->env['task'] ?? ''; $env = $all ? null : array_intersect_key($this->env, ['extwin' => 1, 'framed' => 1]); // keep jQuery-UI files $css_files = $script_files = []; foreach ($this->css_files as $file) { if (str_starts_with($file, 'plugins/jqueryui')) { $css_files[] = $file; } } foreach ($this->script_files as $position => $files) { foreach ($files as $file) { if (str_starts_with($file, 'plugins/jqueryui')) { $script_files[$position][] = $file; } } } parent::reset(); // let some env variables survive $this->env = $this->js_env = $env; $this->framed = $framed || !empty($this->env['framed']); $this->js_labels = []; $this->js_commands = []; $this->scripts = []; $this->header = ''; $this->footer = ''; $this->body = ''; $this->css_files = []; $this->script_files = []; // load defaults if (!$all) { $this->init(); } // 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()) */ #[Override] public function redirect($p = [], $delay = 1, $secure = false) { if (!empty($this->env['extwin']) && !(is_string($p) && preg_match('#^https?://#', $p))) { if (!is_array($p)) { $p = ['_action' => $p]; } $p['_extwin'] = 1; } $location = $this->app->url($p, false, false, $secure); $this->header('Location: ' . $location); exit; } /** * Send the request output to the client. * This will either parse a skin template. * * @param string $templ Template name * @param bool $exit True if script should terminate (default) */ #[Override] 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([ 'code' => 505, '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 (!empty($this->js_env['blankpage'])) { $this->js_env['blankpage'] = $this->asset_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 = []; $this->script_files = []; $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 || !empty($this->env['framed']); if ($framed && ($xopt = $this->app->config->get('x_frame_options', 'sameorigin'))) { if (strtolower($xopt) === 'deny') { $this->header('X-Frame-Options: sameorigin', true); } } } /** * Parse a specific skin template and deliver to stdout (or return) * * @param string $name Template name * @param bool $exit Exit script * @param bool $write Don't write to stdout, return parsed content instead * * @see https://php.net/manual/en/function.exit.php */ public function parse($name = 'main', $exit = true, $write = true) { $plugin = false; $realname = $name; $skin_dir = ''; $plugin_skin_paths = []; $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) { // skin folder in plugin dir $plugin_skin_paths[] = $this->app->plugins->url . $plugin . '/' . $skin_path; // plugin folder in skin dir $plugin_skin_paths[] = $skin_path . '/plugins/' . $plugin; } // 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) === false) { continue; } $path = RCUBE_INSTALL_PATH . "{$skin_path}/templates/{$name}.html"; // fallback to deprecated template names if (!is_readable($path) && !empty($this->deprecated_templates[$realname])) { $dname = $this->deprecated_templates[$realname]; $path = RCUBE_INSTALL_PATH . "{$skin_path}/templates/{$dname}.html"; if (is_readable($path)) { rcube::raise_error([ 'code' => 502, '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; } $path = false; } // read template file if (!$path || ($templ = @file_get_contents($path)) === false) { rcube::raise_error([ 'code' => 404, '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( ['/\bplugins\//', '/(["\']?)\/this\//'], [$this->app->plugins->url, '\1' . $this->app->plugins->url . $skin_dir . '/'], $templ ); } // parse for special tags $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', [ '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 = []; // these should be always on top, // e.g. hide_message() below depends on env.framed if (!$this->framed && !empty($this->js_env)) { $top_commands[] = ['set_env', $this->js_env]; } if (!empty($this->js_labels)) { $top_commands[] = ['add_label', $this->js_labels]; } // unlock interface after iframe load $unlock = isset($_REQUEST['_unlock']) ? preg_replace('/[^a-z0-9]/i', '', $_REQUEST['_unlock']) : 0; if ($this->framed) { $top_commands[] = ['iframe_loaded', $unlock]; } elseif ($unlock) { $top_commands[] = ['hide_message', $unlock]; } $commands = array_merge($top_commands, $this->js_commands); foreach ($commands as $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 (isset($str[0]) && $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 */ #[Override] public function raise_error($code, $message) { $args = [ 'code' => $code, 'message' => $message, ]; $page = new rcmail_action_utils_error(); $page->run($args); } /** * 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 assets are on a different domain if ($abs_url) { $path = $this->abs_url($path, true); } if (!$this->assets_path || in_array($path[0], ['?', '/', '.']) || strpos($path, '://')) { return $this->resource_location($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_\-]+)/', [$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) { $regexp = '!(src|href|background|data-src-[a-z]+)=(["\']?)([a-z0-9/_.?=-]+)(["\'\s>])!i'; return preg_replace_callback($regexp, [$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); } $file = $this->resource_location($file); return $matches[1] . '=' . $matches[2] . $file . $matches[4]; } /** * Correct paths of asset files according to assets_path */ protected function fix_assets_paths($output) { $regexp = '!(src|href|background)=(["\']?)([a-z0-9/_.?=-]+)(["\'\s>])!i'; return preg_replace_callback($regexp, [$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]); $file = $this->resource_location($file); 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; } /** * Modify resource file location to be passed via the static.php end-point. */ protected function resource_location($location) { if (strpos($location, '://') === false) { $location = ltrim($location, '/'); $prefix = ''; // FIXME: Would REQUEST_URI be a better option than PATH_INFO? if (!empty($_SERVER['PATH_INFO'])) { $path = explode('/', trim($_SERVER['PATH_INFO'], '/')); $prefix = str_repeat('../', count($path) + 1); } if (!str_starts_with($location, $prefix . 'static.php') && !str_starts_with($location, 'data:') && !str_ends_with($location, '.php') ) { $location = $prefix . 'static.php/' . $location; } } return $location; } /** * Public wrapper to dip into template parsing. * * @param string $input Template content * * @return string */ 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) { $regexp1 = '/]+)>/is'; $regexp2 = '/]*)>/is'; $pos = 0; // Find IF tags and process them while ($pos < strlen($input) && preg_match($regexp1, $input, $conditions, \PREG_OFFSET_CAPTURE, $pos)) { $pos = $start = $conditions[0][1]; // Process the 'condition' attribute $attrib = html::parse_attrib_string($conditions[1][0]); $condmet = isset($attrib['condition']) && $this->check_condition($attrib['condition']); // Define start/end position of the content to pass into the output $content_start = $condmet ? $pos + strlen($conditions[0][0]) : null; $content_end = null; $level = 0; $endif = null; $n = $pos + 1; // Process the code until the closing tag (for the processed IF tag) while (preg_match($regexp2, $input, $matches, \PREG_OFFSET_CAPTURE, $n)) { $tag_start = $matches[0][1]; $tag_end = $tag_start + strlen($matches[0][0]); $tag_name = strtolower($matches[1][0]); switch ($tag_name) { case 'if': $level++; break; case 'endif': if (!$level--) { $endif = $tag_end; if ($content_end === null) { $content_end = $tag_start; } break 2; } break; case 'elseif': if (!$level) { if ($condmet) { if ($content_end === null) { $content_end = $tag_start; } } else { // Process the 'condition' attribute $attrib = html::parse_attrib_string($matches[2][0]); $condmet = isset($attrib['condition']) && $this->check_condition($attrib['condition']); if ($condmet) { $content_start = $tag_end; } } } break; case 'else': if (!$level) { if ($condmet) { if ($content_end === null) { $content_end = $tag_start; } } else { $content_start = $tag_end; } } break; } $n = $tag_end; } // No ending tag found if ($endif === null) { $pos = strlen($input); if ($content_end === null) { $content_end = $pos; } } if ($content_start === null) { $content = ''; } else { $content = substr($input, $content_start, $content_end - $content_start); } // Replace the whole IF statement with the output content $input = substr_replace($input, $content, $start, max($endif, $content_end, $pos) - $start); $pos = $start; } return $input; } /** * Determines if a given condition is met * * @param string $condition Condition statement * * @return bool 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 (!empty($attrib['method']) && strtolower($attrib['method']) == 'post') { $hidden = new html_hiddenfield(['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( [ '/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', ], [ "(\$_SESSION['\\1'] ?? null)", "\$this->app->config->get('\\1',rcube_utils::get_boolean('\\3'))", "(\$this->env['\\1'] ?? null)", "rcube_utils::get_input_value('\\1', rcube_utils::INPUT_GPC)", "(\$_COOKIE['\\1'] ?? null)", "(\$this->browser->{'\\1'} ?? null)", "'{$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] ?? null; break; case 'config': $value = $this->config->get($name); if (is_array($value) && !empty($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) { $regexp = '/]|\\\>)+)(?/Ui'; return preg_replace_callback($regexp, [$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 (!empty($attrib['name']) || !empty($attrib['command'])) { return $this->button($attrib); } break; // frame (<< reindent once https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues/7179 is fixed) case 'frame': return $this->frame($attrib); // show a label (<< reindent once https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues/7179 is fixed) case 'label': if (!empty($attrib['expression'])) { $attrib['name'] = $this->eval_expression($attrib['expression']); } if (!empty($attrib['name']) || !empty($attrib['command'])) { $vars = $attrib + ['product' => $this->config->get('product_name')]; unset($vars['name'], $vars['command']); $label = $this->app->gettext($attrib + ['vars' => $vars]); $quoting = null; if (!empty($attrib['quoting'])) { $quoting = strtolower($attrib['quoting']); } elseif (isset($attrib['html'])) { $quoting = rcube_utils::get_boolean((string) $attrib['html']) ? 'no' : ''; } // 'noshow' can be used in skins to define new labels if (!empty($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 (<< reindent once https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues/7179 is fixed) case 'include': if (!empty($attrib['condition']) && !$this->check_condition($attrib['condition'])) { break; } if ($attrib['file'][0] != '/') { $attrib['file'] = '/templates/' . $attrib['file']; } $old_base_path = $this->base_path; $include = ''; $attr_skin_path = !empty($attrib['skinpath']) ? $attrib['skinpath'] : null; if (!empty($attrib['skin_path'])) { $attr_skin_path = $attrib['skin_path']; } if ($path = $this->get_skin_file($attrib['file'], $skin_path, $attr_skin_path)) { // 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 + ['content' => '']); return $hook['content']; // define a container block (<< reindent once https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues/7179 is fixed) case 'container': if (!empty($attrib['name']) && !empty($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 + ['content' => '']); return $hook['content']; } break; // return code for a specific application object (<< reindent once https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues/7179 is fixed) case 'object': $object = strtolower($attrib['name']); $content = ''; $handler = null; // correct deprecated object names if (!empty($this->deprecated_template_objects[$object])) { $object = $this->deprecated_template_objects[$object]; } if (!empty($this->object_handlers[$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); } elseif ($object == 'doctype') { $content = html::doctype($attrib['value']); } elseif ($object == 'logo') { $attrib += ['alt' => $this->xml_command(['', 'object', 'name="productname"'])]; // 'type' attribute added in 1.4 was renamed 'logo-type' in 1.5 // check both for backwards compatibility $logo_type = !empty($attrib['logo-type']) ? $attrib['logo-type'] : null; $logo_match = !empty($attrib['logo-match']) ? $attrib['logo-match'] : null; if (!empty($attrib['type']) && empty($logo_type)) { $logo_type = $attrib['type']; } if (($template_logo = $this->get_template_logo($logo_type, $logo_match)) !== null) { $attrib['src'] = $template_logo; } if (($link = $this->get_template_logo('link')) !== null) { $attrib['onclick'] = "location.href='{$link}';"; $attrib['style'] = 'cursor:pointer;'; } $additional_logos = []; $logo_types = (array) $this->config->get('additional_logo_types'); foreach ($logo_types as $type) { if (($template_logo = $this->get_template_logo($type)) !== null) { $additional_logos[$type] = $this->asset_url($template_logo, true); } elseif (!empty($attrib['data-src-' . $type])) { $additional_logos[$type] = $this->asset_url($attrib['data-src-' . $type], true); } } if (!empty($additional_logos)) { $this->set_env('additional_logos', $additional_logos); } if (!empty($attrib['src'])) { $content = html::img($attrib); } } elseif ($object == 'productname') { $name = $this->config->get('product_name', 'Roundcube Webmail'); $content = html::quote($name); } elseif ($object == 'version') { $ver = (string) RCMAIL_VERSION; if (is_file(RCUBE_INSTALL_PATH . '.svn/entries')) { if (function_exists('shell_exec')) { if (preg_match('/Revision:\s(\d+)/', (string) @shell_exec('svn info'), $regs)) { $ver .= ' [SVN r' . $regs[1] . ']'; } } else { $ver .= ' [SVN]'; } } elseif (is_file(RCUBE_INSTALL_PATH . '.git/index')) { if (function_exists('shell_exec')) { if (preg_match('/Date:\s+([^\n]+)/', (string) @shell_exec('git log -1'), $regs)) { if ($date = date('Ymd.Hi', strtotime($regs[1]))) { $ver .= ' [GIT ' . $date . ']'; } } } else { $ver .= ' [GIT]'; } } $content = html::quote($ver); } elseif ($object == 'steptitle') { $content = html::quote($this->get_pagetitle(false)); } elseif ($object == 'pagetitle') { // Deprecated, will be added automatically $content = html::quote($this->get_pagetitle()); } elseif ($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); } elseif ($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 // - ['key' => 'val'] // - [string, string] // - [[], string] // - [['key' => 'val'], ['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 ? [$vars] : $vars) : [$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 : [$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; } elseif ($href = $this->config->get('favicon', '/images/favicon.ico')) { $args[$param] = $href; } } $content .= html::tag($tag, [$key => $name, 'nl' => true] + $args); } } } // exec plugin hooks for this template object $hook = $this->app->plugins->exec_hook("template_object_{$object}", $attrib + ['content' => (string) $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 (<< reindent once https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues/7179 is fixed) 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 (<< reindent once https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues/7179 is fixed) case 'exp': return html::quote($this->eval_expression($attrib['expression'])); // return variable (<< reindent once https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues/7179 is fixed) 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 (str_starts_with($key, 'data-label-')) { // 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, (string) $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', [$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; // these commands can be called directly via url $a_static_commands = ['compose', 'list', 'preferences', 'folders', 'identities']; if (empty($attrib['command']) && empty($attrib['name']) && empty($attrib['href'])) { return ''; } $command = !empty($attrib['command']) ? $attrib['command'] : null; $action = $command ?: (!empty($attrib['name']) ? $attrib['name'] : null); if (!empty($attrib['task'])) { $command = $attrib['task'] . '.' . $command; $element = $attrib['task'] . '.' . $action; } else { $element = (!empty($this->env['task']) ? $this->env['task'] . '.' : '') . $action; } $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 ''; } // try to find out the button type if (!empty($attrib['type'])) { $attrib['type'] = strtolower($attrib['type']); if (strpos($attrib['type'], '-menuitem')) { $attrib['type'] = substr($attrib['type'], 0, -9); $menuitem = true; } } elseif (!empty($attrib['image']) || !empty($attrib['imagepas']) || !empty($attrib['imageact'])) { $attrib['type'] = 'image'; } else { $attrib['type'] = 'button'; } if (empty($attrib['image'])) { if (!empty($attrib['imagepas'])) { $attrib['image'] = $attrib['imagepas']; } elseif (!empty($attrib['imageact'])) { $attrib['image'] = $attrib['imageact']; } } if (empty($attrib['id'])) { // ensure auto generated IDs are unique between main window and content frame // Elastic skin duplicates buttons between the two on smaller screens (#7618) $prefix = ($this->framed || !empty($this->env['framed'])) ? 'frm' : ''; $attrib['id'] = sprintf('rcmbtn%s%d', $prefix, $s_button_count++); } // get localized text for labels and titles $domain = !empty($attrib['domain']) ? $attrib['domain'] : null; if (!empty($attrib['title'])) { $attrib['title'] = html::quote($this->app->gettext($attrib['title'], $domain)); } if (!empty($attrib['label'])) { $attrib['label'] = html::quote($this->app->gettext($attrib['label'], $domain)); } if (!empty($attrib['alt'])) { $attrib['alt'] = html::quote($this->app->gettext($attrib['alt'], $domain)); } // set accessibility attributes if (empty($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 && empty($attrib['title']) && !empty($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 (!empty($attrib['command'])) { $this->add_script(sprintf( "%s.register_button('%s', '%s', '%s', '%s', '%s', '%s');", self::JS_OBJECT_NAME, $command, $attrib['id'], $attrib['type'], !empty($attrib['imageact']) ? $this->abs_url($attrib['imageact']) : (!empty($attrib['classact']) ? $attrib['classact'] : ''), !empty($attrib['imagesel']) ? $this->abs_url($attrib['imagesel']) : (!empty($attrib['classsel']) ? $attrib['classsel'] : ''), !empty($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(['task' => $attrib['command']]); $attrib['onclick'] = sprintf("return %s.command('switch-task','%s',this,event)", self::JS_OBJECT_NAME, $attrib['command']); } elseif (!empty($attrib['task']) && in_array($attrib['task'], rcmail::$main_tasks)) { $attrib['href'] = $this->app->url(['action' => $attrib['command'], 'task' => $attrib['task']]); } elseif (in_array($attrib['command'], $a_static_commands)) { $attrib['href'] = $this->app->url(['action' => $attrib['command']]); } elseif (($attrib['command'] == 'permaurl' || $attrib['command'] == 'extwin') && !empty($this->env['permaurl'])) { $attrib['href'] = $this->env['permaurl']; } } // overwrite attributes if (empty($attrib['href'])) { $attrib['href'] = '#'; } if (!empty($attrib['task'])) { if (!empty($attrib['classact'])) { $attrib['class'] = $attrib['classact']; } } elseif ($command && empty($attrib['onclick'])) { $attrib['onclick'] = sprintf( "return %s.command('%s','%s',this,event)", self::JS_OBJECT_NAME, $command, !empty($attrib['prop']) ? $attrib['prop'] : '' ); } $out = ''; $btn_content = null; $link_attrib = []; // generate image tag if ($attrib['type'] == 'image') { $attrib_str = html::attrib_string( $attrib, [ '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 (!empty($attrib['label'])) { $btn_content .= ' ' . $attrib['label']; } $link_attrib = ['href', 'onclick', 'onmouseover', 'onmouseout', 'onmousedown', 'onmouseup', 'target']; } elseif ($attrib['type'] == 'link') { $btn_content = $attrib['content'] ?? (!empty($attrib['label']) ? $attrib['label'] : $attrib['command']); $link_attrib = array_merge(html::$common_attrib, ['href', 'onclick', 'tabindex', 'target', 'rel']); if (!empty($attrib['innerclass'])) { $btn_content = html::span($attrib['innerclass'], $btn_content); } } elseif ($attrib['type'] == 'input') { $attrib['type'] = 'button'; if (!empty($attrib['label'])) { $attrib['value'] = $attrib['label']; } if (!empty($attrib['command'])) { $attrib['disabled'] = 'disabled'; } $out = html::tag('input', $attrib, null, ['type', 'value', 'onclick', 'id', 'class', 'style', 'tabindex', 'disabled']); } else { if (!empty($attrib['label'])) { $attrib['value'] = $attrib['label']; } if (!empty($attrib['command'])) { $attrib['disabled'] = 'disabled'; } $content = $attrib['content'] ?? $attrib['label']; $out = html::tag('button', $attrib, $content, ['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 (!empty($attrib['wrapper'])) { $out = html::tag($attrib['wrapper'], null, $out); } if (!empty($menuitem)) { $class = !empty($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 (!isset($this->script_files[$position]) || !is_array($this->script_files[$position])) { $this->script_files[$position] = []; } 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 before </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 = static function ($output, $script) { return $output . html::script($script); }; $merge_scripts = static function ($output, $script) { return $output . html::script([], $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()) { $this->header('Content-Language: ' . $lang); } } // include meta tag with charset if (!empty($this->charset)) { if (!headers_sent()) { $this->header('Content-Type: text/html; charset=' . $this->charset); } $meta .= html::tag('meta', [ '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 = (string) preg_replace('/(]*>)\n*/i', "\\1\n{$meta}", $output, 1, $count); if (!$count) { $page_header .= $meta; } // include scripts into header/footer if (!empty($this->script_files['head'])) { $page_header .= array_reduce((array) $this->script_files['head'], $merge_script_files); } $head = $this->scripts['head_top'] ?? ''; $head .= $this->scripts['head'] ?? ''; $page_header .= array_reduce((array) $head, $merge_scripts); $page_header .= $this->header . "\n"; if (!empty($this->script_files['head_bottom'])) { $page_header .= array_reduce((array) $this->script_files['head_bottom'], $merge_script_files); } if (!empty($this->script_files['foot'])) { $page_footer .= array_reduce((array) $this->script_files['foot'], $merge_script_files); } $page_footer .= $this->footer . "\n"; if (!empty($this->scripts['foot'])) { $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, '