_initConfig(); // Set user and current request information. $this->user = Session::getMemberInfo() ?: new Helpers\SessionHelper(); $this->request = \Context::getCurrentRequest(); // Populate static properties for optimization. if (self::$_mtime === null) { self::$_mtime = filemtime(__FILE__); } if (self::$_delay_compile === null) { self::$_delay_compile = config('view.delay_compile') ?? 0; } if (self::$_json_options === null) { self::$_json_options = \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_APOS | \JSON_HEX_QUOT | \JSON_UNESCAPED_UNICODE; } if (self::$_json_options2 === null) { self::$_json_options2 = \JSON_HEX_TAG | \JSON_HEX_QUOT | \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES; } // If paths were provided, initialize immediately. if ($dirname !== null && $filename !== null) { $this->_setSourcePath($dirname, $filename, $extension ?? 'auto'); } } /** * Initialize the configuration object. * * @return void */ protected function _initConfig(): void { $this->config = new \stdClass; $this->config->version = 1; $this->config->autoescape = false; $this->config->context = 'HTML'; } /** * Initialize and normalize paths. * * @param string $dirname * @param string $filename * @param string $extension * @return void */ protected function _setSourcePath(string $dirname, string $filename, string $extension = 'auto'): void { // Normalize the template path. Result will look like 'modules/foo/views/' $dirname = trim(preg_replace('@^(' . preg_quote(\RX_BASEDIR, '@') . '|\./)@', '', strtr($dirname, ['\\' => '/', '//' => '/'])), '/') . '/'; $dirname = preg_replace('/[\{\}\(\)\[\]<>\$\'"]/', '', $dirname); $this->absolute_dirname = \RX_BASEDIR . $dirname; $this->relative_dirname = $dirname; // Normalize the filename. Result will look like 'bar/example.html' $filename = trim(strtr($filename, ['\\' => '/', '//' => '/']), '/'); $filename = preg_replace('/[\{\}\(\)\[\]<>\$\'"]/', '', $filename); // If the filename doesn't have a typical extension and doesn't exist, try adding common extensions. if (!preg_match('/\.(?:html?|php)$/', $filename) && !Storage::isFile($this->absolute_dirname . $filename)) { if ($extension !== 'auto') { $filename .= '.' . $extension; $this->extension = $extension; } elseif (Storage::isFile($this->absolute_dirname . $filename . '.html')) { $filename .= '.html'; $this->extension = 'html'; $this->exists = true; } elseif (Storage::isFile($this->absolute_dirname . $filename . '.blade.php')) { $filename .= '.blade.php'; $this->extension = 'blade.php'; $this->exists = true; } else { $filename .= '.html'; $this->extension = 'html'; } } // Set the remainder of properties. $this->filename = $filename; $this->absolute_path = $this->absolute_dirname . $filename; $this->relative_path = $this->relative_dirname . $filename; if ($this->extension === null) { $this->extension = preg_match('/\.(blade\.php|[a-z]+)$/i', $filename, $m) ? $m[1] : ''; } if ($this->exists === null) { $this->exists = Storage::isFile($this->absolute_path); } if ($this->exists && $this->extension === 'blade.php') { $this->config->version = 2; $this->config->autoescape = true; } if (preg_match('!^(addons|common|(?:m\.)?layouts|modules|plugins|themes|widgets|widgetstyles)/(\w+)!', $this->relative_dirname, $match)) { $this->source_type = $match[1]; $this->source_name = $match[2]; } $this->path = $this->absolute_dirname; $this->web_path = \RX_BASEURL . $this->relative_dirname; $this->setCachePath(); } /** * Set the path for the cache file. * * @param ?string $cache_path * @return void */ public function setCachePath(?string $cache_path = null) { $clean_path = str_replace('../', '__parentdir/', $this->relative_path); $this->cache_path = $cache_path ?? (\RX_BASEDIR . 'files/cache/template/' . $clean_path . '.compiled.php'); if ($this->exists) { Debug::addFilenameAlias($this->absolute_path, $this->cache_path); } } /** * Disable caching. * * @return void */ public function disableCache(): void { $this->cache_enabled = false; } /** * Check if the template file exists. * * @return bool */ public function exists(): bool { return $this->exists ? true : false; } /** * Get the parent template. * * @return ?self */ public function getParent(): ?self { return $this->parent; } /** * Set the parent template. * * @param ?self $parent * @return void */ public function setParent(self $parent): void { $this->parent = $parent; } /** * Get vars. * * @return ?object */ public function getVars(): ?object { return $this->vars; } /** * Set vars. * * @param array|object $vars * @return void */ public function setVars($vars): void { if (is_array($vars)) { $this->vars = (object)$vars; } elseif (is_object($vars)) { $this->vars = clone $vars; } else { throw new Exception('Template vars must be an array or object'); } } /** * Add vars. * * @param array|object $vars * @return void */ public function addVars($vars): void { if (!isset($this->vars)) { $this->vars = new \stdClass; } foreach (is_object($vars) ? get_object_vars($vars) : $vars as $key => $val) { $this->vars->$key = $val; } } /** * Compile and execute a template file. * * You don't need to pass any paths if you have already supplied them * through the constructor. They exist for backward compatibility. * * $override_filename should be considered deprecated, as it is only * used in faceOff (layout source editor). * * @param ?string $dirname * @param ?string $filename * @param ?string $override_filename * @return string */ public function compile(?string $dirname = null, ?string $filename = null, ?string $override_filename = null) { // If paths are given, initialize now. if ($dirname && $filename) { $this->_initConfig(); $this->_setSourcePath($dirname, $filename); } if ($override_filename) { $override_filename = trim(preg_replace('@^' . preg_quote(\RX_BASEDIR, '@') . '|\./@', '', strtr($override_filename, ['\\' => '/', '//' => '/'])), '/'); $override_filename = preg_replace('/[\{\}\(\)\[\]<>\$\'"]/', '', $override_filename); $this->absolute_path = \RX_BASEDIR . $override_filename; $this->relative_path = $override_filename; $this->exists = Storage::exists($this->absolute_path); $this->setCachePath(); } // Return error if the source file does not exist. if (!$this->exists) { $error_message = sprintf('Template not found: %s', $this->relative_path); trigger_error($error_message, \E_USER_WARNING); return escape($error_message); } // Record the starting time. $start = microtime(true); // Find the latest mtime of the source template and the template parser. $filemtime = filemtime($this->absolute_path); if ($filemtime > time() - self::$_delay_compile) { $latest_mtime = self::$_mtime; } else { $latest_mtime = max($filemtime, self::$_mtime); } // If a cached result does not exist, or if it is stale, compile again. if (!Storage::exists($this->cache_path) || filemtime($this->cache_path) < $latest_mtime || !$this->cache_enabled) { $content = $this->parse(); if (!Storage::write($this->cache_path, $content)) { if (!Storage::write($this->cache_path, $content)) { throw new Exception('Cannot write template cache file: ' . $this->cache_path); } } } $output = $this->execute(); // Record the time elapsed. Debug::addTime('template', microtime(true) - $start); return $output; } /** * Compile a template and return the PHP code. * * @param string $dirname * @param string $filename * @return string */ public function compileDirect(string $dirname, string $filename): string { // Initialize paths. Return error if file does not exist. $this->_initConfig(); $this->_setSourcePath($dirname, $filename); if (!$this->exists) { $error_message = sprintf('Template not found: %s', $this->relative_path); trigger_error($error_message, \E_USER_WARNING); return escape($error_message); } // Parse the template, but don't actually execute it. return $this->parse(); } /** * Convert template code to PHP using a version-specific parser. * * Directly passing $content as a string is not available as an * official API. It only exists for unit testing. * * @return string */ public function parse(?string $content = null): string { // Read the source, or use the provided content. if ($content === null && $this->exists) { $content = Storage::read($this->absolute_path); $content = trim($content) . PHP_EOL; } if ($content === null || $content === '' || $content === PHP_EOL) { return ''; } // Remove UTF-8 BOM and convert CRLF to LF. $content = preg_replace(['/^\xEF\xBB\xBF/', '/\r\n/'], ['', "\n"], $content); // Check the config tag: or $content = preg_replace_callback('!(?<=^|\n)!', function($match) { $this->config->{$match[1]} = ($match[1] === 'version' ? intval($match[2]) : toBool($match[2])); return sprintf('config->%s = %s; ?>', $match[1], var_export($this->config->{$match[1]}, true)); }, $content); // Check the alternative version directive: @version(2) $content = preg_replace_callback('!(?<=^|\n)@version\s?\(([0-9]+)\)!', function($match) { $this->config->version = intval($match[1]); return sprintf('config->version = %s; ?>', var_export($this->config->version, true)); }, $content); // Call a version-specific parser to convert template code into PHP. $class_name = '\Rhymix\Framework\Parsers\Template\TemplateParser_v' . $this->config->version; $parser = new $class_name; $content = $parser->convert($content, $this); return $content; } /** * Execute the converted template and return the output. * * @return string */ public function execute(): string { // Import Context and lang as local variables. $__Context = $this->vars ?: \Context::getAll(); $__Context->tpl_path = './' . $this->relative_dirname; // Start the output buffer. $this->_ob_level = ob_get_level(); ob_start(); // Include the compiled template. include $this->cache_path; // Fetch the content of the output buffer until the buffer level is the same as before. $content = ''; while (ob_get_level() > $this->_ob_level) { $content .= ob_get_clean(); } // Insert comments for debugging. if(Debug::isEnabledForCurrentUser() && \Context::getResponseMethod() === 'HTML' && !preg_match('/^<(?:\!DOCTYPE|\?xml)/', $content)) { $meta = '' . "\n"; $content = sprintf($meta, 'Start') . $content . sprintf($meta, 'End'); } return $content; } /** * Get a fragment of the executed output. * * @param string $name * @return ?string */ public function getFragment(string $name): ?string { if (isset($this->_fragments[$name])) { return $this->_fragments[$name]; } else { return null; } } /** * Get the contents of a stack. * * @param string $name * @return ?array */ public function getStack(string $name): ?array { if (isset(self::$_stacks[$name])) { return self::$_stacks[$name]; } else { return null; } } /** * Check if a path should be treated as relative to the path of the current template. * * @param string $path * @return bool */ public function isRelativePath(string $path): bool { return !preg_match('#^((?:https?|file|data):|[\/\{<])#i', $path); } /** * Convert a relative path using the given basepath. * * @param string $path * @param ?string $basepath * @return string */ public function convertPath(string $path, ?string $basepath = null): string { // If basepath is not provided, use the relative dir of the current instance. if ($basepath === null) { $basepath = $this->relative_dirname; } // Path relative to the Rhymix installation directory? if (preg_match('#^\^/?(\w.+)$#s', $path, $match)) { $path = \RX_BASEURL . $match[1]; } // Other paths will be relative to the given basepath. else { $path = preg_replace('#/\./#', '/', $basepath . $path); } // Normalize and return the path. return $this->normalizePath($path); } /** * Normalize a path by removing extra slashes and parent directory references. * * @param string $path * @return string */ public function normalizePath(string $path): string { $path = preg_replace('#[\\\\/]+#', '/', $path); $path = preg_replace('#/\./#', '/', $path); while (($tmp = preg_replace('#(/|^)(?!\.\./)[^/]+/\.\.(/|$)#', '$1', $path)) !== $path) { $path = $tmp; } return $path; } /** * =================== HELPER FUNCTIONS FOR TEMPLATE v2 =================== */ /** * Include another template from v2 @include directive. * * Blade has several variations of the @include directive, and we need * access to the actual PHP args in order to process them accurately. * So we do this in the Template class, not in the converter. * * @param ...$args * @return string */ protected function _v2_include(...$args): string { // Set some basic information. $directive = $args[0]; $extension = $this->extension === 'blade.php' ? 'blade.php' : null; $isConditional = in_array($directive, ['includeWhen', 'includeUnless']); $basedir = $this->relative_dirname; $cond = $isConditional ? $args[1] : null; $path = $isConditional ? $args[2] : $args[1]; $vars = $isConditional ? ($args[3] ?? null) : ($args[2] ?? null); // If the conditions are not met, return. if ($isConditional && $directive === 'includeWhen' && !$cond) { return ''; } if ($isConditional && $directive === 'includeUnless' && $cond) { return ''; } // Handle paths relative to the Rhymix installation directory. if (preg_match('#^\^/?(\w.+)$#s', $path, $match)) { $basedir = str_contains($match[1], '/') ? dirname($match[1]) : \RX_BASEDIR; $path = basename($match[1]); } // Convert relative paths embedded in the filename. if (preg_match('#^(.+)/([^/]+)$#', $path, $match)) { $basedir = $this->normalizePath($basedir . $match[1] . '/'); $path = $match[2]; } // Create a new instance of TemplateHandler. $template = new self($basedir, $path, $extension); // If the directive is @includeIf and the template file does not exist, return. if ($directive === 'includeIf' && !$template->exists()) { return ''; } // Set variables. $template->setParent($this); if ($this->vars) { $template->setVars($this->vars); } if ($vars !== null) { $template->addVars($vars); } // Compile and return. return $template->compile(); } /** * Load a resource from v2 @load directive. * * The Blade-style syntax does not have named arguments, so we must rely * on the position and format of each argument to guess what it is for. * Fortunately, there are only a handful of valid options for the type, * media, and index attributes. * * @param string $path * @param string $media_type * @param int $index * @param array|object $vars * @return void */ protected function _v2_loadResource(string $path, $media_type = null, $index = null, $vars = null): void { // Assign the path. if (empty($path)) { trigger_error('Resource loading directive used with no path', \E_USER_WARNING); return; } // Check whether the path is an internal or external link. $external = false; if (preg_match('#^\^#', $path)) { $path = './' . ltrim($path, '^/'); } elseif ($this->isRelativePath($path)) { $path = $this->convertPath($path, './' . $this->relative_dirname); } else { $external = true; } // If any of the variables seems to be an array or object, it's $vars. if (!is_scalar($media_type ?? '')) { $vars = $media_type; $media_type = null; } if (!is_scalar($index ?? '')) { $vars = $index; $index = null; } if (ctype_digit($media_type ?? '')) { $index = $media_type; $media_type = null; } // Split the media type if it has a colon in it. if (preg_match('#^(css|js):(.+)$#s', $media_type ?? '', $match)) { $media_type = trim($match[2]); $type = $match[1]; } // Determine the type of resource. elseif (!$external && str_starts_with($path, './common/js/plugins/')) { $type = 'jsplugin'; } elseif (!$external && preg_match('#/lang(\.xml)?$#', $path)) { $type = 'lang'; } elseif (preg_match('#\.(css|js|scss|less)($|\?|/)#', $path, $match)) { $type = $match[1]; } elseif (preg_match('#/css\d?\?.+#', $path)) { $type = 'css'; } else { $type = 'unknown'; } // Load the resource. if ($type === 'jsplugin') { if (preg_match('#/common/js/plugins/([^/]+)#', $path, $match)) { $plugin_name = $match[1]; \Context::loadJavascriptPlugin($plugin_name); } else { trigger_error("Unable to find JS plugin at $path", \E_USER_WARNING); } } elseif ($type === 'lang') { $lang_dir = preg_replace('#/lang\.xml$#', '', $path); \Context::loadLang($lang_dir); } elseif ($type === 'js') { \Context::loadFile([ $path, $media_type ?? '', $external ? $this->source_type : '', $index ? intval($index) : '', ]); } elseif ($type === 'css' || $type === 'scss' || $type === 'less') { \Context::loadFile([ $path, $media_type ?? '', $external ? $this->source_type : '', $index ? intval($index) : '', $vars ?? [], ]); } else { trigger_error("Unable to determine type of resource at $path", \E_USER_WARNING); } } /** * Initialize v2 loop variable. * * @param string $stack_id * @param array|Traversable &$array * @return object */ protected function _v2_initLoopVar(string $stack_id, &$array): object { // Create the data structure. $loop = new \stdClass; $loop->index = 0; $loop->iteration = 1; $loop->count = is_countable($array) ? count($array) : countobj($array); $loop->remaining = $loop->count - 1; $loop->first = true; $loop->last = ($loop->count === 1); $loop->even = false; $loop->odd = true; $loop->depth = count(self::$_loopvars) + 1; $loop->parent = count(self::$_loopvars) ? end(self::$_loopvars) : null; // Append to stack and return. return self::$_loopvars[$stack_id] = $loop; } /** * Increment v2 loop variable. * * @param object $loopvar * @return void */ protected function _v2_incrLoopVar(object $loop): void { // Update properties. $loop->index++; $loop->iteration++; $loop->remaining--; $loop->first = ($loop->count === 1); $loop->last = ($loop->iteration === $loop->count); $loop->even = ($loop->iteration % 2 === 0); $loop->odd = !$loop->even; } /** * Remove v2 loop variable. * * @param object $loopvar * @return void */ protected function _v2_removeLoopVar(object $loop): void { // Remove from stack. if ($loop === end(self::$_loopvars)) { array_pop(self::$_loopvars); } } /** * Attribute builder for v2. * * @param string $attribute * @param array $definition * @return string */ protected function _v2_buildAttribute(string $attribute, array $definition = []): string { $delimiters = [ 'class' => ' ', 'style' => '; ', ]; $values = []; foreach ($definition as $key => $val) { if (is_int($key) && !empty($val)) { $values[] = $val; } elseif ($val) { $values[] = $key; } } if (count($values)) { return sprintf(' %s="%s"', $attribute, escape(implode($delimiters[$attribute], $values), false)); } else { return ''; } } /** * Auth checker for v2. * * @param string $type * @return bool */ protected function _v2_checkAuth(string $type = 'member'): bool { $grant = \Context::get('grant'); switch ($type) { case 'admin': return $this->user->isAdmin(); case 'manager': return $grant->manager ?? false; case 'member': return $this->user->isMember(); default: false; } } /** * Capability checker for v2. * * @param int $check_type * @param string|array $capability * @return bool */ protected function _v2_checkCapability(int $check_type, $capability): bool { $grant = \Context::get('grant'); if (!($grant instanceof \Rhymix\Modules\Module\Models\Permission)) { return false; } elseif ($check_type === 1) { return $grant->can($capability); } elseif ($check_type === 2) { return !$grant->can($capability); } elseif (is_array($capability)) { foreach ($capability as $cap) { if ($grant->can($cap)) { return true; } } return false; } else { return false; } } /** * Check if a validation error exists for v2. * * @param ...$args * @return bool */ protected function _v2_errorExists(...$args): bool { $validator_id = \Context::get('XE_VALIDATOR_ID'); $validator_message = \Context::get('XE_VALIDATOR_MESSAGE'); if (empty($validator_id) || empty($validator_message)) { return false; } return count($args) ? in_array((string)$validator_id, $args, true) : true; } /** * Check if the current visitor is using a mobile device for v2. * * @return bool */ protected function _v2_isMobile(): bool { return UA::isMobile() && (config('mobile.tablets') || !UA::isTablet()); } /** * Contextual escape function for v2. * * @param string $str * @return string */ protected function _v2_escape($str): string { switch ($this->config->context) { case 'JS': return escape_js(strval($str)); default: return escape(strval($str)); } } /** * Lang shortcut for v2. * * @param ...$args * @return string */ protected function _v2_lang(...$args): string { if (!isset($GLOBALS['lang']) || !$GLOBALS['lang'] instanceof Lang) { $GLOBALS['lang'] = Lang::getInstance(\Context::getLangType()); $GLOBALS['lang']->loadDirectory(\RX_BASEDIR . 'common/lang', 'common'); } if (isset($args[0]) && !strncmp($args[0], 'this.', 5)) { $args[0] = $this->source_name . '.' . substr($args[0], 5); } return $GLOBALS['lang']->get(...$args); } }