Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 2nd May 2023 */ namespace IPS\Theme; /* To prevent PHP errors (extending class does not exist) revealing path */ use ErrorException; use InvalidArgumentException; use IPS\Application; use IPS\Data\Store; use IPS\DateTime; use IPS\Db; use IPS\Helpers\Form; use IPS\Helpers\Form\Codemirror; use IPS\Helpers\Form\Radio; use IPS\Helpers\Form\Select; use IPS\Helpers\Form\Text; use IPS\Log; use IPS\Member; use IPS\Node\Model; use IPS\Output; use IPS\Patterns\ActiveRecordIterator; use IPS\Http\Url; use IPS\Request; use IPS\Theme; use LogicException; use OutOfRangeException; use ParseError; use UnderflowException; use UnexpectedValueException; use function array_keys; use function array_merge; use function count; use function defined; use function file_get_contents; use function implode; use function in_array; use function preg_match; use function str_replace; use const IPS\DEBUG_CUSTOM_TEMPLATES; use const IPS\IN_DEV; use const IPS\DEBUG_TEMPLATES; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Custom Template Class */ class CustomTemplate extends Model { /** * @brief Multiton Store */ protected static array $multitons = array(); /** * @brief [ActiveRecord] Database Table */ public static ?string $databaseTable = 'core_theme_templates_custom'; /** * @brief [ActiveRecord] ID Database Column */ public static string $databaseColumnId = 'id'; /** * @brief [ActiveRecord] Database ID Fields */ protected static array $databaseIdFields = ['template_key']; /** * @brief Database Prefix */ public static string $databasePrefix = 'template_'; /** * @brief [ActiveRecord] Multiton Map */ protected static array $multitonMap = array(); /** * @brief [Node] Node Title */ public static string $nodeTitle = 'custom_templates'; /** * @brief [Node] Sortable */ public static bool $nodeSortable = TRUE; /** * @brief [Node] Positon Column */ public static ?string $databaseColumnOrder = 'order'; /** * @brief [ActiveRecord] Caches * @note Defined cache keys will be cleared automatically as needed */ protected array $caches = array( 'custom_templates', 'custom_templates_rows' ); /** * @brief Templates already loaded and evald */ public static array $calledTemplates = array(); /** * @brief Current scope? (app/theme) */ public static string $scopeKey = ''; public static string $scopeValue = ''; /** * Return the cleaned name, suitable for a PHP function * * @return string */ public function cleanName(): string { return Theme::cleanGroupName( $this->name ); } /** * Return all templates * * @return ActiveRecordIterator */ public static function all(): ActiveRecordIterator { return new ActiveRecordIterator( Db::i()->select( '*', static::$databaseTable, null, static::$databasePrefix . static::$databaseColumnOrder ), 'IPS\Theme\CustomTemplate' ); } /** * Get all custom templates that match a main template path (eg core/front/global/globalTemplate) * * @param $templatePath * @return ActiveRecordIterator */ public static function byHookableTemplatePath( $templatePath ): ActiveRecordIterator { return new ActiveRecordIterator( Db::i()->select( '*', static::$databaseTable, [ Db::i()->like( 'template_hookpoint', $templatePath ) ] ), 'IPS\Theme\CustomTemplate' ); } /** * Get the template this custom template hooks into * * @param string|null $fullPath * @return array|null */ public function hookedTemplate( ?string $fullPath=NULL ): ?array { $fullPath = $fullPath ?: $this->hookpoint; [ $path, $hook ] = explode( ':', $fullPath ); $pathData = CustomTemplate::pathToArray( $path ); if ( IN_DEV ) { $template = Theme::i()->getTemplate( $pathData['group'], $pathData['app'], $pathData['location'] ); $html = @file_get_contents( $template->sourceFolder . $pathData['templateName'] . '.phtml' ); if ( ! $html ) { return null; } /* Parse the header tag */ preg_match( '/^(\r\n?|\n)/', $html, $params ); /* Strip it */ $html = ( isset($params[0]) ) ? str_replace( $params[0], '', $html ) : $html; return [ 'template_content' => $html, 'template_app' => $pathData['app'], 'template_location'=> $pathData['location'], 'template_group' => $pathData['group'], 'template_name' => $pathData['templateName'], 'template_data' => ( isset( $params[1] ) ) ? $params[1] : '', 'template_set_id' => 0 ]; } else { try { return Db::i()->select( '*', 'core_theme_templates', array('template_set_id=? and template_app=? and template_location=? and template_group=? and template_name=?', 0, $pathData['app'], $pathData['location'], $pathData['group'], $pathData['templateName']) )->first(); } catch ( UnderflowException $e ) { return null; } } } /** * We want a single template PHP function to be grouped by a common name * The common name would be something like core_front_global_globalTemplate__hookpoint__hookPointType * * @return string */ public function groupedPhpFunctionName(): string { [ $path, $hook ] = explode( ':', $this->hookpoint ); return str_replace( '/', '_', $path ) . '__' . $hook . '__' . str_replace( '-', '', $this->hookpoint_type ); } /** * Convert the path and hookpoint to a PHP function name * @param string $path * @param string $hookpoint * @param string $hooktype * @return string */ public static function pathAndHookPointToFunctionName( string $path, string $hookpoint, string $hooktype ): string { return str_replace( '/', '_', $path ) . '__' . $hookpoint . '__' . str_replace( '-', '', $hooktype ); } /** * Return the available params for the template this hooks into * * @param string|null $params * @param bool $returnVariablesOnly Only return the variable names, not the ='foo' defaults * @return array|null */ public function availableParams( ?string $params=null, bool $returnVariablesOnly = false ): array|null { if ( $template = $this->hookedTemplate() and $template['template_content'] ) { $params = ( $params ) ?: $template['template_data']; if ( $returnVariablesOnly ) { $matches = []; /* We just want the $varname, not the ='foo' defaults */ preg_match_all( '/(\$[a-zA-Z0-9_]+)/', $params, $matches ); return array_unique( $matches[1] ); } else { // Extract key-value pairs using regular expression preg_match_all('/(\$[a-zA-Z_][a-zA-Z0-9_]*)(\s*=\s*("[^"]*"|\'[^\']*\'|[^\s,]*))?/', $params, $matches); $results = []; foreach( $matches[0] as $row ) { $results[] = $row; } return $results; } } return NULL; } /** * Convert the stored path string to an array of app/location/group/name params * * @param string $path * @return array */ public static function pathToArray( string $path ): array { $bits = explode( "/", $path ); $app = array_shift( $bits ); $location = array_shift( $bits ); $group = array_shift( $bits ); $templateName = implode( "_", $bits ); //[ $app, $location, $group, $templateName ] = explode( '/', $path ); return [ 'app' => $app, 'location' => $location, 'group' => $group, 'templateName' => $templateName ]; } /** * Compile custom templates into a single data store object * * @return boolean|null */ public static function compileCustomTemplates(): ?bool { $flagKey = 'template_compiling_custom'; if ( Theme::checkLock( $flagKey ) ) { return NULL; } Theme::lock( $flagKey ); $functions = []; $organised = []; foreach( static::all() as $template ) { /* Only use templates that are enabled */ if ( $template->app and ! in_array( $template->app, array_keys( Application::enabledApplications() ) ) ) { continue; } if ( ! $template->hookpoint ) { if ( $template->key ) { /* This isn't using a hookpoint, so save it as a function we can call directly */ $organised[ 'custom_nohook_' . $template->key ] = [ 'content' => $template->content, 'params' => '' ]; } } else { if ( ! isset( $organised[ $template->groupedPhpFunctionName() ] ) ) { $paramList = ''; if ( $template->availableParams() ) { $paramList = implode( ',', $template->availableParams() ); } $organised[ $template->groupedPhpFunctionName() ] = [ 'params' => $paramList, 'content' => $template->contentForPhpFunction() ]; } else { $organised[ $template->groupedPhpFunctionName() ]['content'] .= $template->contentForPhpFunction(); } } } foreach( $organised as $functionName => $data ) { $functions[] = Theme::_compileTemplate( $data['content'], $functionName, TRUE, FALSE, $data['params'] ); } /* Put them in a class */ $template = <<custom_templates = str_replace( 'namespace _NAMESPACE_', 'namespace IPS\Theme', $template ); Theme::unlock( $flagKey ); return TRUE; } public function contentForPhpFunction(): string { $header = "\n\n"; if ( $this->set_id ) { /* Is this for a specific theme set? */ $content = "{{if \IPS\Theme::i()->id == {$this->set_id}}}\n" . $header . $this->content . "\n{{endif}}"; } elseif ( $this->app ) { /* Is this for a specific app? */ $content = "{{if \IPS\Application::appIsEnabled( '{$this->app}' )}}\n" . $header . $this->content . "\n{{endif}}"; } else { /* This is a global template */ $content = $header . $this->content; } return $content; } /** * @param string $name core/front/global/globalTemplate * @param string $hookPoint body:inside-start * @return string * @throws ErrorException */ public static function getCustomTemplatesForHookPoint( string $name, string $hookPoint ): string { if ( $hookPoint ) { [$hookName, $hookType] = explode( ':', $hookPoint ); } else { $hookName = null; $hookType = null; } $args = []; /* The {customtemplate..} tag when automatically added via static::expandHookPoints() will pass in the arguments to the function, so we need to grab them here. This allows those scoped variables to be used in the custom templates we create */ if ( func_num_args() > 2 ) { /* The forth argument is an array of the template_data params */ $args = func_get_arg(2); } if ( !in_array( 'custom_templates', array_keys( static::$calledTemplates ) ) ) { /* If we don't have a compiled template, do that now */ if ( !isset( Store::i()->custom_templates ) ) { /* It can take a few seconds for templates to finish compiling if initiated elsewhere, so let's try a few times sleeping 1 second between attempts to give the compilation time to finish */ $attempts = 0; while( $attempts < 6 ) { $built = CustomTemplate::compileCustomTemplates(); if ( $built === NULL ) { $attempts++; sleep(1); } else { break; } } /* Still no key? */ if ( ! isset( Store::i()->custom_templates ) ) { Log::log( "Custom templates store missing", "template_store_missing" ); throw new ErrorException( 'template_store_missing' ); } } if ( ! class_exists( '\IPS\Theme\CustomTemplateClass', false ) ) { $storedTemplate = Store::i()->custom_templates; if ( DEBUG_TEMPLATES ) { Theme::runDebugTemplate( 'custom_template', $storedTemplate ); } else { try { if ( @eval( $storedTemplate ) === FALSE ) { if ( IN_DEV ) { throw new TemplateException( 'Invalid Custom Template' ); } } } catch ( ParseError $e ) { throw new UnexpectedValueException; } } /* Init */ static::$calledTemplates['custom_template'] = new \IPS\Theme\CustomTemplateClass; } } if ( $hookPoint ) { $functionName = CustomTemplate::pathAndHookPointToFunctionName( $name, $hookName, $hookType ); } else { $functionName = "custom_nohook_" . $name; } /* If we're debugging, unset the store on shutdown */ if ( DEBUG_CUSTOM_TEMPLATES ) { register_shutdown_function( function() { unset( Store::i()->custom_templates ); } ); } if ( method_exists( static::$calledTemplates['custom_template'], $functionName ) ) { return static::$calledTemplates['custom_template']->$functionName( ...$args ); } else { return ''; } } /* Node class handler stuff */ /** * Fetch All Root Nodes * * @param string|NULL $permissionCheck The permission key to check for or NULl to not check permissions * @param Member|NULL $member The member to check permissions for or NULL for the currently logged in member * @param mixed|array $where Additional WHERE clause * @param array|NULL $limit Limit/offset to use, or NULL for no limit (default) * @return array */ public static function roots( ?string $permissionCheck ='view', $member=NULL, $where=array(), $limit=NULL ): array { if ( !count( $where ) ) { $return = array(); foreach( static::getStore() AS $node ) { if ( static::$scopeKey === 'set_id' ) { if ( $node['template_set_id'] != static::$scopeValue ) { continue; } } $return[ $node['template_id'] ] = static::constructFromData( $node ); } return $return; } else { return parent::roots( $permissionCheck, $member, $where, $limit ); } } /** * Get data store * * @return array * @note Note that all records are returned, even disabled promotion rules. Enable status needs to be checked in userland code when appropriate. */ public static function getStore(): array { if ( !isset( Store::i()->custom_templates_rows) ) { $select = [ 'template_id', 'template_name', 'template_hookpoint', 'template_updated', 'template_version', 'template_set_id', 'template_app', 'template_hookpoint_type', 'template_key', 'template_order' ]; Store::i()->custom_templates_rows = iterator_to_array( Db::i()->select( implode( ', ', $select ), static::$databaseTable, NULL, "template_order ASC" )->setKeyField( 'template_id' ) ); } return Store::i()->custom_templates_rows; } /** * [Node] Get Title * * @return string */ protected function get__title(): string { return $this->name; } /** * [Node] Return the custom badge for each row * * @return NULL|array Null for no badge, or an array of badge data (0 => CSS class type, 1 => language string, 2 => optional raw HTML to show instead of language string) */ protected function get__badge(): ?array { /* Is there an update to show? */ $badge = []; if ( $this->hookpoint and $this->hookpoint_type ) { [ $path, $hook ] = explode( ':', $this->hookpoint ); $pathData = CustomTemplate::pathToArray( $path ); $badge = array( 0 => 'style2', 1 => implode( "/", $pathData ) . ':' . $hook . ' ' . $this->hookpoint_type ); } else if ( $this->key ) { $badge = array( 0 => 'style3', 1 => $this->key ); } return $badge; } /** * [Node] Get buttons to display in tree * Example code explains return value * * @param Url $url Base URL * @param bool $subnode Is this a subnode? * @return array */ public function getButtons( Url $url, bool $subnode=FALSE ): array { $buttons = parent::getButtons( $url, $subnode ); if ( isset( $buttons['copy'] ) ) { unset( $buttons['copy'] ); } $buttons['edit']['link'] = Url::internal( "app=core&module=customization&controller=customtemplates&do=form&" . static::$scopeKey. "=" . static::$scopeValue . "&id=" . $this->id ); /* Remove the modal for editing */ unset( $buttons['edit']['data']['ipsDialog'], $buttons['edit']['data']['ipsDialog-title'] ); return $buttons; } /** * [Node] Does the currently logged-in user have permission to edit permissions for this node? * @note We currently do not use the permission system for templates. * * @return bool */ public function canManagePermissions(): bool { return FALSE; } /** * [Node] Does the currently logged-in user have permission to copy this node? * * @return bool */ public function canCopy(): bool { return FALSE; } /** * Form * * @param Form $form The form * @return void */ public function form( Form &$form ): void { //$form->class = 'ipsForm--vertical ipsForm--custom-template ipsForm--fullWidth'; $form->attributes['data-controller'] = 'core.admin.customization.customTemplatesForm'; Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'admin_customization.js', 'core', 'admin' ) ); $applications = []; $hookPointsSelect = []; $pathNamesSelect = []; foreach( Application::applications() as $application ) { foreach ( Theme::i()->getHookPoints( [ $application->directory ] ) as $path => $hookPointNames ) { $applications[ $application->directory ] = $application->_title; foreach ( $hookPointNames as $hookPointName ) { $fixedPath = preg_replace( '#^' . $application->directory . '/#', '', $path ); $pathNamesSelect[ $application->directory ][ str_replace( '/', '_', $fixedPath ) ] = $fixedPath; $hookPointsSelect[ $application->directory . '_' . str_replace( '/', '_', $fixedPath ) ][ $hookPointName ] = $hookPointName; } } } /* Work out some defaults */ $defaultHookapp = 'core'; $defaultPath = ''; $defaultTemplateHook = ''; if ( $this->id and $this->hookpoint ) { $array = static::pathToArray( $this->hookpoint ); if ( isset( $array['app'] ) ) { $defaultHookapp = $array['app']; } if ( isset( $array['group'] ) and isset( $array['templateName'] ) ) { [ $templateName, $defaultTemplateHook ] = explode( ':', $array['templateName'] ); $defaultPath = $array['location'] . '_' . $array['group'] . '_' . $templateName; } } $form->add( new Text( 'template_name', $this->id ? $this->name : NULL, true ) ); $form->add( new Radio( 'template_type', ( $this->id and $this->hookpoint ) ? 'hook' : 'custom', true, array( 'options' => array( 'hook' => 'template_type_hook', 'custom' => 'template_type_custom' ) ), NULL, NULL, NULL, 'template_type' ) ); $form->add( new Select( 'template_hookapp', $defaultHookapp, FALSE, array( 'options' => $applications ), NULL, NULL, NULL, 'template_hookapp' ) ); foreach( $pathNamesSelect as $applicationDirectory => $path ) { $form->add( new Select( 'template_path_' . $applicationDirectory, $defaultPath, FALSE, array( 'options' => $path, 'parse' => 'normal' ), NULL, NULL, Theme::i()->getTemplate( 'customization', 'core', 'admin' )->previewTemplateLink(), 'template_path_' . $applicationDirectory ) ); Member::loggedIn()->language()->words[ 'template_path_' . $applicationDirectory ] = Application::load( $applicationDirectory )->_title; } foreach( $hookPointsSelect as $data => $hookPoints ) { [ $applicationDirectory, $path ] = explode( '_', $data, 2 ); $form->add( new Select( 'template_hookpoint_' . $applicationDirectory . '_' . $path, $defaultTemplateHook, FALSE, array( 'options' => $hookPoints, 'parse' => 'normal' ), NULL, NULL, NULL, 'template_hookpoint_' . $applicationDirectory . '_' . $path ) ); Member::loggedIn()->language()->words[ 'template_hookpoint_' . $applicationDirectory . '_' . $path ] = Member::loggedIn()->language()->get('template_hookpoint'); } $form->add( new Select( 'template_hookpoint_type', $this->id ? $this->hookpoint_type : 'before', FALSE, array( 'options' => [ 'before' => 'template_custom_before', 'inside-start' => 'template_custom_inside_start', 'inside-end' => 'template_custom_inside_end', 'after' => 'template_custom_after', ] ), NULL, NULL, NULL, 'template_hookpoint_type' ) ); $form->add( new Text( 'template_key', $this->id ? $this->key : NULL, FALSE, [], function( $val ) { try { if ( ! $val ) { if ( Request::i()->template_type == 'custom' ) { /* This is required if we're not using a hookpoint */ throw new InvalidArgumentException('custom_template_key_cant_be_empty'); } return true; } $val = Theme::cleanGroupName( $val ); try { $template = CustomTemplate::load( $val, 'template_key'); } catch( OutOfRangeException $ex ) { /* Doesn't exist? Good! */ return true; } /* It's taken... */ if ( Request::i()->id == $template->id ) { /* But it's this one so that's ok */ return true; } /* and if we're here, it's not... */ throw new InvalidArgumentException('custom_template_key_not_unique'); } catch ( OutOfRangeException $e ) { /* Slug is OK as load failed */ return true; } }, NULL, NULL, 'template_key' ) ); $form->add( new Codemirror( 'template_content', $this->id ? $this->content : null, FALSE, [ 'codeModeAllowedLanguages' => [ 'ipsphtml' ] ], function( $val ) { /* Test */ try { $currentApp = Request::i()->template_hookapp; $params = ''; if ( $currentApp !== 'none' ) { $path = Request::i()->{'template_path_' . $currentApp}; $hook = Request::i()->{'template_hookpoint_' . $currentApp . '_' . $path}; $template = new CustomTemplate(); $template->hookpoint = $currentApp . '/' . str_replace( '_', '/', $path ) . ':' . $hook; $template->hookpoint_type = Request::i()->template_hookpoint_type; $params = implode( ', ', $template->availableParams() ); } Theme::checkTemplateSyntax( $val, $params ); } catch( LogicException $e ) { throw new LogicException('template_error_bad_syntax'); } } ) ); /* If we are editing, we can save and reload */ if( $this->id ) { $form->canSaveAndReload = true; } } /** * [Node] Format form values from add/edit form for save * * @param array $values Values from the form * @return array */ public function formatFormValues( array $values ): array { $values['template_updated'] = DateTime::create()->getTimestamp(); $values['template_version'] = Application::load('core')->long_version; $values['template_key'] = $values['template_key'] ? Theme::cleanGroupName( $values['template_key'] ) : null; if ( $values['template_type'] === 'hook' and isset( $values[ 'template_path_' . $values['template_hookapp'] ] ) ) { $currentApp = $values['template_hookapp']; $path = $values[ 'template_path_' . $currentApp ]; $hook = $values[ 'template_hookpoint_' . $currentApp . '_' . $path ]; $values['template_hookpoint'] = $currentApp . '/' . str_replace( '_', '/', $path ) . ':' . $hook; } else { $values['template_hookpoint'] = null; } if ( static::$scopeKey === 'set_id' ) { $values['template_set_id'] = (int) static::$scopeValue; } else if ( static::$scopeKey === 'appKey' ) { $values['template_app'] = static::$scopeValue; } /* Unset some things we don't need */ foreach( $values as $k => $v ) { if( $k == 'template_hookpoint_type' ) { continue; } if ( stristr( $k, 'template_hookpoint_' ) or stristr( $k, 'template_path_' ) ) { unset( $values[ $k ] ); } } unset( $values['template_hookapp'], $values['template_type'] ); return parent::formatFormValues( $values ); } /** * Import custom templates from a json file * * @param string $file * @param string $appKey * @param int|null $offset * @param int|null $limit * @return int */ public static function importFromFile( string $file, string $appKey, ?int $offset=null, ?int $limit=null ) : int { $templates = json_decode( file_get_contents( $file ), true ); $keys = array_keys( $templates ); $offset = $offset ?? 0; if( $limit !== null ) { $keys = array_slice( $keys, $offset, $limit ); } $current = iterator_to_array( Db::i()->select( '*', static::$databaseTable, [ 'template_app=?', $appKey ] ) ->setKeyField( 'template_name' ) ); $imported = 0; foreach( $keys as $name ) { $data = $templates[ $name ]; if( isset( $current[ $name ] ) ) { Db::i()->update( static::$databaseTable, [ 'template_hookpoint' => $data['hookpoint'], 'template_hookpoint_type' => $data['type'], 'template_key' => $data['key'], 'template_version' => $data['version'], 'template_content' => $data['content'], 'template_updated' => time() ], [ 'template_id=?', $current[ $name ]['template_id'] ] ); unset( $current[ $name ] ); } else { Db::i()->insert( static::$databaseTable, [ 'template_app' => $appKey, 'template_name' => $name, 'template_hookpoint' => $data['hookpoint'], 'template_hookpoint_type' => $data['type'], 'template_key' => $data['key'], 'template_version' => $data['version'], 'template_content' => $data['content'], 'template_updated' => time() ]); } $imported++; } Db::i()->delete( static::$databaseTable, [ [ 'template_app=?', $appKey ], [ Db::i()->in( 'template_name', array_keys( $current ) ) ] ]); return $imported; } }