Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @subpackage Content * @since 25 Feb 2014 */ namespace IPS\cms; /* To prevent PHP errors (extending class does not exist) revealing path */ use BadMethodCallException; use Exception; use InvalidArgumentException; use IPS\cms\Pages\Page; use IPS\Data\Store; use IPS\Db; use IPS\Db\Select; use IPS\File; use IPS\Helpers\Form; use IPS\Helpers\Form\CheckboxSet; use IPS\Http\Url; use IPS\Http\Url\Friendly; use IPS\Http\Url\Internal; use IPS\Member; use IPS\Patterns\ActiveRecord; use IPS\Theme; use OutOfRangeException; use SimpleXMLElement; use UnderflowException; use UnexpectedValueException; use XMLReader; use XMLWriter; use function count; use function defined; use function in_array; use function is_array; use function is_numeric; use function strtolower; use function substr; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * @brief Template Model * @notes Rules of a template: * user_created: this means the user has creating a new set of templates (via import or Add New) * user_edited: this means the user has edited a template so it no longer matches the master template * * CUSTOM FLAG: True when user_created is true, user_edited is false, original_group != any of the master original_groups (if original group is the same as any of the master groups then it is a default template still) * MODIFIED FLAG: True when user_edited is true * * Updating templates: Update where user_edited is FALSE and user_created is FALSE OR user_created is TRUE and original_group is in master groups */ class Templates extends ActiveRecord { /** * @brief [ActiveRecord] Multiton Store */ protected static array $multitons; /** * @brief [ActiveRecord] Database Prefix */ public static string $databasePrefix = 'template_'; /** * @brief [ActiveRecord] ID Database Table */ public static ?string $databaseTable = 'cms_templates'; /** * @brief [ActiveRecord] ID Database Column */ public static string $databaseColumnId = 'id'; /** * @brief [ActiveRecord] Database ID Fields */ protected static array $databaseIdFields = array( 'template_key', 'template_id' ); /** * @brief [ActiveRecord] Multiton Map */ protected static array $multitonMap = array(); /** * @brief Master groups */ protected static ?array $masterGroups = NULL; /** * @brief Master templates */ protected static ?array $masterTemplates = NULL; /** * @brief Retusn all types */ const RETURN_ALL = 1; /** * @brief Returns block templates */ const RETURN_BLOCK = 2; /** * @brief Return page templates */ const RETURN_PAGE = 4; /** * @brief Return database templates */ const RETURN_DATABASE = 8; /** * @brief Return just css type */ const RETURN_ONLY_CSS = 16; /** * @brief Return just js type */ const RETURN_ONLY_JS = 32; /** * @brief Return just template type */ const RETURN_ONLY_TEMPLATE = 64; /** * @brief Return just contents of cms_templates ignoring IN_DEV */ const RETURN_DATABASE_ONLY = 128; /** * @brief Return both IN_DEV and database templates */ const RETURN_DATABASE_AND_IN_DEV = 256; /** * @brief Default database template group names */ public static array $databaseDefaults = array( 'index' => 'category_articles', 'form' => 'form', 'display' => 'display', 'listing' => 'listing', 'categories' => 'category_index' ); /** * Ensure that the template is calling the correct groups * * @param string $group Group to load templates from * @return void */ public static function fixTemplateTags( string $group ) : void { $templates = iterator_to_array( Db::i()->select( '*', 'cms_templates', array( array( 'template_group=?', $group ) ) )->setKeyField('template_title') ); foreach( $templates as $template ) { $save = array(); /* Make sure template tags call the correct group */ if ( mb_stristr( $template['template_content'], '{template' ) ) { preg_match_all( '/\{([a-z]+?=([\'"]).+?\\2 ?+)}/', $template['template_content'], $matches, PREG_SET_ORDER ); /* Work out the plugin and the values to pass */ foreach( $matches as $index => $array ) { preg_match_all( '/(.+?)=' . $array[ 2 ] . '(.+?)' . $array[ 2 ] . '\s?/', $array[ 1 ], $submatches ); $plugin = array_shift( $submatches[ 1 ] ); if ( $plugin == 'template' ) { $value = array_shift( $submatches[ 2 ] ); $options = array(); foreach ( $submatches[ 1 ] as $k => $v ) { $options[ $v ] = $submatches[ 2 ][ $k ]; } if ( isset( $options['app'] ) and $options['app'] == 'cms' and isset( $options['location'] ) and $options['location'] == 'database' and isset( $options['group'] ) and $options['group'] != $template['template_original_group'] ) { if ( in_array( $value, array_keys( $templates ) ) ) { $options['group'] = $group; $replace = '{template="' . $value . '" app="' . $options['app'] . '" location="' . $options['location'] . '" group="' . $options['group'] . '" params="' . ( $options['params'] ?? NULL ) . '"}'; $save['template_content'] = str_replace( $matches[$index][0], $replace, $template['template_content'] ); } } if ( count( $save ) ) { Db::i()->update( 'cms_templates', $save, array( 'template_id=?', $template['template_id'] ) ); } } } } } } /** * Load Record * Overloaded so we can force loading by key by default but still retain the template_id field as the primary key so * save still updates the primary ID. * * @see Db::build * @param int|string|null $id ID * @param string|null $idField The database column that the $id parameter pertains to (NULL will use static::$databaseColumnId) * @param mixed $extraWhereClause Additional where clause(s) (see \IPS\Db::build for details) * @return ActiveRecord|static * @throws InvalidArgumentException * @throws OutOfRangeException */ public static function load( int|string|null $id, string $idField=NULL, mixed $extraWhereClause=NULL ): ActiveRecord|static { if ( !is_numeric( $id ) and $idField === NULL ) { $idField = 'template_key'; } if ( !is_numeric( $id ) and \IPS\IN_DEV ) { $templates = Theme::i()->getAllTemplates( '', '', '', \IPS\cms\Theme::RETURN_AS_OBJECT ); if ( isset( $templates[ $id ] ) ) { return $templates[ $id ]; } } return parent::load( $id, $idField, $extraWhereClause ); } /** * Make a group_name readable (Group Name) * * @param string|null $name Group name from the database * @return string */ public static function readableGroupName( ?string $name ) : string { switch( $name ) { case null: return ''; case 'js': return 'JS'; case 'css': return 'CSS'; default: return ucwords( str_replace( array( '-', '_' ), ' ', $name ) ); } } /** * Get master templates * * This returns an array of all template groups that are considered IPS defaults * @return array */ public static function getMasterTemplates() : array { if ( static::$masterTemplates === NULL ) { static::$masterTemplates = iterator_to_array( Db::i()->select( '*, MD5( CONCAT( template_location, \'.\', template_group, \'.\', template_title ) ) as bit_key', 'cms_templates', array( 'template_master=1' ) )->setKeyField( 'bit_key' ) ); } return static::$masterTemplates; } /** * Get the master template version of this template * * @return Templates|null */ public function getMasterOfThis(): ?Templates { if ( $this->master ) { return $this; } $key = md5( $this->location . '.'. $this->original_group . '.' . $this->title ); if ( in_array( $key, array_keys( static::getMasterTemplates() ) ) ) { /* @var static Templates */ return static::constructFromData( static::$masterTemplates[ $key ] ); } return null; } /** * Get master group names * * This returns an array of all template groups that are considered IPS defaults * @return array */ public static function getMasterGroups() : array { if ( static::$masterGroups === NULL ) { static::$masterGroups = array(); foreach( Db::i()->select( 'template_group', static::$databaseTable, array( 'template_master=1 and template_original_group=template_group' ), 'template_group ASC', NULL, 'template_group' ) as $template ) { static::$masterGroups[ $template ] = $template; } } return static::$masterGroups; } /** * Get all template group * * @param string $returnType Determines the content returned * @return array */ public static function getGroups( string $returnType ): array { $where = array(); $return = array(); switch( $returnType ) { case 'all': $returnType = self::RETURN_ALL; break; case 'block': $returnType = self::RETURN_BLOCK; break; case 'page': $returnType = self::RETURN_PAGE; break; case 'database': $returnType = self::RETURN_DATABASE; break; } if ( $returnType & self::RETURN_ALL ) { $where[] = array( 'template_location !=?', NULL ); } else { $locations = array(); if ( $returnType & self::RETURN_BLOCK ) { $locations[] = 'block'; } if ( $returnType & self::RETURN_PAGE ) { $locations[] = 'page'; } if ( $returnType & self::RETURN_DATABASE ) { $locations[] = 'database'; } if ( ! count( $locations ) ) { throw new UnexpectedValueException(); } $where[] = array( "template_location IN ('" . implode( "','", $locations ) . "')" ); } foreach( Db::i()->select( 'template_group', static::$databaseTable, $where, 'template_group ASC', NULL, 'template_group' ) as $template ) { $return[ $template ] = $template; } return $return; } /** * Get all templates * * @param int $returnType Determines the content returned * @return array */ public static function getTemplates( int $returnType=1 ): array { $where = array(); $return = array(); if ( ( \IPS\IN_DEV ) AND ( $returnType & self::RETURN_DATABASE_AND_IN_DEV ) AND ! ( $returnType & self::RETURN_DATABASE_ONLY ) ) { $flags = \IPS\cms\Theme::RETURN_AS_OBJECT; if ( $returnType & self::RETURN_ONLY_TEMPLATE ) { $flags += \IPS\cms\Theme::RETURN_ONLY_TEMPLATE; } else if ( $returnType & self::RETURN_ONLY_CSS ) { $flags += \IPS\cms\Theme::RETURN_ONLY_CSS; } else if ( $returnType & self::RETURN_ONLY_JS ) { $flags += \IPS\cms\Theme::RETURN_ONLY_JS; } else { if ( $returnType & self::RETURN_BLOCK ) { $flags += \IPS\cms\Theme::RETURN_BLOCK; } if ( $returnType & self::RETURN_PAGE ) { $flags += \IPS\cms\Theme::RETURN_PAGE; } if ( $returnType & self::RETURN_DATABASE ) { $flags += \IPS\cms\Theme::RETURN_DATABASE; } } $return = Theme::i()->getAllTemplates( 'cms', '', '', $flags ); } if ( ! ( \IPS\IN_DEV ) OR ( $returnType & self::RETURN_DATABASE_AND_IN_DEV ) OR ( $returnType & self::RETURN_DATABASE_ONLY ) ) { if ( $returnType & self::RETURN_ALL ) { $where[] = array( 'template_location !=?', NULL ); } else if ( $returnType & self::RETURN_ONLY_TEMPLATE ) { $where[] = array( 'template_type = ?', 'template' ); } else if ( $returnType & self::RETURN_ONLY_CSS ) { $where[] = array( 'template_type = ?', 'css' ); } else if ( $returnType & self::RETURN_ONLY_JS ) { $where[] = array( 'template_type = ?', 'js' ); } else { $locations = array(); if ( $returnType & self::RETURN_BLOCK ) { $locations[] = 'block'; } if ( $returnType & self::RETURN_PAGE ) { $locations[] = 'page'; } if ( $returnType & self::RETURN_DATABASE ) { $locations[] = 'database'; } if ( !count( $locations ) ) { throw new UnexpectedValueException(); } $where[] = array( "template_location IN ('" . implode( "','", $locations ) . "')" ); } foreach ( Db::i()->select( '*', static::$databaseTable, $where, 'template_user_edited DESC' ) as $template ) { /* user_edited version is returned first, so only add to the array if the key isn't already in $return */ if ( !isset( $return[ $template['template_key'] ] ) ) { $return[ $template['template_key'] ] = static::constructFromData( $template ); } } } return $return; } /** * Construct Load Query * Overloaded so we return the user_edited version where available * * @param int|string $id ID * @param string $idField The database column that the $id parameter pertains to * @param mixed $extraWhereClause Additional where clause(s) * @return Select */ protected static function constructLoadQuery( int|string $id, string $idField, mixed $extraWhereClause ): Select { $where = array( array( $idField . '=?', $id ) ); if( $extraWhereClause !== NULL ) { if ( !is_array( $extraWhereClause ) or !is_array( $extraWhereClause[0] ) ) { $extraWhereClause = array( $extraWhereClause ); } $where = array_merge( $where, $extraWhereClause ); } return static::db()->select( '*', static::$databaseTable, $where, 'template_user_edited DESC' ); } /** * Generate a tree of templates * * @param array $templates Template data from the database * @return array */ public static function buildTree( array $templates ) : array { $return = array(); foreach( $templates as $id => $template ) { if ( ! ( $template->location === 'database' and ! Member::loggedIn()->hasAcpRestriction( 'cms', 'databases', 'databases_use' ) ) ) { $return[$template->location][$template->group][$template->key] = $template; } } return $return; } /** * Add a new template * * @param array $template Template Data * @return static */ public static function add( array $template ) : static { $newTemplate = new static; foreach( $template as $_k => $_v ) { $newTemplate->$_k = $_v; } $newTemplate->_new = TRUE; $newTemplate->user_created = 1; $newTemplate->user_edited = 0; $newTemplate->master = 0; $newTemplate->save(); /* Create a unique key */ $newTemplate->key = $newTemplate->location . '_' . Friendly::seoTitle( $newTemplate->title ) . '_' . $newTemplate->id; /* Make sure there's no double __ in there */ foreach( array( 'group', 'title', 'key' ) as $field ) { if ( mb_strstr( $newTemplate->$field, '__' ) ) { $newTemplate->$field = str_replace( '__', '_', $newTemplate->$field ); } } $newTemplate->save(); return $newTemplate; } /** * Removes all stored files so they can be rebuilt on the fly * * @return void */ public static function deleteCompiledFiles() : void { foreach( Db::i()->select( '*', 'cms_templates', array( 'template_file_object IS NOT NULL' ) ) as $template ) { try { File::get( 'core_Theme', $template['template_file_object'] )->delete(); } catch( Exception $ex ) { } } Db::i()->update( 'cms_templates', array( 'template_file_object' => NULL ) ); } /** * Export form for CMS Templates * * @param bool $appPluginBuild TRUE to customise form for app/plugin builds * @param array $preSelected Array of default values for checkboxSet * @return Form */ public static function exportForm( bool $appPluginBuild=FALSE, array $preSelected=array() ): Form { $form = new Form( 'form', $appPluginBuild ? 'save' : 'download' ); if( !$appPluginBuild ) { $form->addMessage( 'cms_templates_export_description', 'ipsMessage ipsMessage_information' ); $form->addButton( 'cms_templates_export_return', 'link', Url::internal( "app=cms&module=pages&controller=templates" ) ); } else { $form->addMessage( 'cms_templates_export_description_app', 'ipsMessage ipsMessage_general' ); } $templates = []; foreach(Templates::getTemplates( Templates::RETURN_DATABASE_AND_IN_DEV + Templates::RETURN_ALL ) as $template ) { $title = Templates::readableGroupName( $template->group ); if( $template->location == 'database' ) { $templates['database'][ $template->group ] = $title; } else { $templates[ $template->location ][ $template->key ] = $title . ' > ' . $template->title; } } foreach( $templates as $location => $data ) { $defaultValue = $preSelected[ 'templates_' . $location ] ?? FALSE; $form->add( new CheckboxSet( 'templates_' . $location, $defaultValue, FALSE, array( 'options' => $data ) ) ); } return $form; } /** * Export requested templates as XML * * @param array $values Checkbox set values * @return XMLWriter|null */ public static function exportAsXml( array $values ):? XMLWriter { $templates = array(); foreach(Templates::getTemplates( Templates::RETURN_DATABASE_ONLY + Templates::RETURN_ALL ) as $template ) { $templates[ $template->location ][ $template->group ] = $template->title; } $exportTemplates = array(); foreach ( $templates as $location => $outerData ) { if ( isset( $values[ 'templates_' . $location ] ) and count( $values[ 'templates_' . $location ] ) ) { if ( $location === 'database' ) { $tmp = \IPS\cms\Theme::i()->getAllTemplates( 'cms', array( 'database' ), array_values( $values[ 'templates_' . $location ] ), \IPS\cms\Theme::RETURN_DATABASE_ONLY + Theme::RETURN_ALL ); foreach ( $tmp['cms'] as $loc => $data ) { if ( $loc === $location ) { foreach ( $data as $key => $tdata ) { foreach ( $tdata as $tkey => $tkdata ) { $exportTemplates['cms'][ $location ][ $key ][ $tkey ] = $tkdata; } } } } } else { $tmp = \IPS\cms\Theme::i()->getAllTemplates( 'cms', array( $location ), [], \IPS\cms\Theme::RETURN_DATABASE_ONLY + Theme::RETURN_ALL ); foreach ( $tmp['cms'] as $loc => $data ) { if ( $loc === $location ) { foreach ( $data as $key => $tdata ) { foreach ( $tdata as $tkey => $tkdata ) { if ( in_array( $tkey, $values[ 'templates_' . $location ] ) ) { $exportTemplates['cms'][ $location ][ $key ][ $tkey ] = $tkdata; } } } } } } } } if ( count( $exportTemplates ) ) { /* Init */ $xml = new XMLWriter; $xml->openMemory(); $xml->setIndent( TRUE ); $xml->startDocument( '1.0', 'UTF-8' ); /* Root tag */ $xml->startElement( 'templates' ); foreach ( $exportTemplates as $app => $location ) { foreach ( $location as $key => $data ) { foreach ( $data as $group => $template ) { foreach ( $template as $key => $templateData ) { /* Initiate the