<?php namespace CodeIgniter\View; /** * CodeIgniter * * An open source application development framework for PHP * * This content is released under the MIT License (MIT) * * Copyright (c) 2014-2019 British Columbia Institute of Technology * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * * @package CodeIgniter * @author CodeIgniter Dev Team * @copyright 2014-2019 British Columbia Institute of Technology (https://bcit.ca/) * @license https://opensource.org/licenses/MIT MIT License * @link https://codeigniter.com * @since Version 3.0.0 * @filesource */ use CodeIgniter\Log\Logger; use CodeIgniter\View\Exceptions\ViewException; /** * Class Parser * * ClassFormerlyKnownAsTemplateParser * * @package CodeIgniter\View */ class Parser extends View { /** * Left delimiter character for pseudo vars * * @var string */ public $leftDelimiter = '{'; /** * Right delimiter character for pseudo vars * * @var string */ public $rightDelimiter = '}'; /** * Stores extracted noparse blocks. * * @var array */ protected $noparseBlocks = []; /** * Stores any plugins registered at run-time. * * @var array */ protected $plugins = []; /** * Stores the context for each data element * when set by `setData` so the context is respected. * * @var array */ protected $dataContexts = []; //-------------------------------------------------------------------- /** * Constructor * * @param \Config\View $config * @param string $viewPath * @param mixed $loader * @param boolean $debug * @param Logger $logger */ public function __construct($config, string $viewPath = null, $loader = null, bool $debug = null, Logger $logger = null) { // Ensure user plugins override core plugins. $this->plugins = $config->plugins ?? []; parent::__construct($config, $viewPath, $loader, $debug, $logger); } //-------------------------------------------------------------------- /** * Parse a template * * Parses pseudo-variables contained in the specified template view, * replacing them with any data that has already been set. * * @param string $view * @param array $options * @param boolean $saveData * * @return string */ public function render(string $view, array $options = null, $saveData = null): string { $start = microtime(true); if (is_null($saveData)) { $saveData = $this->config->saveData; } $view = str_replace('.php', '', $view); // Was it cached? if (isset($options['cache'])) { $cacheName = $options['cache_name'] ?: $view; if ($output = cache($cacheName)) { $this->logPerformance($start, microtime(true), $view); return $output; } } $view = $view . '.php'; $file = $this->viewPath . $view; if (! is_file($file)) { $file = $this->loader->locateFile($view, 'Views'); } // locateFile will return an empty string if the file cannot be found. if (empty($file)) { throw ViewException::forInvalidFile($file); } $template = file_get_contents($file); $output = $this->parse($template, $this->data, $options); $this->logPerformance($start, microtime(true), $view); if (! $saveData) { $this->data = []; } // Should we cache? if (isset($options['cache'])) { cache()->save($cacheName, $output, (int) $options['cache']); } return $output; } //-------------------------------------------------------------------- /** * Parse a String * * Parses pseudo-variables contained in the specified string, * replacing them with any data that has already been set. * * @param string $template * @param array $options * @param boolean $saveData * * @return string */ public function renderString(string $template, array $options = null, $saveData = null): string { $start = microtime(true); if (is_null($saveData)) { $saveData = $this->config->saveData; } $output = $this->parse($template, $this->data, $options); $this->logPerformance($start, microtime(true), $this->excerpt($template)); if (! $saveData) { $this->data = []; } return $output; } //-------------------------------------------------------------------- /** * Sets several pieces of view data at once. * In the Parser, we need to store the context here * so that the variable is correctly handled within the * parsing itself, and contexts (including raw) are respected. * * @param array $data * @param string $context The context to escape it for: html, css, js, url, raw * If 'raw', no escaping will happen * * @return RendererInterface */ public function setData(array $data = [], string $context = null): RendererInterface { if (! empty($context)) { foreach ($data as $key => &$value) { if (is_array($value)) { foreach ($value as &$obj) { $obj = $this->objectToArray($obj); } } else { $value = $this->objectToArray($value); } $this->dataContexts[$key] = $context; } } $this->data = array_merge($this->data, $data); return $this; } //-------------------------------------------------------------------- /** * Parse a template * * Parses pseudo-variables contained in the specified template, * replacing them with the data in the second param * * @param string $template * @param array $data * @param array $options Future options * @return string */ protected function parse(string $template, array $data = [], array $options = null): string { if ($template === '') { return ''; } // Remove any possible PHP tags since we don't support it // and parseConditionals needs it clean anyway... $template = str_replace(['<?', '?>'], ['<?', '?>'], $template); $template = $this->parseComments($template); $template = $this->extractNoparse($template); // Replace any conditional code here so we don't have to parse as much $template = $this->parseConditionals($template); // Handle any plugins before normal data, so that // it can potentially modify any template between its tags. $template = $this->parsePlugins($template); // loop over the data variables, replacing // the content as we go. foreach ($data as $key => $val) { $escape = true; if (is_array($val)) { $escape = false; $replace = $this->parsePair($key, $val, $template); } else { $replace = $this->parseSingle($key, (string) $val); } foreach ($replace as $pattern => $content) { $template = $this->replaceSingle($pattern, $content, $template, $escape); } } $template = $this->insertNoparse($template); return $template; } //-------------------------------------------------------------------- /** * Parse a single key/value, extracting it * * @param string $key * @param string $val * @return array */ protected function parseSingle(string $key, string $val): array { $pattern = '#' . $this->leftDelimiter . '!?\s*' . preg_quote($key) . '\s*\|*\s*([|a-zA-Z0-9<>=\(\),:_\-\s\+]+)*\s*!?' . $this->rightDelimiter . '#ms'; return [$pattern => $val]; } //-------------------------------------------------------------------- /** * Parse a tag pair * * Parses tag pairs: {some_tag} string... {/some_tag} * * @param string $variable * @param array $data * @param string $template * @return array */ protected function parsePair(string $variable, array $data, string $template): array { // Holds the replacement patterns and contents // that will be used within a preg_replace in parse() $replace = []; // Find all matches of space-flexible versions of {tag}{/tag} so we // have something to loop over. preg_match_all( '#' . $this->leftDelimiter . '\s*' . preg_quote($variable) . '\s*' . $this->rightDelimiter . '(.+?)' . $this->leftDelimiter . '\s*' . '/' . preg_quote($variable) . '\s*' . $this->rightDelimiter . '#s', $template, $matches, PREG_SET_ORDER ); /* * Each match looks like: * * $match[0] {tag}...{/tag} * $match[1] Contents inside the tag */ foreach ($matches as $match) { // Loop over each piece of $data, replacing // it's contents so that we know what to replace in parse() $str = ''; // holds the new contents for this tag pair. foreach ($data as $row) { // Objects that have a `toArray()` method should be // converted with that method (i.e. Entities) if (is_object($row) && method_exists($row, 'toArray')) { $row = $row->toArray(); } // Otherwise, cast as an array and it will grab public properties. else if (is_object($row)) { $row = (array)$row; } $temp = []; $pairs = []; $out = $match[1]; foreach ($row as $key => $val) { // For nested data, send us back through this method... if (is_array($val)) { $pair = $this->parsePair($key, $val, $match[1]); if (! empty($pair)) { $pairs[array_keys( $pair )[0]] = true; $temp = array_merge($temp, $pair); } continue; } else if (is_object($val)) { $val = 'Class: ' . get_class($val); } else if (is_resource($val)) { $val = 'Resource'; } $temp['#' . $this->leftDelimiter . '!?\s*' . preg_quote($key) . '\s*\|*\s*([|\w<>=\(\),:_\-\s\+]+)*\s*!?' . $this->rightDelimiter . '#s'] = $val; } // Now replace our placeholders with the new content. foreach ($temp as $pattern => $content) { $out = $this->replaceSingle($pattern, $content, $out, ! isset( $pairs[$pattern] ) ); } $str .= $out; } $replace['#' . $match[0] . '#s'] = $str; } return $replace; } //-------------------------------------------------------------------- /** * Removes any comments from the file. Comments are wrapped in {# #} symbols: * * {# This is a comment #} * * @param string $template * * @return string */ protected function parseComments(string $template): string { return preg_replace('/\{#.*?#\}/s', '', $template); } //-------------------------------------------------------------------- /** * Extracts noparse blocks, inserting a hash in its place so that * those blocks of the page are not touched by parsing. * * @param string $template * * @return string */ protected function extractNoparse(string $template): string { $pattern = '/\{\s*noparse\s*\}(.*?)\{\s*\/noparse\s*\}/ms'; /* * $matches[][0] is the raw match * $matches[][1] is the contents */ if (preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { // Create a hash of the contents to insert in its place. $hash = md5($match[1]); $this->noparseBlocks[$hash] = $match[1]; $template = str_replace($match[0], "noparse_{$hash}", $template); } } return $template; } //-------------------------------------------------------------------- /** * Re-inserts the noparsed contents back into the template. * * @param string $template * * @return string */ public function insertNoparse(string $template): string { foreach ($this->noparseBlocks as $hash => $replace) { $template = str_replace("noparse_{$hash}", $replace, $template); unset($this->noparseBlocks[$hash]); } return $template; } //-------------------------------------------------------------------- /** * Parses any conditionals in the code, removing blocks that don't * pass so we don't try to parse it later. * * Valid conditionals: * - if * - elseif * - else * * @param string $template * * @return string */ protected function parseConditionals(string $template): string { $pattern = '/\{\s*(if|elseif)\s*((?:\()?(.*?)(?:\))?)\s*\}/ms'; /** * For each match: * [0] = raw match `{if var}` * [1] = conditional `if` * [2] = condition `do === true` * [3] = same as [2] */ preg_match_all($pattern, $template, $matches, PREG_SET_ORDER); foreach ($matches as $match) { // Build the string to replace the `if` statement with. $condition = $match[2]; $statement = $match[1] === 'elseif' ? '<?php elseif (' . $condition . '): ?>' : '<?php if (' . $condition . '): ?>'; $template = str_replace($match[0], $statement, $template); } $template = preg_replace('/\{\s*else\s*\}/ms', '<?php else: ?>', $template); $template = preg_replace('/\{\s*endif\s*\}/ms', '<?php endif; ?>', $template); // Parse the PHP itself, or insert an error so they can debug ob_start(); extract($this->data); try { $result = eval('?>' . $template . '<?php '); } catch (\ParseError $e) { ob_end_clean(); throw ViewException::forTagSyntaxError(str_replace(['?>', '<?php '], '', $template)); } return ob_get_clean(); } //-------------------------------------------------------------------- /** * Over-ride the substitution field delimiters. * * @param string $leftDelimiter * @param string $rightDelimiter * @return RendererInterface */ public function setDelimiters($leftDelimiter = '{', $rightDelimiter = '}'): RendererInterface { $this->leftDelimiter = $leftDelimiter; $this->rightDelimiter = $rightDelimiter; return $this; } //-------------------------------------------------------------------- /** * Handles replacing a pseudo-variable with the actual content. Will double-check * for escaping brackets. * * @param $pattern * @param $content * @param $template * @param boolean $escape * * @return string */ protected function replaceSingle($pattern, $content, $template, bool $escape = false): string { // Any dollar signs in the pattern will be mis-interpreted, so slash them $pattern = addcslashes($pattern, '$'); // Replace the content in the template $template = preg_replace_callback($pattern, function ($matches) use ($content, $escape) { // Check for {! !} syntax to not-escape this one. if (strpos($matches[0], '{!') === 0 && substr($matches[0], -2) === '!}') { $escape = false; } return $this->prepareReplacement($matches, $content, $escape); }, $template); return $template; } //-------------------------------------------------------------------- /** * Callback used during parse() to apply any filters to the value. * * @param array $matches * @param string $replace * @param boolean $escape * * @return string */ protected function prepareReplacement(array $matches, string $replace, bool $escape = true) { $orig = array_shift($matches); // Our regex earlier will leave all chained values on a single line // so we need to break them apart so we can apply them all. $filters = isset($matches[0]) ? explode('|', $matches[0]) : []; if ($escape && ! isset($matches[0])) { if ($context = $this->shouldAddEscaping($orig)) { $filters[] = "esc({$context})"; } } $replace = $this->applyFilters($replace, $filters); return $replace; } //-------------------------------------------------------------------- /** * Checks the placeholder the view provided to see if we need to provide any autoescaping. * * @param string $key * * @return false|html */ public function shouldAddEscaping(string $key) { $escape = false; $key = trim(str_replace(['{', '}'], '', $key)); // If the key has a context stored (from setData) // we need to respect that. if (array_key_exists($key, $this->dataContexts)) { if ($this->dataContexts[$key] !== 'raw') { return $this->dataContexts[$key]; } } // No pipes, then we know we need to escape elseif (strpos($key, '|') === false) { $escape = 'html'; } // If there's a `noescape` then we're definitely false. elseif (strpos($key, 'noescape') !== false) { $escape = false; } // If no `esc` filter is found, then we'll need to add one. elseif (! preg_match('/\s+esc/', $key)) { $escape = 'html'; } return $escape; } //-------------------------------------------------------------------- /** * Given a set of filters, will apply each of the filters in turn * to $replace, and return the modified string. * * @param string $replace * @param array $filters * * @return string */ protected function applyFilters(string $replace, array $filters): string { // Determine the requested filters foreach ($filters as $filter) { // Grab any parameter we might need to send preg_match('/\([a-zA-Z0-9\-:_ +,<>=]+\)/', $filter, $param); // Remove the () and spaces to we have just the parameter left $param = ! empty($param) ? trim($param[0], '() ') : null; // Params can be separated by commas to allow multiple parameters for the filter if (! empty($param)) { $param = explode(',', $param); // Clean it up foreach ($param as &$p) { $p = trim($p, ' "'); } } else { $param = []; } // Get our filter name $filter = ! empty($param) ? trim(strtolower(substr($filter, 0, strpos($filter, '(')))) : trim($filter); if (! array_key_exists($filter, $this->config->filters)) { continue; } // Filter it.... $replace = $this->config->filters[$filter]($replace, ...$param); } return $replace; } //-------------------------------------------------------------------- // Plugins //-------------------------------------------------------------------- /** * Scans the template for any parser plugins, and attempts to execute them. * Plugins are notated based on {+ +} opening and closing braces. * * When encountered, * * @param string $template * * @return string */ protected function parsePlugins(string $template) { foreach ($this->plugins as $plugin => $callable) { // Paired tags are enclosed in an array in the config array. $isPair = is_array($callable); $callable = $isPair ? array_shift($callable) : $callable; $pattern = $isPair ? '#{\+\s*' . $plugin . '([\w\d=-_:\+\s()/\"@.]*)?\s*\+}(.+?){\+\s*/' . $plugin . '\s*\+}#ims' : '#{\+\s*' . $plugin . '([\w\d=-_:\+\s()/\"@.]*)?\s*\+}#ims'; /** * Match tag pairs * * Each match is an array: * $matches[0] = entire matched string * $matches[1] = all parameters string in opening tag * $matches[2] = content between the tags to send to the plugin. */ preg_match_all($pattern, $template, $matches, PREG_SET_ORDER); if (empty($matches)) { continue; } foreach ($matches as $match) { $params = []; // Split on "words", but keep quoted groups together, accounting for escaped quotes. // Note: requires double quotes, not single quotes. $parts = str_getcsv($match[1], ' '); foreach ($parts as $part) { if (empty($part)) { continue; } if (strpos($part, '=') !== false) { list($a, $b) = explode('=', $part); $params[$a] = $b; } else { $params[] = $part; } } unset($parts); $template = $isPair ? str_replace($match[0], $callable($match[2], $params), $template) : str_replace($match[0], $callable($params), $template); } } return $template; } /** * Makes a new plugin available during the parsing of the template. * * @param string $alias * @param callable $callback * * @param boolean $isPair * * @return $this */ public function addPlugin(string $alias, callable $callback, bool $isPair = false) { $this->plugins[$alias] = $isPair ? [$callback] : $callback; return $this; } //-------------------------------------------------------------------- /** * Removes a plugin from the available plugins. * * @param string $alias * * @return $this */ public function removePlugin(string $alias) { unset($this->plugins[$alias]); return $this; } /** * Converts an object to an array, respecting any * toArray() methods on an object. * * @param $value * * @return mixed */ protected function objectToArray($value) { // Objects that have a `toArray()` method should be // converted with that method (i.e. Entities) if (is_object($value) && method_exists($value, 'toArray')) { $value = $value->toArray(); } // Otherwise, cast as an array and it will grab public properties. else if (is_object($value)) { $value = (array)$value; } return $value; } //-------------------------------------------------------------------- }