| +-----------------------------------------------------------------------+ */ /** * 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 = '/* * @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('/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 .= '
\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, '