Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 16 Apr 2013 */ namespace IPS; /* To prevent PHP errors (extending class does not exist) revealing path */ use Diff; use DomainException; use DOMText; use DOMXPath; use ErrorException; use Exception; use InvalidArgumentException; use IPS\cms\Pages\Page; use IPS\Data\Store; use IPS\Dispatcher\Front; use IPS\Helpers\Form; use IPS\Helpers\Form\CheckboxSet; use IPS\Helpers\Form\Codemirror; use IPS\Helpers\Form\Number; use IPS\Helpers\Form\Text; use IPS\Helpers\Form\Translatable; use IPS\Helpers\Form\YesNo; use IPS\Http\Url; use IPS\Http\Url\Friendly; use IPS\Member\Group; use IPS\Node\Model; use IPS\Patterns\ActiveRecord; use IPS\Patterns\ActiveRecordIterator; use IPS\Theme\CustomTemplate; use IPS\Theme\Editor\Category; use IPS\Theme\Editor\Setting; use IPS\Theme\SandboxedTemplate; use IPS\Theme\Dev\Theme as DevTheme; use IPS\Theme\Setup\Theme as SetupTheme; use IPS\Theme\TemplateException; use IPS\Xml\DOMDocument; use LogicException; use OutOfRangeException; use OverflowException; use ParseError; use UnderflowException; use UnexpectedValueException; use XMLReader; use function array_combine; use function array_merge; use function count; use function defined; use function file_get_contents; use function file_put_contents; use function function_exists; use function get_called_class; use function glob; use function implode; use function in_array; use function intval; use function is_array; use function is_string; use function iterator_to_array; use function json_decode; use function json_encode; use function mb_substr; use function md5; use function preg_match; use function preg_match_all; use function preg_replace; use function rtrim; use function str_replace; use function stristr; use function strlen; use function strstr; use function strtolower; use function substr; use function unlink; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Skin set */ class Theme extends Model { /** * @brief [ActiveRecord] Multiton Store */ protected static array $multitons; /** * @brief [ActiveRecord] Database Table */ public static ?string $databaseTable = 'core_themes'; /** * @brief [ActiveRecord] Database Prefix */ public static string $databasePrefix = 'set_'; /** * @brief [ActiveRecord] ID Database Column */ public static string $databaseColumnId = 'id'; /** * @brief [ActiveRecord] Database ID Fields */ protected static array $databaseIdFields = array('set_id', 'set_key', 'set_edit_in_progress'); /** * @brief [ActiveRecord] Multiton Map */ protected static array $multitonMap = array(); /** * @brief [Node] Order Database Column */ public static ?string $databaseColumnOrder = 'order'; /** * @brief [Node] Node Title */ public static string $nodeTitle = 'menu__core_customization_themes'; /** * @brief [Node] Show forms modally? */ public static bool $modalForms = TRUE; /** * @brief [Node] Title prefix. If specified, will look for a language key with "{$key}_title" as the key */ public static ?string $titleLangPrefix = 'core_theme_set_title_'; /** * @brief IN_DEV "theme" */ public static ?Theme $inDevTheme = NULL; /** * @brief Setup "theme" */ protected static Theme|SetupTheme|null $setupSkin = NULL; /** * @brief Admin "theme" */ public static Theme|DevTheme|null $adminSkin = NULL; /** * @brief Member's "theme" */ public static mixed $memberTheme = NULL; /** * @brief Have fetched all? */ protected static bool $gotAll = FALSE; /** * @brief [SkinSets] Stores the default theme set id */ public static int $defaultFrontendThemeSet = 0; /** * @brief [SkinSets] Templates already loaded and evald via getTemplate() */ public static array $calledTemplates = array(); /** * @brief [SkinSets] Some CSS files are built from a directory to save on http requests. They are saved as {$location}_{$folder}.css (so front_responsive.css for example) */ protected static array $buildGrouping = array( 'css' => array( 'core' => array( 'global'=> array( 'framework' ), 'admin' => array( 'core' ) ) ) ); /** * @brief Return type for getAllTemplates/getRawCss: Return all */ const RETURN_ALL = 1; /** * @brief Return type for getAllTemplates/getRawCss: Return groups and names as a tree */ const RETURN_BIT_NAMES = 2; /** * @brief Return type for getAllTemplates/getRawCss: Return groups and names as a tree with array of data without content */ const RETURN_ALL_NO_CONTENT = 4; /** * @brief Return type for getAllTemplates/getRawCss: Returns bit names as a flat array */ const RETURN_ARRAY_BIT_NAMES = 8; /** * @brief Return type for getAllTemplates/getRawCss: Uses DB if not IN_DEV, otherwise uses disk .phtml look up */ const RETURN_NATIVE = 16; /** * @brief Type for templates */ const TEMPLATES = 1; /** * @brief Type for CSS */ const CSS = 2; /** * @brief Type for Images */ const IMAGES = 4; /** * @brief Bit option for theme settings */ const THEME_KEY_VALUE_PAIRS = 1; /** * @brief Bit option for theme settings */ const THEME_ID_KEY = 2; /** * @brief Bit option for theme settings */ const TEMPLATE_SETTINGS_DEFAULT = 4; /** * @brief Bit option for getting css variables */ const CUSTOM_ONLY = 1; /** * @brief Bit option to use the default values */ const FORCE_DEFAULT = 2; /** * @brief Name of variable filename */ const CSS_VARIABLE_FILENAME = '1-2-settings.css'; /** * @brief Disable the copy button - useful when the forms are very distinctly different */ public bool $noCopyButton = TRUE; /** * @brief [ActiveRecord] Caches * @note Defined cache keys will be cleared automatically as needed */ protected array $caches = array( 'updatecount_themes' ); /** * @brief Save a few cycles not json_decoding constantly */ protected array $_layoutValues = []; /** * Get currently logged in member's theme * * @return static|SetupTheme|DevTheme */ public static function i(): static|SetupTheme|DevTheme { if ( Dispatcher::hasInstance() AND class_exists( '\IPS\Dispatcher', FALSE ) and Dispatcher::i()->controllerLocation === 'setup' ) { if ( static::$setupSkin === NULL ) { static::$setupSkin = new SetupTheme; } return static::$setupSkin; } else if (RECOVERY_MODE) { if ( static::$memberTheme === NULL ) { foreach( static::themes() as $theme ) { if ( ! $theme->isCustomized() ) { static::$memberTheme = $theme; break; } } } /* Set Admin Theme */ if ( static::$adminSkin === NULL ) { static::$adminSkin = static::master(); } /* Still here because all themes are customized? */ if ( static::$memberTheme === NULL ) { $newTheme = new Theme; $newTheme->permissions = Member::loggedIn()->member_group_id; $newTheme->save(); Lang::saveCustom( 'core', "core_theme_set_title_" . $newTheme->id, "IPS Default" ); static::$memberTheme = $newTheme; } return static::$memberTheme; } else if (IN_DEV) { if ( static::$inDevTheme === NULL ) { static::$inDevTheme = new DevTheme(); static::themes(); /* Add in the default theme properties (_data array, etc) */ $default = ( isset( static::$multitons[DEFAULT_THEME_ID] ) ) ? static::$multitons[DEFAULT_THEME_ID] : reset( static::$multitons ); foreach( $default as $k => $v ) { static::$inDevTheme->$k = $v; } } if ( Member::loggedIn()->isEditingTheme() ) { try { static::$memberTheme = static::byEditingMember(); } catch( Exception ) { static::$memberTheme = static::load( static::defaultTheme() ); } } return static::$inDevTheme; } else if ( Dispatcher::hasInstance() AND class_exists( '\IPS\Dispatcher', FALSE ) and Dispatcher::i()->controllerLocation === 'admin' ) { if ( static::$adminSkin === NULL ) { static::$adminSkin = static::master(); } return static::$adminSkin; } else { if ( static::$memberTheme === NULL ) { static::themes(); $setId = NULL; if ( Dispatcher::hasInstance() and Dispatcher::i()->controllerLocation == 'front' ) { $setId = static::currentThemeId(); if ( $setId ) { if( ! Request::i()->isAjax() and Session\Front::loggedIn() ) { /* Not an ajax call, so reset theme_id */ $setId = NULL; Session\Front::i()->setTheme(0); } else { try { if ( static::load( $setId )->canAccess() !== true ) { $setId = NULL; } } catch ( OutOfRangeException $ex ) { $setId = NULL; } } } } if ( ! $setId and Member::loggedIn()->skin and array_key_exists( Member::loggedIn()->skin, static::themes() ) ) { $setId = Member::loggedIn()->skin; if ( static::load( $setId )->canAccess() !== true ) { $setId = static::defaultTheme(); /* Restore default theme for member */ Member::loggedIn()->skin = $setId; if( Member::loggedIn()->member_id ) { Member::loggedIn()->save(); } } } else if ( ! $setId ) { $setId = static::defaultTheme(); } if ( Member::loggedIn()->isEditingTheme() ) { try { static::$memberTheme = static::byEditingMember(); } catch( Exception ) { static::$memberTheme = static::load( $setId ); } } else { static::$memberTheme = static::load( $setId ); } } return static::$memberTheme; } } /** * Get current theme id * * @return int|null */ public static function currentThemeId(): ?int { if ( Session\Front::loggedIn() ) { return Session\Front::i()->getTheme(); } return isset( Request::i()->cookie['theme'] ) ? Request::i()->cookie['theme'] : NULL; } /** * Themes * * @return array */ public static function themes(): array { if ( !static::$gotAll ) { static::$gotAll = true; if ( isset( Store::i()->themes ) ) { $rows = Store::i()->themes; } else { $rows = iterator_to_array( Db::i()->select( '*', 'core_themes', NULL, 'set_order' )->setKeyField('set_id') ); Store::i()->themes = $rows; } foreach( $rows as $id => $theme ) { if ( $theme['set_is_default'] ) { static::$defaultFrontendThemeSet = $theme['set_id']; } static::$multitons[ $theme['set_id'] ] = static::constructFromData( $theme ); } /* Load all theme settings upfront */ Setting::loadAllKeys(); } return static::$multitons; } /** * Returns only the visible themes for the member * * @return array */ public static function getThemesWithAccessPermission(): array { $visibleThemes = array(); foreach(Theme::roots() as $theme ) { if ( $theme->canAccess() ) { $visibleThemes[$theme->id] = $theme; } } return $visibleThemes; } /** * Fetch the master theme object * * @return Theme|DevTheme */ public static function master(): Theme|DevTheme { static::themes(); return static::$multitons[ static::$defaultFrontendThemeSet ]; } /** * Default Frontend Skin Set ID * * @return int */ public static function defaultTheme(): int { if ( !static::$gotAll ) { static::themes(); } return static::$defaultFrontendThemeSet; } /** * Switches the currently initialized theme during execution * * @param int $themeId Id of the theme to switch to * @param boolean $persistent Allow to persist through ajax calls * @return boolean * @note This will not check to ensure the member has permission to view the theme */ public static function switchTheme( int $themeId, bool $persistent=TRUE ): bool { static::themes(); try { /* Make sure the member theme is set */ Theme::i(); $class = static::i(); static::$memberTheme = $class::load( $themeId ); /* Store the ID in sessions so ajax loads correct theme */ if ( $persistent ) { Session\Front::i()->setTheme( $themeId ); } /* Flush loaded CSS */ Output::i()->cssFiles = array(); Front::baseCss(); /* App CSS */ $app = Front::i()->application; $app::outputCss(); } catch( OutOfRangeException $e ) { return FALSE; } return TRUE; } /** * @brief Prevent CSS from being loaded more than once */ protected static array $usedCss = array(); /** * Get CSS * This method is used to return the built CSS stored in the file objects system * * @param string $file Filename * @param string|null $app Application * @param string|null $location Location (e.g. 'admin', 'front') * @return array URLs to CSS files */ public function css( string $file, string $app=NULL, string $location=NULL ): array { $app = $app ?: Dispatcher::i()->application->directory; $location = $location ?: Dispatcher::i()->controllerLocation; $paths = explode( '/', $file ); $name = array_pop( $paths ); $path = ( count( $paths ) ) ? implode( '/', $paths ) : '.'; $cacheKey = $file . ',' . $app . ',' . $location . ',' . $this->_id; if ( isset( static::$usedCss[ $cacheKey ] ) ) { return static::$usedCss[ $cacheKey ]; } if ( $location === 'interface' ) { /* Legacy code support. The following directories were moved to /static */ if ( ( $app === "core" or $app === null) and preg_match( "/^(?:codemirror|fontawesome)/", $file ) ) { $file = "static/" . $file; } static::$usedCss[ $cacheKey ] = array( rtrim( Url::baseUrl( Url::PROTOCOL_RELATIVE ), '/' ) . "/applications/{$app}/interface/{$file}" ); return static::$usedCss[ $cacheKey ]; } $key = static::cssFilePrefix( $app, $location, $path ); if( in_array( $app, IPS::$ipsApps ) AND $file != 'custom.css' ) { static::$usedCss[ $cacheKey ] = array( rtrim( Url::baseUrl( Url::PROTOCOL_RELATIVE ), '/' ) . "/static/css/" . $key . "_{$name}" ); return static::$usedCss[ $cacheKey ]; } /* We use a different format for when we compile CSS outside first party static files */ $cssMapKey = static::makeBuiltTemplateLookupHash( $app, $location, str_contains( $file, '/' ) ? $file : './' . $file ); if( array_key_exists( $cssMapKey, $this->css_map ) ) { if ( $this->css_map[ $cssMapKey ] !== null ) { if( !in_array( $app, IPS::$ipsApps ) or $file == 'custom.css' ) { static::$usedCss[ $cacheKey ] = array( File::get( 'core_Theme', $this->css_map[ $cssMapKey ] )->url ); } } else { static::$usedCss[ $cacheKey ] = array(); } return static::$usedCss[ $cacheKey ]; } else { /* We're setting up, do nothing to avoid compilation requests when tables are incomplete */ if ( ! isset( Settings::i()->setup_in_progress ) OR Settings::i()->setup_in_progress ) { /* Do not store a cache here as this will break later when we want CSS */ //return array(); // If we don't let the CSS rebuild here as needed then the AdminCP upgrader can result in a completely unstyled page when interrupted } /* Map doesn't exist, try and create it */ if ( $this->compileCss( $app, $location, $path, $name ) === NULL ) { /* Do not store a cache here as this will break later when we want CSS */ return array(); } /* Still not here? Then add a key but as null to prevent it from attempting to rebuild on every single page * load thus hitting the DB multiple times */ $cssMap = $this->css_map; if ( ! in_array( $cssMapKey, array_keys( $this->css_map ) ) ) { $cssMap[ $cssMapKey ] = null; $this->css_map = $cssMap; $this->save(); } else { if( in_array( $app, IPS::$ipsApps ) AND $file != 'custom.css' ) { static::$usedCss[ $cacheKey ] = array( "/static/css/". $key . "_{$name}" ); } else { try { static::$usedCss[ $cacheKey ] = array( File::get( 'core_Theme', $this->css_map[ $cssMapKey ] )->url ); } catch( Exception ) { static::$usedCss[ $cacheKey ] = array(); } } return static::$usedCss[ $cacheKey ]; } } return array(); } /** * Returns a human brain digestable unique prefix for CSS files * * @param string $app * @param string $location * @param string $path * @return string Md5 Key */ public static function cssFilePrefix( string $app, string $location, string $path ): string { return str_replace( '/', '-', mb_strtolower( $app ) . '_' . mb_strtolower( $location ) . ( ( $path and $path != '.' ) ? '_' . mb_strtolower( $path ) : '' ) ); } /** * Resets the hook point flags in the core_theme_templates database * * @param string|null $app * @return void */ public static function rebuildHookPointFlags( string $app=null ): void { /* Reset flags */ $where = $app ? [ 'template_app=?', $app ] : []; Db::i()->update( 'core_theme_templates', [ 'template_has_hookpoints' => 0 ], $where ); /* We might need to get more fancy at some point but now that no one can edit templates, it's unlikely data-ips-hook= will be used in any other way. If it becomes a problem, we can simply iterate over and use xpath to check for reals. */ Db::i()->update( 'core_theme_templates', [ 'template_has_hookpoints' => 1 ], [ array_merge( $where, Db::i()->like( 'template_content', ' data-ips-hook=', FALSE, TRUE, TRUE ) ) ] ); } /** * Get Theme Resource (image, font, theme-specific JS, etc) * * @param string $path Path to resource * @param string|null $app Application key * @param string|null $location Location * @return Url|string|NULL URL to resource */ public function resource( string $path, string $app=NULL, string $location=NULL, $noProtocol=FALSE ): Url|string|null { $app = $app ?: Dispatcher::i()->application->directory; $location = $location ?: Dispatcher::i()->controllerLocation; $paths = explode( '/', $path ); $name = array_pop( $paths ); $path = ( count( $paths ) ) ? ( '/' . implode( '/', $paths ) . '/' ) : '/'; $key = static::makeBuiltTemplateLookupHash($app, $location, $path) . '_' .$name; if ( $location === 'interface' ) { return Url::internal( "applications/{$app}/interface{$path}{$name}", 'interface', NULL, array(), Url::PROTOCOL_RELATIVE ); } /* Are these core IPS apps? If so, we store these in the static directory and won't be in the resource map */ if( in_array( $app, IPS::$ipsApps ) and str_replace( '/', '', $path ) !== 'custom' ) { $url = Url::internal( 'static/resources/' . $app . '_' . static::makeBuiltTemplateLookupHash($app, $location, $path) . '_' .$name, 'static' ); if( $noProtocol ) { $url = $url->setScheme(NULL); } return $url; } /* Make sure that resource_map is an array */ if( !is_array( $this->resource_map ) ) { if( $json = json_decode( $this->resource_map, TRUE ) ) { $this->resource_map = $json; } else { return NULL; } } if ( in_array( $key, array_keys( $this->resource_map ) ) ) { if ( $this->resource_map[ $key ] === NULL ) { return NULL; } else { $url = File::get( 'core_Theme', $this->resource_map[ $key ] )->url; if( $noProtocol and $url instanceof Url ) { $url = $url->setScheme(NULL); } return $url; } } /* Still here? Map doesn't exist, try and create it */ $resourceMap = $this->resource_map; try { /* We're setting up, do nothing to avoid compilation requests when tables are incomplete */ if ( ! isset( Settings::i()->setup_in_progress ) OR Settings::i()->setup_in_progress ) { return NULL; } $flagKey = 'resource_compiling_' . $this->_id . '_' . md5( $key ); if ( static::checkLock( $flagKey ) ) { return NULL; } static::lock( $flagKey ); $resource = Db::i()->select( '*', 'core_theme_resources', array( 'resource_set_id=? AND resource_app=? AND resource_location=? AND resource_path=? AND resource_name=?', $this->id, $app, $location, $path, $name ) )->first(); $resourceMap[ $key ] = (string) File::create( 'core_Theme', $key, $resource['resource_data'], 'set_resources_' . $this->id, TRUE, NULL, FALSE ); Db::i()->update( 'core_theme_resources', array( 'resource_added' => time(), 'resource_filename' => $resourceMap[ $key ] ), array( 'resource_id=?', $resource['resource_id'] ) ); /* Save map */ $this->resource_map = $resourceMap; $this->save(); static::unlock( $flagKey ); if( in_array( $app, IPS::$ipsApps ) and $resource['resource_path'] !== '/custom/' ) { $url = rtrim( Settings::i()->base_url, '/' ) . '/' . $resourceMap[ $key ]; } else { $url = File::get( 'core_Theme', $resourceMap[ $key ] )->url; } if( $noProtocol and $url instanceof Url) { $url = $url->setScheme(NULL); } return $url; } catch( UnderflowException $e ) { /* Doesn't exist, add null entry to map to prevent it from being rebuilt on each page load */ $resourceMap[ $key ] = null; /* Save map */ $this->resource_map = $resourceMap; $this->save(); return NULL; } } /** * Get a template * * @param string $group Template Group * @param string|null $app Application key (NULL for current application) * @param string|null $location Template Location (NULL for current template location) * @return mixed * @throws UnexpectedValueException * @throws ErrorException */ public function getTemplate( string $group, string $app=NULL, string $location=NULL ): mixed { /* Do we have an application? */ if( $app === NULL ) { $app = Dispatcher::i()->application->directory; } /* How about a template location? */ if( $location === NULL ) { $location = Dispatcher::i()->controllerLocation; } $hashedKey = strtolower( static::makeBuiltTemplateLookupHash( $app, $location, $group ) . '_' . static::cleanGroupName( $group ) ); $key = 'template_' . $this->id . '_' . $hashedKey; $cachedObject = NULL; $class = "\\IPS\\Theme\\" . 'class_' . $app . '_' . $location . '_' . $group; /* First-party apps should be pre-compiled */ if( in_array( $app, IPS::$ipsApps ) AND !IN_DEV ) { $templateFile = ROOT_PATH . '/static/templates/' . $app . '_' . $location . '_' . $group . '.php'; if( file_exists( $templateFile ) ) { require_once( $templateFile ); return new SandboxedTemplate( new $class( $app, $location, $group ) ); } } /* We cannot use isset( static::$calledTemplates[ $key ] ) here because it fails with NULL while in_array does not */ if ( !in_array( $hashedKey, array_keys( static::$calledTemplates ) ) ) { /* If we don't have a compiled template, do that now */ if ( ! $cachedObject and !isset( Store::i()->$key ) ) { /* 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 ) { if ( $attempts === 5 ) { /* Rebuild in progress */ Log::debug( "Template store key: {$key} rebuilding and requested again ({$app}, {$location}, {$group})", "template_store_building" ); /* Since we can't do anything else, this ends up just being an uncaught exception - show the error page right away to avoid the unnecessary logging */ IPS::genericExceptionPage(); } $built = $this->compileTemplates( $app, $location, $group ); if ( $built === NULL ) { $attempts++; sleep(1); } else { break; } } /* Still no key? */ if ( ! isset( Store::i()->$key ) ) { Log::log( "Template store key: {$key} missing ({$app}, {$location}, {$group})", "template_store_missing" ); throw new ErrorException( 'template_store_missing' ); } } /* Load compiled template */ if ( $cachedObject ) { /* Init */ static::$calledTemplates[ $hashedKey ] = new SandboxedTemplate( $cachedObject ); } else { $compiledGroup = Store::i()->$key; if (DEBUG_TEMPLATES) { static::runDebugTemplate( $key, $compiledGroup ); } else { try { if ( @eval( $compiledGroup ) === FALSE ) { throw new TemplateException( 'Invalid Template', 1000, NULL, array( 'group' => $group, 'app' => $app, 'location' => $location ), $this ); } } catch ( ParseError $e ) { throw new UnexpectedValueException; } } /* Init */ static::$calledTemplates[ $hashedKey ] = new SandboxedTemplate( new $class( $app, $location, $group ) ); } } return static::$calledTemplates[ $hashedKey ]; } /** * Determine the path to load the file from * * @param string $file File path * @return string */ public static function getHookPath( string $file ): string { $path = ROOT_PATH; if ( CIC2 AND IPS::isThirdParty( $file ) ) { $path = SITE_FILES_PATH; } return $path; } /*! Active Record */ /** * Construct ActiveRecord from database row * * @param array $data Row from database table * @param bool $updateMultitonStoreIfExists Replace current object in multiton store if it already exists there? * @return ActiveRecord */ public static function constructFromData( array $data, bool $updateMultitonStoreIfExists = TRUE ): ActiveRecord { $obj = parent::constructFromData( $data, $updateMultitonStoreIfExists ); /* Extra set up */ $logo = json_decode( $obj->logo_data, true ); $obj->_data['logo'] = array( 'front' => null, 'front-dark' => null, 'mobile' => null, 'mobile-dark' => null ); if ( is_array( $logo ) ) { foreach( [ 'front', 'front-dark', 'mobile', 'mobile-dark' ] as $type ) { if ( isset( $logo[$type] ) ) { $obj->_data['logo'][$type] = $logo[$type]; } } } if ( ! is_array( $obj->_data['resource_map'] ) ) { if( $obj->id > 0 ) { if ( $imgMap = json_decode( $obj->_data['resource_map'], true ) ) { $obj->_data['resource_map'] = $imgMap; } else { $obj->_data['resource_map'] = array(); } } else { if( !isset( Store::i()->acp_resource_map ) ) { $obj->_data['resource_map'] = []; } else { $obj->_data['resource_map'] = Store::i()->acp_resource_map; } } } if ( ! is_array( $obj->_data['css_map'] ) ) { if ( $obj->id > 0 ) { if ( $cssMap = json_decode( $obj->_data['css_map'], true ) ) { $obj->_data['css_map'] = $cssMap; } else { $obj->_data['css_map'] = array(); } } else { $obj->_data['css_map'] = json_decode( Settings::i()->acp_css_map, true ); } } return $obj; } public function get_logo() : array { if( isset( $this->_data['logo'] ) ) { $update = false; foreach( $this->_data['logo'] as $type => $logo ) { if( isset( $logo['filename'] ) and empty( $logo['url'] ) ) { $update = true; /* Check to see if $logo['filename'] starts with a 'monthly_' */ if ( str_starts_with( $logo['filename'], 'monthly_' ) ) { /* Originally, logos were stored in monthly folders before they were moved to the theme resources area */ try { $this->_data['logo'][ $type ]['url'] = (string) File::get( 'core_Theme', $logo['filename'] )->url; } catch ( Exception $e ) { } } else { $this->_data['logo'][ $type ]['url'] = (string) $this->resource( 'custom/' . $logo['filename'], 'core', 'front' ); } } } if( $update ) { $this->logo_data = json_encode( $this->_data['logo'] ); $this->save(); } } return $this->_data['logo'] ?? []; } /** * Save resource map * * @param $value array Value to save * @return void */ public function set_resource_map( array $value ) : void { $this->_data['resource_map'] = json_encode( $value ); if ( $this->id === 0 ) { Store::i()->acp_resource_map = $value; } } /** * Save CSS map * * @param $value array Value to save */ public function set_css_map( array $value ) : void { $this->_data['css_map'] = json_encode( $value ); if ( $this->id === 0 ) { Settings::i()->changeValues( array( 'acp_css_map' => json_encode( $value ) ) ); } } /** * Make sure we clean out the custom CSS to prevent anyone adding closing style tags * * @param string $value * @return void */ public function set_custom_css( string $value ) : void { $value = str_replace( '', '', $value ); $this->_data['custom_css'] = $value; $key = $this->_id . '_theme_editor_custom_css'; if ( isset( Store::i()->$key ) ) { unset( Store::i()->$key ); } } /** * Clear any compiled JS when we save * * @param string $value * @return void */ public function set_core_js( string $value ) : void { $this->_data['core_js'] = $value; $key = $this->_id . '_theme_editor_custom_js'; if( isset( Store::i()->$key ) ) { unset( Store::i()->$key ); } } /** * Return the JS, compiled, from the cache * * @return string */ public function getCustomJsForOutput() : string { $key = $this->_id . '_theme_editor_custom_js'; if ( ! isset( Store::i()->$key ) ) { /* If we didn't specify a function name, create one */ $functionName = $functionName ?? "js_" . uniqid(); static::makeProcessFunction( static::fixResourceTags( (string) $this->core_js, 'front' ), $functionName, '', false, true ); $fqFunc = 'IPS\\Theme\\'. $functionName; $content = $fqFunc(); /* Replace any tags in the CSS */ Output::i()->parseFileObjectUrls( $content ); Store::i()->$key = $content; } return Store::i()->$key; } /** * Return the CSS, compiled, from the cache * * @return string */ public function getCustomCssForOutput(): string { /* Use pending changes, if we have any */ if( $data = $this->editingSessionData() ) { /* Compile the pending CSS but don't store it */ return static::compileCustomCss( $data['custom_css'] ?? '' ); } if( !$this->custom_css ) { return ''; } $key = $this->_id . '_theme_editor_custom_css'; if ( ! isset( Store::i()->$key ) ) { Store::i()->$key = static::compileCustomCss( (string) $this->custom_css ); } return Store::i()->$key; } /** * Compile custom CSS for output * * @param string $content * @param string|null $functionName * @return string */ public static function compileCustomCss( string $content, ?string $functionName=null ) : string { /* If we didn't specify a function name, create one */ $functionName = $functionName ?? "css_" . uniqid(); static::makeProcessFunction( static::fixResourceTags( $content, 'front' ), $functionName, '', FALSE, TRUE ); $fqFunc = 'IPS\\Theme\\'. $functionName; $content = static::minifyCss( $fqFunc() ); /* Replace any tags in the CSS */ Output::i()->parseFileObjectUrls( $content ); return $content; } /** * Get the source CSS that is compiled then returned in getCustomCssForOutput * * @return string */ public function getCustomCssForThemeEditorCodebox(): string { if( $data = $this->editingSessionData() ) { return $data['custom_css'] ?? ''; } return (string) $this->custom_css; } /** * Add in the header and footer special tags if they are not already present in globalTemplate * * @return void */ public function fixHeaderAndFooterTags(): void { /* Check the globalTemplate for the special tags we need */ try { $template = Db::i()->select( '*', 'core_theme_templates', [ 'template_set_id=? AND template_app=? AND template_group=? AND template_location=? and template_name=?', $this->_id, 'core', 'global', 'front', 'globalTemplate'] )->first(); $update = FALSE; if ( ! stristr( $template['template_content'], '{theme="headerHtml"}') ) { $template['template_content'] = preg_replace( '#(\n)#i', "$1\n{theme=\"headerHtml\"}\n", $template['template_content'] ); $update = TRUE; } if ( ! stristr( $template['template_content'], '{theme="footerHtml"}') ) { $template['template_content'] = preg_replace( '#(\n)#i', "{theme=\"footerHtml\"}\n$1", $template['template_content'] ); $update = TRUE; } if ( $update ) { Db::i()->update( 'core_theme_templates', [ 'template_content' => $template['template_content'] ], [ 'template_id=?', $template['template_id'] ] ); } } catch( UnderflowException $e ) { /* No custom template, so we're good */ } } /** * Save Changed Columns * * @return void */ public function save(): void { parent::save(); unset( Store::i()->themes ); /* If we are using a customized globalTemplate, we need to slap in the header/footer tags */ if ( ! empty( $this->custom_footer ) or ! empty( $this->customer_header ) ) { $this->fixHeaderAndFooterTags(); } foreach( [ 'header', 'footer' ] as $type ) { $key = 'custom_' . $type . '_' . $this->_id; if ( isset( Store::i()->$key ) ) { unset( Store::i()->$key ); } } /* Reset map arrays */ if ( !isset( $this->_data['resource_map'] ) OR ! is_array( $this->_data['resource_map'] ) ) { if ( isset( $this->_data['resource_map'] ) AND $imgMap = json_decode( $this->_data['resource_map'], true ) ) { $this->_data['resource_map'] = $imgMap; } else { $this->_data['resource_map'] = array(); } } if ( !isset( $this->_data['css_map'] ) OR ! is_array( $this->_data['css_map'] ) ) { if ( isset( $this->_data['css_map'] ) AND $cssMap = json_decode( $this->_data['css_map'], true ) ) { $this->_data['css_map'] = $cssMap; } else { $this->_data['css_map'] = array(); } } } /*! Node */ /** * Get header logo * * @return Url|string */ public function get_logo_front(): Url|string { return $this->logoImage( 'front' ); } /** * Return logo image * * @param string $type Type of logo image * @return Url|string */ protected function logoImage( string $type ): Url|string { if( !empty( $this->logo[ $type ]['url'] ) ) { try { return File::get( 'core_Theme', $this->logo[ $type ]['url'] )->url; } catch( Exception $e ) { return ''; } } return ''; } /** * [Node] Get Description * * @return string|null */ protected function get__description(): ?string { return Theme::i()->getTemplate( 'customization', 'core' )->themeDescription( $this ); } /** * Get the authors website * * * @return Url|null */ public function website(): ?Url { if ( $this->_data['author_url'] ) { return Url::createFromString( $this->_data['author_url'] ); } return NULL; } /** * Store the default so that we can determine if this is customized or not * * @var array[] */ public static array $defaultThemeEditorData = [ 'header' => [ 'logo' => 4, 'navigation' => 5, 'user' => 6, 'breadcrumb' => 7, 'search' => 9, ] ]; /** * @return array */ public function get_theme_editor_data() : array { $editorData = isset( $this->_data['theme_editor_data'] ) ? json_decode( $this->_data['theme_editor_data'], true ) : []; if( !isset( $editorData['header'] ) ) { $editorData['header'] = static::$defaultThemeEditorData['header']; } return $editorData; } /** * @param mixed $val * @return void */ public function set_theme_editor_data( mixed $val ) : void { $this->_data['theme_editor_data'] = is_array( $val ) ? json_encode( $val ) : null; } /** * [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 = NULL; if ( $this->is_default ) { $message = 'default_no_parenthesis'; $badge = array( 0 => 'positive', 1 => $message ); } if ( $this->update_data ) { $data = json_decode( $this->update_data, TRUE ); if( !empty($data['longversion']) AND $data['longversion'] > $this->long_version ) { $released = NULL; if( $data['released'] AND intval($data['released']) == $data['released'] AND strlen($data['released']) == 10 ) { $released = DateTime::ts( $data['released'] )->localeDate(); } else if( $data['released'] ) { $released = $data['released']; } $badge = array( 0 => 'new', 1 => '', 2 => Theme::i()->getTemplate( 'global', 'core' )->updatebadge( $data['version'], $data['updateurl'], $released ) ); } } return $badge; } /** * [Node] Clone the theme set * * @return void */ public function __clone() { if( $this->skipCloneDuplication === TRUE ) { return; } $originalId = $this->_data['id']; $title = Member::loggedIn()->language()->get( static::$titleLangPrefix . $originalId ); /* Unset custom properties */ foreach( array( 'resource_map', 'css_map', 'logo', 'name_translated', 'title' ) as $f ) { unset( $this->_data[ $f ] ); } $this->is_default = FALSE; parent::__clone(); /* Insert new language bit */ Lang::saveCustom( 'core', "core_theme_set_title_" . $this->id, sprintf( Member::loggedIn()->language()->get( 'theme_clone_copy_of' ), $title ) ); /* Make sure data objects are loaded correctly */ static::$gotAll = false; /* Save css/img maps */ Theme::load( $this->id )->saveSet(); Session::i()->log( 'acplogs__themeset_created', array( sprintf( Member::loggedIn()->language()->get( 'theme_clone_copy_of' ), Member::loggedIn()->language()->get( 'core_theme_set_title_' . $originalId ) ) => FALSE ) ); } /** * [Node] Does the currently logged-in user have permission to delete this node? * * @return bool */ public function canDelete(): bool { if ( IN_DEV AND $this->_id == DEFAULT_THEME_ID) { return FALSE; } if( $this->is_default ) { return FALSE; } foreach( $this->children( NULL ) as $childTheme ) { if( $childTheme->is_default ) { return FALSE; } } return parent::canDelete(); } /** * [Node] Delete the theme set * * @return void */ public function delete(): void { if ( IN_DEV AND $this->_id == DEFAULT_THEME_ID) { Output::i()->error( 'theme_error_not_available_in_dev', '2S140/1', 403, '' ); } if ( $this->is_default ) { Output::i()->error( 'core_theme_cannot_delete_default_theme', '2S162/1', 403, '' ); } /* Clear out existing built bits */ File::getClass('core_Theme')->deleteContainer( 'css_built_' . $this->_id ); File::getClass('core_Theme')->deleteContainer( 'set_resources_' . $this->_id ); $templates = $this->getAllTemplates(); foreach( $templates as $app => $v ) { foreach( $v as $location => $groups ) { foreach( $v[ $location ] as $group => $bits ) { foreach( $v[ $location ][ $group ] as $name => $data ) { /* Store it */ $key = strtolower( 'template_' . $this->_id . '_' .static::makeBuiltTemplateLookupHash( $app, $location, $group ) . '_' . static::cleanGroupName( $group ) ); unset( Store::i()->$key ); } } } } Db::i()->delete( 'core_theme_resources', array( 'resource_set_id=?', $this->_id ) ); Db::i()->delete( 'core_theme_css', array( 'css_set_id=?', $this->_id ) ); Db::i()->delete( 'core_theme_templates', array( 'template_set_id=?', $this->_id ) ); Db::i()->delete( 'core_sys_lang_words', array( 'word_theme=?', $this->_id ) ); Db::i()->delete( 'core_theme_templates_custom', [ 'template_set_id=?', $this->_id ] ); /* Delete theme editor settings */ Db::i()->delete( 'core_theme_editor_settings', [ 'setting_set_id=?', $this->_id ] ); /* Check the categories. If there are settings from another app, then we leave the category, and change the application key */ $categories = iterator_to_array( Db::i()->select( '*', 'core_theme_editor_categories', [ 'cat_set_id=?', $this->_id ] ) ); foreach( $categories as $category ) { try { $otherSetting = Db::i()->select( '*', 'core_theme_editor_settings', [ 'setting_category_id=? and setting_set_id != ?', $category['cat_id'], $this->_id ], 'setting_position', array( 0, 1 ) )->first(); Db::i()->update( 'core_theme_editor_categories', [ 'cat_set_id' => 0 ], [ 'cat_id=?', $category['cat_id'] ] ); } catch( UnderflowException ) { Db::i()->delete( 'core_theme_editor_categories', [ 'cat_id=?', $category['cat_id'] ] ); } } /** reset member skin */ Db::i()->update( 'core_members', array( 'skin' => 0 ), array('skin=?', $this->_id ) ); parent::delete(); unset( Store::i()->themes ); } /** * [Node] Add/Edit Form * * @param Form $form The form * @return void * @throws Exception */ public function form( Form &$form ): void { /* Check permission */ Dispatcher::i()->checkAcpPermission( 'theme_sets_manage' ); Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'customization/themes.css', 'core', 'admin' ) ); Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'admin_customization.js', 'core', 'admin' ) ); $form->attributes[ 'data-controller' ] = 'core.admin.customization.themeForm'; /* General */ if ( $this->id ) { Output::i()->title = Member::loggedIn()->language()->addToStack('core_theme_editing_set', FALSE, array( 'sprintf' => array( $this->_title ) ) ); $form->addTab( 'theme_set_tab__general' ); $groups = array(); foreach( Group::groups() as $group ) { if ( $group->g_bitoptions['gbw_change_layouts'] ) { $groups[] = Theme::i()->getTemplate( 'customization' )->groupLink( $group->g_id, $group->name ); } } $form->addHtml( Theme::i()->getTemplate( 'customization' )->layoutPermissionBlurb( $groups ) ); } else { Output::i()->title = Member::loggedIn()->language()->addToStack('theme_set_add_button'); $form->addTab( 'theme_set_tab__new_custom_set' ); } $form->add( new Translatable( 'core_theme_set_title', NULL, TRUE, array( 'app' => 'core', 'key' => ( $this->id ? "core_theme_set_title_{$this->id}" : NULL ) ) ) ); $id = $this->id; $form->add( new YesNo( 'core_theme_set_is_default' , $this->is_default, false, array( 'togglesOff' => array('core_theme_set_permissions') ), function( $val ) use ( $id ) { $where = array( array( 'set_is_default=1' ) ); if ( $id ) { $where[] = array('set_id<>?', $id ); } if ( !$val and !Db::i()->select( 'COUNT(*)', 'core_themes', $where )->first() ) { throw new DomainException('core_theme_set_is_default_error'); } } ) ); $form->add( new CheckboxSet( 'core_theme_set_permissions', ( $this->id ) ? ( $this->permissions === '*' ? '*' : explode( ",", $this->permissions ) ) : '*', FALSE, array( 'options' => Group::groups(), 'multiple' => TRUE, 'parse' => 'normal', 'unlimited' => '*', 'unlimitedLang' => 'all', 'impliedUnlimited' => TRUE ), NULL, NULL, NULL, 'core_theme_set_permissions' ) ); if (IN_DEV) { $form->add( new Text( 'theme_template_export_author_name', $this->author_name, false ) ); $form->add( new Text( 'theme_template_export_author_url' , $this->author_url, false ) ); $form->add( new Text( 'theme_update_check' , $this->update_check, false ) ); $form->add( new Text( 'theme_template_export_version' , $this->version ? $this->version : '1.0' , true, array( 'placeholder' => '1.0.0' ) ) ); $form->add( new Number( 'theme_template_export_long_version' , $this->long_version ?: 10000, true ) ); } /* Header, footer and custom CSS */ if ( Member::loggedIn()->hasAcpRestriction( 'core', 'customization', 'theme_templates_manage' ) ) { $form->addTab('core_theme_set_header_and_footer_header'); $form->add( new Codemirror( 'theme_simple_header_settings', $this->custom_header, FALSE, [ 'codeModeAllowedLanguages' => [ 'ipsphtml' ], 'height' => 600 ], function( $val ) { try { static::makeProcessFunction( $val, 'template_' . md5( mt_rand() . time() ), '', TRUE, FALSE ); } catch( ParseError $e ) { throw new DomainException( 'core_theme_template_parse_error' ); } }, NULL, NULL, 'theme_header' ) ); $form->add( new Codemirror( 'theme_simple_footer_settings', $this->custom_footer, FALSE, [ 'codeModeAllowedLanguages' => [ 'ipsphtml' ], 'height' => 600 ], function( $val ) { try { static::makeProcessFunction( $val, 'template_' . md5( mt_rand() . time() ), '', TRUE, FALSE ); } catch( ParseError $e ) { throw new DomainException( 'core_theme_template_parse_error' ); } }, NULL, NULL, 'theme_footer' ) ); } $form->canSaveAndReload = true; } /** * [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 { $parentButtons = array(); $buttons = array(); foreach( parent::getButtons( $url, $subnode ) as $button ) { $parentButtons[ $button['title'] ] = $button; } unset( $parentButtons['edit']['data']['ipsDialog'], $parentButtons['edit']['data']['ipsDialog-title'] ); $buttons['edit'] = $parentButtons['edit']; $buttons['resources'] = array( 'icon' => 'file-image', 'title' => 'theme_set_manage_resources', 'link' => Url::internal( "app=core&module=customization&controller=media&set_id={$this->_id}" ), 'data' => array() ); $buttons['copy'] = $parentButtons['copy']; if ( Member::loggedIn()->hasAcpRestriction( 'core', 'customization', 'theme_download_upload' ) AND !DEMO_MODE) { $buttons['upload'] = array( 'icon' => 'upload', 'title' => 'theme_set_import', 'link' => Url::internal( "app=core&module=customization&controller=themes&do=importForm&id={$this->_id}" ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack( 'theme_set_import_title', FALSE, array( 'sprintf' => array( $this->_title ) ) ) ) ); $buttons['download'] = array( 'icon' => 'download', 'title' => 'theme_set_export', 'link' => Url::internal( "app=core&module=customization&controller=themes&do=exportForm&id={$this->_id}" . ( ( IN_DEV or Settings::i()->theme_designer_mode ) ? '' : '&form_submitted=1' ) ) ); } if ( Member::loggedIn()->hasAcpRestriction( 'core', 'members', 'member_edit' ) ) { $buttons['member_theme_set'] = array( 'icon' => 'user', 'title' => 'theme_set_members', 'link' => $url->setQueryString( array( 'do' => 'setMembers', 'id' => $this->is_default ? 0 : $this->_id ) ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => $this->_title ) ); } if ( $this->canDelete() ) { $buttons['delete'] = $parentButtons['delete']; } if ( $this->isCustomized() ) { $buttons['theme_revert'] = [ 'icon' => 'reply', 'title' => 'theme_set_revert', 'link' => Url::internal( "app=core&module=customization&controller=themes&do=revertCustomizations&set_id={$this->_id}" ), 'data' => [ 'confirm' => 'true', 'confirmMessage' => Member::loggedIn()->language()->addToStack( 'theme_set_revert_explain' )] ]; } if ( IN_DEV or Settings::i()->theme_designer_mode ) { $buttons['theme_settings'] = [ 'icon' => 'sliders', 'title' => 'theme_set_editor_settings', 'link' => Url::internal( "app=core&module=customization&controller=themeeditor&set_id={$this->_id}" ) ]; $buttons['theme_core_cssjs'] = [ 'icon' => 'file-code', 'title' => 'theme_set_designer_core_cssjs', 'link' => Url::internal( "app=core&module=customization&controller=themes&do=designerCore&set_id={$this->_id}" ) ]; $buttons['custom_templates'] = array( 'icon' => 'code', 'title' => 'theme_set_manage_templates_custom', 'link' => Url::internal( "app=core&module=customization&controller=customtemplates&type=themes&set_id={$this->_id}" ), 'data' => array() ); } return $buttons; } /** * [Node] Save Add/Edit Form * * @param array $values Values from the form * @return mixed */ public function saveForm( array $values ): mixed { $creating = FALSE; /* Create if necessary */ if ( ! $this->id ) { $creating = TRUE; $this->long_version = Application::load( 'core' )->long_version; $this->save(); } if ( isset( $values['core_theme_set_new_import'] ) ) { /* Move it to a temporary location */ $tempFile = tempnam( TEMP_DIRECTORY, 'IPS' ); move_uploaded_file( $values['core_theme_set_new_import'], $tempFile ); /* Store values */ $key = 'core_theme_import_' . md5_file( $tempFile ); Store::i()->$key = array( 'apps' => 'all', 'resources' => true, 'html' => true, 'css' => true ); /* Initate a redirector */ Output::i()->redirect( Url::internal( 'app=core&module=customization&controller=themes&do=import' )->setQueryString( array( 'file' => $tempFile, 'key' => md5_file( $tempFile ), 'id' => $this->id ) ) ); } else { /* Name */ Lang::saveCustom( 'core', "core_theme_set_title_{$this->id}", $values['core_theme_set_title'] ); $dataChanged = false; $save = array(); if ( $values['core_theme_set_is_default'] ) { Db::i()->update( 'core_themes', array( 'set_is_default' => 0 ), array( 'set_id<>?', $this->id ) ); $dataChanged = true; } if ( $dataChanged OR IN_DEV) { if (IN_DEV) { $save['set_author_name'] = $values['theme_template_export_author_name']; $save['set_author_url'] = $values['theme_template_export_author_url']; $save['set_update_check'] = $values['theme_update_check']; $save['set_version'] = $values['theme_template_export_version']; $save['set_long_version'] = $values['theme_template_export_long_version']; } $this->save(); $this->saveSet( $save ); } } $this->is_default = $values['core_theme_set_is_default']; $this->permissions = ( $values['core_theme_set_permissions'] === '*' ) ? '*' : implode( ',', $values['core_theme_set_permissions'] ); $this->css_updated = time(); if ( isset( $values['theme_simple_header_settings'] ) ) { $this->custom_header = $values['theme_simple_header_settings']; } if ( isset( $values['theme_simple_footer_settings'] ) ) { $this->custom_footer = $values['theme_simple_footer_settings']; } if ( isset( $values['theme_simple_css'] ) ) { $this->custom_css = $values['theme_simple_css']; } $headerKey = 'custom_header_' . $this->id; $footerKey = 'custom_footer_' . $this->id; unset( Store::i()->$headerKey); unset( Store::i()->$footerKey); /* Remove compiled templates to ensure the globalTemplate gets the latest updates */ static::deleteCompiledTemplate( 'core', 'front', 'global', $this->id ); /* Remove compiled CSS */ static::deleteCompiledCss( 'core', 'front', 'custom', 'custom.css', $this->id ); /* Finally, save the theme */ $this->save(); return NULL; } /** * Return the value of a layout setting * * @param string $what * @return string * @throws InvalidArgumentException */ public function getLayoutValue( string $what ): string { /* Check the session data first */ $sessionData = $this->editingSessionData()['data'] ?? []; /* For consistency, the theme editor prefixes layout options with 'layout_' */ if( isset( $sessionData['layouts'] ) and isset( $sessionData['layouts'][ 'layout_' . $what ] ) ) { return $sessionData['layouts'][ 'layout_' . $what ]; } elseif( isset( $sessionData['vars'] ) and isset( $sessionData['vars'][ 'layout_' . $what ] ) ) { return $sessionData['vars'][ 'layout_' . $what ]; } if ( empty( $this->_layoutValues ) ) { $this->_layoutValues = $this->view_options ? json_decode( $this->view_options, TRUE ) : []; } if ( isset( $this->_layoutValues[ $what ] ) ) { return $this->_layoutValues[ $what ]; } /* Got a default? */ $vars = $this->getCssVariables(); if( isset( $vars[ 'layout_' . $what ] ) ) { return $vars[ 'layout_' . $what ]; } /* Can't really help you */ throw new InvalidArgumentException( 'Invalid layout value' ); } /** * Return the available layout options * * @return array */ public function getAvailableLayoutOptionsForThemeEditor(): array { $return = []; foreach( Application::enabledApplications() as $dir => $app ) { $return = array_merge( $return, $app->getThemeLayoutOptionsForThisPage() ); } /* loop through $return array and prepend 'layout_' to each value */ foreach ( $return as &$value ) { $value = 'layout_' . $value; } return $return; } /** * Build resource map of "human URL" to File Object URL * * @param array|string|null $app App (e.g. core, forum) * @return void */ public function buildResourceMap( array|string $app=NULL ) { $flagKey = 'resource_compiling_' . $this->_id . '_' . $app; if ( static::checkLock( $flagKey ) ) { return NULL; } static::lock( $flagKey ); $resourceMap = $this->resource_map; $where = ( $app !== null ) ? array( 'resource_set_id=? and resource_app=?', $this->_id, $app ) : array('resource_set_id=?', $this->_id ); foreach (Db::i()->select( '*', 'core_theme_resources', $where ) as $row ) { $name = static::makeBuiltTemplateLookupHash( $row['resource_app'], $row['resource_location'], $row['resource_path'] ) . '_' . $row['resource_name']; if ( $row['resource_filename'] ) { $resourceMap[$name] = $row['resource_filename']; } else { /* If there is no filename, then it has yet to be compiled so do not add it to the resource map as it prevents it being compiled later */ unset( $resourceMap[$name] ); } } $this->resource_map = $resourceMap; /* Any time we rebuild the resources, we need to rebuild the logos */ $logoData = isset( $this->_data['logo_data'] ) ? json_decode( $this->_data['logo_data'], true ) : []; foreach( $logoData as $type => $logo ) { if( isset( $logo['url'] ) ) { unset( $logoData[ $type ]['url'] ); } } $this->logo_data = json_encode( $logoData ); $this->save(); static::unlock( $flagKey ); } /** * Copy all resources from set $id to this set * Theme resources should be raw binary data everywhere (filesystem and DB) except in the theme XML download where they are base64 encoded. * * @param int $id ID to copy from. 0 is the 'master' resources (same as the default when first installed) * @param int $offset * @param int|null $limit * @return void */ public function copyResourcesFromSet( int $id=0, int $offset=0, ?int $limit=null ): void { $resourceMap = $this->resource_map ?? []; if( !is_array( $resourceMap ) ) { $resourceMap = json_decode( $resourceMap, true ); } if( $limit !== null ) { $limit = [ $offset, $limit ]; } foreach (Db::i()->select( '*', 'core_theme_resources', array( 'resource_set_id=?', $id ), 'resource_id', $limit ) as $data ) { $key = static::makeBuiltTemplateLookupHash($data['resource_app'], $data['resource_location'], $data['resource_path']) . '_' . $data['resource_name']; if ( $data['resource_data'] ) { try { $fileName = (string) File::create( 'core_Theme', $key, $data['resource_data'], 'set_resources_' . $this->_id, FALSE, NULL, FALSE ); Db::i()->insert( 'core_theme_resources', array( 'resource_set_id' => $this->_id, 'resource_app' => $data['resource_app'], 'resource_location' => $data['resource_location'], 'resource_path' => $data['resource_path'], 'resource_name' => $data['resource_name'], 'resource_added' => time(), 'resource_filename' => $fileName, 'resource_data' => $data['resource_data'], 'resource_user_edited' => $data['resource_user_edited'] ) ); $resourceMap[ $key ] = $fileName; } catch( Exception $e ) { } } } /* Update theme map */ $this->resource_map = $resourceMap; $this->save(); } /** * Copy all logos from a set * * @param int $id ID to copy from * @return void */ public function copyLogosFromSet( int $id ): void { try { $original = static::load( $id ); } catch( OutOfRangeException $e ) { throw new OutOfRangeException("CANNOT_LOAD_THEME"); } if ( $original->logo_data === NULL ) { return; } /* Allow SVGs without the obscure hash removing the file extension */ File::$safeFileExtensions[] = 'svg'; $currentLogos = json_decode( $this->logo_data, TRUE ); $logos = array(); foreach ( json_decode( $original->logo_data, TRUE ) as $file => $data ) { if ( isset( $currentLogos[ $file ] ) ) { continue; } if( isset( $data['url'] ) and $data['url'] ) { try { /* Create new file */ $original = File::get( 'core_Theme', $data['url'] ); $newImage = File::create( 'core_Theme', $original->originalFilename, $original->contents() ); $data['url'] = (string) $newImage; $data['filename'] = $newImage->originalFilename; $logos[$file] = $data; } catch ( Exception $e ) {} } } $this->logo_data = json_encode( $logos ); $this->save(); } /** * Copy the editor settings and categories from the specified theme * * @param int $id * @return void */ public function copyEditorSettings( int $id ) : void { $categoryMapping = []; foreach( new ActiveRecordIterator( Db::i()->select( '*', Category::$databaseTable, [ 'cat_set_id=?', $id ], 'cat_parent, cat_position' ), Category::class ) as $cat ) { /* Copy the category and change the theme ID */ $newCategory = clone $cat; $newCategory->set_id = $this->_id; if( $newCategory->parent and array_key_exists( $cat->parent, $categoryMapping ) ) { $newCategory->parent = $categoryMapping[ $cat->parent ]; } $newCategory->save(); /* Map the category IDs, we'll need them later */ $categoryMapping[ $cat->id ] = $newCategory->id; } /* Now loop through the settings */ foreach( new ActiveRecordIterator( Db::i()->select( '*', Setting::$databaseTable, [ 'setting_set_id=?', $id ], 'setting_position' ), Setting::class ) as $setting ) { $newSetting = clone $setting; $newSetting->set_id = $this->_id; $newSetting->category_id = $categoryMapping[ $newSetting->category_id ] ?? 0; $newSetting->save(); } } /** * Copy any custom templates from the original theme * * @param int $id * @return void */ public function copyCustomTemplates( int $id ) : void { foreach( new ActiveRecordIterator( Db::i()->select( '*', 'core_theme_templates_custom', [ 'template_set_id=?', $id ] ), CustomTemplate::class ) as $template ) { $newTemplate = clone $template; $newTemplate->set_id = $this->id; $newTemplate->updated = time(); $newTemplate->save(); } } /** * Install the theme editor settings for this theme * * @return void */ public function installThemeEditorSettings() : void { } /** * Compile all CSS for a first party application * * @param string $app * @return void */ public static function compileStatic( string $app='' ) : void { if( !in_array( $app, IPS::$ipsApps ) ) { return; } $themeXmlFile = Application::load( $app )->getApplicationPath() . "/data/theme.xml"; if( ! file_exists( $themeXmlFile ) ) { return; } /* Try to prevent timeouts to the extent possible */ $cutOff = null; if( $maxExecution = @ini_get( 'max_execution_time' ) ) { /* If max_execution_time is set to "no limit" we should add a hard limit to prevent browser timeouts */ if ( $maxExecution == -1 ) { $maxExecution = 30; } $cutOff = time() + ( $maxExecution * .5 ); } /* Remove old templates... */ $files = glob( ROOT_PATH . '/static/templates/' . $app . '_*' ); foreach( $files as $file ) { @unlink( $file ); } /* Remove old CSS... */ $files = glob( ROOT_PATH . '/static/css/' . $app . '_*' ); foreach( $files as $file ) { @unlink( $file ); } /* Remove old resources... */ $files = glob( ROOT_PATH . '/static/resources/' . $app . '_*' ); foreach( $files as $file ) { @unlink( $file ); } /* Open XML file */ $xml = Xml\XMLReader::safeOpen( $themeXmlFile ); $xml->read(); $functions = array(); $styles = array(); while( $xml->read() ) { if( $xml->nodeType != XMLReader::ELEMENT ) { continue; } switch( $xml->name ) { case 'template': $template = array( 'group' => $xml->getAttribute('template_group'), 'name' => $xml->getAttribute('template_name'), 'variables' => $xml->getAttribute('template_data'), 'content' => $xml->readString(), 'location' => $xml->getAttribute('template_location') ); if( !isset( $functions[ $template['location'] ][ $template['group'] ] ) ) { $functions[ $template['location'] ][ $template['group'] ] = []; } $functions[ $template['location'] ][ $template['group'] ][ $template['name'] ] = static::compileTemplate( $template['content'], $template['name'], $template['variables'], true, false, $app, $template['location'], $template['group'] ); break; case 'css': $location = $xml->getAttribute( 'css_location' ); $path = $xml->getAttribute( 'css_path' ); $name = $xml->getAttribute( 'css_name' ); $content = $xml->readString(); if( !isset( $styles[ $location ][ $path ] ) ) { $styles[ $location ][ $path ] = []; } $styles[ $location ][ $path ][ $name ] = $content; break; case 'resource': static::addResource( array( 'app' => $app, 'location' => $xml->getAttribute('location'), 'path' => $xml->getAttribute('path'), 'name' => $xml->getAttribute('name'), 'content' => base64_decode( $xml->readString() ), ) ); break; } } /* Loop through all functions and write them to the static directory */ $path = '/static/templates/'; if( !is_dir( ROOT_PATH. $path ) ) { mkdir( ROOT_PATH . $path, IPS_FOLDER_PERMISSION, TRUE ); } foreach( $functions as $location => $groups ) { foreach( $groups as $group => $templates ) { /* Put them in a class */ $templateContent = << $paths ) { foreach( $paths as $path => $data ) { if( $path == '.' OR empty( $path ) ) { foreach ( $data as $cssName => $cssData ) { /* forums/front/widgets.css should be written as forums_front_widgets.css */ static::writeCss( [ 'css_app' => $app, 'css_location' => $location, 'css_path' => '.', 'css_name' => $cssName, 'css_content' => $cssData, 'css_set_id' => 0 ] ); } } else { /* Special rules... */ if ( isset( static::$buildGrouping['css'][ $app ][ $location ] ) and in_array( $path, static::$buildGrouping['css'][ $app ][ $location ] ) ) { /* We want this packaged up as one file */ static::writeCss( [ 'css_app' => $app, 'css_location' => $location, 'css_path' => $path, 'css_name' => $path . '.css', 'css_content' => implode( "\n\n", $data ), 'css_set_id' => 0 ] ); } else { /* Otherwise we don't (e.g. core/front/styles/foo.css should be written as core_front_styles_foo.css) */ foreach( $data as $cssName => $cssData ) { static::writeCss( array( 'css_app' => $app, 'css_location' => $location, 'css_path' => $path, 'css_name' => $cssName, 'css_content' => $cssData, 'css_set_id' => 0 ) ); } } } } } } /** * Compile CSS ready for non IN_DEV use. This replaces any HTML logic such as {resource="foo.png"} with full URLs * * @param array|string $app CSS app (e.g. core, forum) * @param array|string $location CSS location (e.g. admin,global,front) * @param array|string $group CSS group (e.g. custom, framework) * @param string $name CSS name (e.g. foo.css) * @return boolean|null */ public function compileCss( array|string $app='', array|string $location='', array|string $group='', string $name='' ): ?bool { $flagKey = 'css_compiling_' . $this->_id . '_' . md5( $app . ',' . $location . ',' . $name ); if ( static::checkLock( $flagKey ) ) { return NULL; } static::lock( $flagKey ); /* Deconstruct build grouping */ if ( $name !== null ) { if ( isset( static::$buildGrouping['css'][ $app ][ $location ] ) ) { foreach( static::$buildGrouping['css'][ $app ][ $location ] as $grouped ) { if ( str_replace( '.css', '', $name ) == $grouped ) { $group = $grouped; } } } } $css = $this->getAllCss( $app, $location, $group ); $cssMap = $this->css_map; if ( $name === null ) { /* Clear out existing built bits */ File::getClass('core_Theme')->deleteContainer( 'css_built_' . $this->_id ); $cssMap = array(); } foreach( $css as $app => $v ) { foreach( $css[ $app ] as $location => $paths ) { $built = array(); foreach( $css[ $app ][ $location ] as $path => $data ) { foreach( $css[ $app ][ $location ][ $path ] as $cssName => $cssData ) { if ( isset( static::$buildGrouping['css'][ $app ][ $location ] ) AND in_array( $path, static::$buildGrouping['css'][ $app ][ $location ] ) ) { if ( $name === null OR $name == ( $path . '.css' ) ) { $key = static::makeBuiltTemplateLookupHash( $app, $location, $path ); if ( isset( $built[ $key ] ) ) { $built[ $key ]['css_content'] .= "\n\n" . $cssData['css_content']; } else { $cssData['css_name'] = $path . '.css'; $cssData['css_path'] = '.'; $built[ $key ] = $cssData; } } } else { if ( $name === null OR $name == $cssData['css_name'] ) { $store = static::makeBuiltTemplateLookupHash( $app, $location, $cssData['css_path'] . '/' . $cssData['css_name'] ); $cssMap[ $store ] = (string) static::writeCss( $cssData ); } } } } /* Write combined css */ if ( count( $built ) ) { foreach( $built as $id => $cssData ) { $store = static::makeBuiltTemplateLookupHash( $app, $location, $cssData['css_path'] . '/' . $cssData['css_name'] ); $cssMap[ $store ] = (string) static::writeCss( $cssData ); } } } } if ( ! empty( trim( $this->custom_css ) ) ) { $store = static::makeBuiltTemplateLookupHash( 'core', 'front', 'custom/custom.css'); $cssMap[ $store ] = (string) static::writeCss( [ 'css_content' => $this->custom_css, 'css_name' => 'custom.css', 'css_path' => 'custom', 'css_location' => 'front', 'css_app' => 'core', 'css_set_id' => $this->id ] ); } $this->css_map = $cssMap; $this->save(); static::unlock( $flagKey ); return TRUE; } /** * Build Templates ready for non IN_DEV use * This fetches all templates in a group, converts HTML logic into ready to eval PHP and stores as a single PHP class per template group * * @param array|string|null $app Templates app (e.g. core, forum) * @param array|string|null $location Templates location (e.g. admin,global,front) * @param array|string|null $group Templates group (e.g. forms, members) * @return boolean|null */ public function compileTemplates( array|string $app=null, array|string $location=null, array|string $group=null ): ?bool { $flagKey = 'template_compiling_' . $this->_id . '_' . md5( $app . ',' . $location . ',' . $group ); if ( static::checkLock( $flagKey ) ) { return NULL; } static::lock( $flagKey ); $templates = $this->getAllTemplates( $app === null ? '' : $app, $location === null ? '' : $location, $group === null ? '' : $group ); foreach( $templates as $app => $v ) { foreach( $templates[ $app ] as $location => $groups ) { foreach( $templates[ $app ][ $location ] as $group => $bits ) { /* Build all the functions */ $functions = array(); foreach( $templates[ $app ][ $location ][ $group ] as $name => $data ) { $functions[ $name ] = static::compileTemplate( $data['template_content'], $name, $data['template_data'], true, false, $app, $location, $group ); } /* Put them in a class */ $template = <<_id . '_' .static::makeBuiltTemplateLookupHash( $app, $location, $group ) . '_' . static::cleanGroupName( $group ) ); Store::i()->$key = str_replace( 'namespace _NAMESPACE_', 'namespace IPS\Theme', $template ); } } } static::unlock( $flagKey ); return TRUE; } /** * Clean the group name * * @param string $name The name to clean * @return string */ public static function cleanGroupName( string $name ): string { return str_replace( '-', '_', Friendly::seoTitle( $name ) ); } /** * Find templates in a template group * * @param string $group Template group to search in * @param string|null $app Application key * @param string|null $location Template location * @return array */ public static function findTemplatesByGroup( string $group, string $app=NULL, string $location=NULL ): array { if (IN_DEV) { return Theme::findTemplatesByGroup( $group, $app, $location ); } $where = array( array( 'template_group=?', $group ) ); if( $app !== NULL ) { $where[] = array( 'template_app=?', $app ); } if( $location !== NULL ) { $where[] = array( 'template_location=?', $location ); } $results = array(); foreach(Db::i()->select( 'template_name', 'core_theme_templates', $where ) as $result ) { $results[ $result['template_name'] ] = $result['template_name']; } return array_unique( $results ); } /** * [Node] Does the currently logged in user have permission to edit permissions for this node? * * @return bool */ public function canManagePermissions(): bool { return false; } /** * User can access this theme? * * @return bool */ public function canAccess(): bool { if ( $this->is_default ) { return true; } return ( $this->permissions === '*' OR Member::loggedIn()->inGroup( explode( ',', $this->permissions ) ) ); } /** * Get theme header and footer * * @param string $type header|footer * @return string */ public function getHeaderAndFooter( string $type = 'header' ): string { $key = "custom_{$type}_{$this->_id}"; $content = ""; switch( $type ) { case 'header': $content = $this->custom_header; break; case 'footer': $content = $this->custom_footer; break; } if( $content ) { if ( ! isset( Store::i()->$key ) ) { Store::i()->$key = static::compileTemplate( $content, $key ); } static::runProcessFunction( Store::i()->$key, $key ); $themeFunction = 'IPS\\Theme\\'. $key; return $themeFunction(); } return ""; } /** * Get all CSS. Raw means {resource..} tags and uncompiled * * @param array|string|null $app CSS app (e.g. core, forum) * @param array|string|null $location CSS location (e.g. admin,global,front) * @param array|string|null $path CSS group (e.g. custom, framework) * @param int|null $returnType Determines the content returned * @param bool $returnThisSetOnly * @return array */ public function getAllCss( array|string|null $app= null, array|string|null $location=array(), array|string|null $path=array(), int $returnType=null, bool $returnThisSetOnly=false ): array { $returnType = ( $returnType === null ) ? static::RETURN_ALL : $returnType; $app = ( is_string( $app ) AND $app != '' ) ? array( $app ) : $app; $location = ( is_string( $location ) AND $location != '' ) ? array( $location ) : $location; $path = ( is_string( $path ) AND $path != '' ) ? array( $path ) : $path; $where = array(); $css = array(); $where[] = "css_set_id IN (" . $this->_id . ", 0)"; if ( is_array( $app ) AND count( $app ) ) { $where[] = "css_app IN ('" . implode( "','", $app ) . "')"; } if ( is_array( $location ) AND count( $location ) ) { $where[] = "css_location IN ('" . implode( "','", $location ) . "')"; } if ( is_array( $path ) AND count( $path ) ) { $where[] = "css_path IN ('" . implode( "','", $path ) . "')"; } $select = ( $returnType & static::RETURN_BIT_NAMES ) ? 'css_set_id, css_app, css_location, css_path, css_id, css_name, css_modules, css_attributes, css_hidden' : '*'; foreach( Db::i()->select( $select . ', INSTR(\',' . $this->_id . ', 0,\', CONCAT(\',\',css_set_id,\',\') ) as theorder', 'core_theme_css', implode( " AND ", $where ), 'css_location, css_path, css_name, theorder desc' ) as $row ) { /* App installed? */ if ( ! Application::appIsEnabled( $row['css_app'] ) ) { continue; } /* CSS not to be included */ if ( ! empty( $row['css_hidden'] ) ) { continue; } /* This set only? */ if ( $returnThisSetOnly === true ) { if ( $row['css_set_id'] != $this->_id ) { continue; } } /* ensure set ID is correct */ $row['css_set_id'] = $this->_id; $row['CssKey'] = str_replace( '.css', '', $row['css_app'] . '_' . $row['css_location'] . '_' . $row['css_path'] . '_' . $row['css_name'] ); $row['jsDataKey'] = str_replace( '.', '--', $row['CssKey'] ); if ( $returnType & static::RETURN_ALL_NO_CONTENT ) { unset( $row['css_content'] ); $css[ $row['css_app'] ][ $row['css_location'] ][ $row['css_path'] ][ $row['css_name'] ] = $row; } else if ( $returnType & static::RETURN_ALL ) { $css[ $row['css_app'] ][ $row['css_location'] ][ $row['css_path'] ][ $row['css_name'] ] = $row; } else if ( $returnType & static::RETURN_BIT_NAMES ) { $css[ $row['css_app'] ][ $row['css_location'] ][ $row['css_path'] ][] = $row['css_name']; } else if ( $returnType & static::RETURN_ARRAY_BIT_NAMES ) { $css[] = $row['css_name']; } } if ( $returnType & static::RETURN_ARRAY_BIT_NAMES ) { sort( $css ); return $css; } ksort( $css ); /* Pretty sure Mark can turn this into a closure */ foreach( $css as $k => $v ) { ksort( $css[ $k ] ); foreach( $css[ $k ] as $ak => $av ) { ksort( $css[ $k ][ $ak ] ); if ( $returnType & static::RETURN_ALL ) { foreach( $css[ $k ][ $ak ] as $bk => $bv ) { ksort( $css[ $k ][ $ak ][ $bk ] ); } } } } return $css; } /** * @var array The CSS variables for this theme, cached to prevent multiple DB calls */ protected array $cssVariablesCached = []; /** * Return the CSS variables for this theme * * @param int $flag * @return array|null */ public function getCssVariables( int $flag=0 ): array|null { if ( ! isset( $this->cssVariablesCached[ $flag ] ) ) { $customValues = []; /* Add custom settings that were added from other apps/themes */ foreach( new ActiveRecordIterator( Db::i()->select( '*', 'core_theme_editor_settings', [ [ "(setting_app not in ('" . implode( "','", IPS::$ipsApps ) . "') OR (setting_set_id != ? and setting_set_id=?) )", 0, $this->_id ] ] ), Setting::class ) as $setting ) { $value = $setting->value( true ); if( is_array( $value ) ) { foreach( $value as $_k => $_v ) { $customValues[ $_k . '__' . $setting->key ] = $_v; } } else { $customValues[ $setting->key ] = $value; } } if( !( $flag & static::FORCE_DEFAULT ) ) { if ( ! empty( $this->css_variables ) and $variables = json_decode( $this->css_variables, true ) ) { foreach( $variables as $k => $v ) { $customValues[ $k ] = $v; } } if( ! empty( $this->view_options ) and $layouts = json_decode( $this->view_options, true ) ) { foreach( $layouts as $k => $v ) { $customValues[ 'layout_' . $k ] = $v; } } } /* Add any pending changes */ if( !( $flag & static::FORCE_DEFAULT ) ) { $data = $this->editingSessionData()['data'] ?? []; foreach( $data as $group => $vars ) { if( is_array( $vars ) ) { $customValues = array_merge( $customValues, $vars ); } } } if ( $flag & static::CUSTOM_ONLY ) { /* We just want the custom/changed variables only */ $this->cssVariablesCached[ $flag ] = $customValues; return $this->cssVariablesCached[ $flag ]; } /* If we are still here, we want to merge in any custom variables with the default variables (from the master set) */ if ( IN_DEV ) { /* Try the dev theme */ $cssData = file_get_contents( ROOT_PATH . "/applications/core/dev/css/global/framework/" . static::CSS_VARIABLE_FILENAME ); } else { try { /* Try the master theme in the core/global/framework path if not IN_DEV */ $cssData = Db::i()->select( 'css_content', 'core_theme_css', ['css_set_id=? AND css_app=? AND css_location=? AND css_path=? AND css_name=?', 0, 'core', 'global', 'framework', static::CSS_VARIABLE_FILENAME] )->first(); } catch( UnderflowException ) { $cssData = ''; } } // Regular expression to find CSS variables $pattern = '/--([\w-]+)\s*:\s*([^;]+)/'; preg_match_all( $pattern, $cssData, $matches ); $variableNames = $matches[1] ?? []; $variableValues = $matches[2] ?? []; /* Include any editor settings that are not in the main CSS file */ foreach( new ActiveRecordIterator( Db::i()->select( '*', 'core_theme_editor_settings' ), Setting::class ) as $setting ) { try { $value = $setting->value( ( $flag & static::FORCE_DEFAULT ) ); if( is_array( $value ) ) { foreach( $value as $_k => $_v ) { $variableNames[] = $_k . '__' . $setting->key; $variableValues[] = $_v; } } else { $variableNames[] = $setting->key; $variableValues[] = $value; } } catch( InvalidArgumentException $e ) { Log::debug( $e, 'template_error' ); $variableValues[] = ""; } } $this->cssVariablesCached[ $flag ] = array_merge( array_combine( $variableNames, $variableValues ), $customValues ); } return $this->cssVariablesCached[ $flag ]; } /** * Return any saved CSS variables for