Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @subpackage Content * @since 15 Jan 2014 */ namespace IPS\cms\Pages; /* To prevent PHP errors (extending class does not exist) revealing path */ use DomainException; use ErrorException; use Exception; use InvalidArgumentException; use IPS\Application; use IPS\Application\Module; use IPS\cms\Databases; use IPS\cms\extensions\core\FrontNavigation\Pages; use IPS\cms\Templates; use IPS\Content\Search\Index; use IPS\Content\ViewUpdates; use IPS\core\extensions\core\FrontNavigation\Menu; use IPS\core\FrontNavigation; use IPS\core\Feature; use IPS\Data\Store; use IPS\Db; use IPS\Dispatcher; use IPS\Dispatcher\Front; use IPS\File; use IPS\Helpers\Form; use IPS\Helpers\Form\CheckboxSet; use IPS\Helpers\Form\Node; use IPS\Helpers\Form\Radio; use IPS\Helpers\Form\Select; use IPS\Helpers\Form\Text; use IPS\Helpers\Form\TextArea; use IPS\Helpers\Form\Translatable; use IPS\Helpers\Form\YesNo; use IPS\Http\Url; use IPS\Http\Url\Friendly; use IPS\Lang; use IPS\Member; use IPS\Member\Group; use IPS\Node\Model; use IPS\Node\Permissions; use IPS\Output; use IPS\Patterns\ActiveRecordIterator; use IPS\Request; use IPS\Settings; use IPS\Task; use IPS\Theme; use IPS\Widget\Area; use LogicException; use OutOfBoundsException; use OutOfRangeException; use UnderflowException; use function count; use function defined; use function in_array; use function intval; use function is_array; use function is_numeric; use function mb_substr; use function strstr; use const IPS\ROOT_PATH; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * @brief Page Model */ class Page extends Model implements Permissions { use ViewUpdates; /** * @brief [ActiveRecord] Multiton Store */ protected static array $multitons; /** * @brief [ActiveRecord] Caches * @note Defined cache keys will be cleared automatically as needed */ protected array $caches = array( 'frontNavigation' ); /** * @brief [ActiveRecord] Database Table */ public static ?string $databaseTable = 'cms_pages'; /** * @brief [ActiveRecord] Database Prefix */ public static string $databasePrefix = 'page_'; /** * @brief [ActiveRecord] ID Database Column */ public static string $databaseColumnId = 'id'; /** * @brief [ActiveRecord] Database ID Fields */ protected static array $databaseIdFields = array('page_seo_name', 'page_full_path'); /** * @brief [ActiveRecord] Multiton Map */ protected static array $multitonMap = array(); /** * @brief [Node] Parent Node ID Database Column */ public static string $parentNodeColumnId = 'folder_id'; /** * @brief [Node] Parent Node Class */ public static string $parentNodeClass = 'IPS\cms\Pages\Folder'; /** * @brief [Node] Parent ID Database Column */ public static ?string $databaseColumnOrder = 'seo_name'; /** * @brief [Node] Automatically set position for new nodes */ public static bool $automaticPositionDetermination = FALSE; /** * @brief [Node] Show forms modally? */ public static bool $modalForms = TRUE; /** * @brief [Node] Title */ public static string $nodeTitle = 'page'; /** * @brief [Node] ACP Restrictions * @code array( 'app' => 'core', // The application key which holds the restrictrions 'module' => 'foo', // The module key which holds the restrictions 'map' => array( // [Optional] The key for each restriction - can alternatively use "prefix" 'add' => 'foo_add', 'edit' => 'foo_edit', 'permissions' => 'foo_perms', 'delete' => 'foo_delete' ), 'all' => 'foo_manage', // [Optional] The key to use for any restriction not provided in the map (only needed if not providing all 4) 'prefix' => 'foo_', // [Optional] Rather than specifying each key in the map, you can specify a prefix, and it will automatically look for restrictions with the key "[prefix]_add/edit/permissions/delete" * @endcode */ protected static ?array $restrictions = array( 'app' => 'cms', 'module' => 'pages', 'prefix' => 'page_' ); /** * @brief [Node] App for permission index */ public static ?string $permApp = 'cms'; /** * @brief [Node] Type for permission index */ public static ?string $permType = 'pages'; /** * @brief The map of permission columns */ public static array $permissionMap = array( 'view' => 'view' ); /** * @brief [Node] Prefix string that is automatically prepended to permission matrix language strings */ public static string $permissionLangPrefix = 'perm_content_page_'; /** * @brief [Page] Loaded pages from paths */ protected static array $loadedPagesFromPath = array(); /** * @brief [Page] Currently loaded page */ public static ?Page $currentPage = NULL; /** * @brief [Page] Default page */ public static array $defaultPage = array(); /** * @brief Pre save flag */ const PRE_SAVE = 1; /** * @brief Post save flag */ const POST_SAVE = 2; /** * Set Default Values * * @return void */ public function setDefaultValues() : void { $this->js_css_ids = ''; $this->content = ''; $this->meta_keywords = ''; $this->meta_description = ''; $this->template = ''; $this->full_path = ''; $this->js_css_objects = ''; $this->meta_index = TRUE; $this->ipb_wrapper = true; } /** * @brief [Node] Title prefix. If specified, will look for a language key with "{$titleLangPrefix}_{$id}" as the key */ public static ?string $titleLangPrefix = 'cms_page_'; /** * Load record based on a URL * * @param Url $url URL to load from * @return Page * @throws InvalidArgumentException * @throws OutOfRangeException */ public static function loadFromUrl(Url $url ): mixed { $qs = array_merge( $url->hiddenQueryString, $url->queryString ); if ( isset( $qs['id'] ) ) { return static::load( $qs['id'] ); } else if ( isset( $qs['path'] ) ) { try { $return = static::load( $qs['path'], 'page_full_path' ); } catch( OutOfRangeException $ex ) { $return = static::loadFromPath( $qs['path'] ); } if ( method_exists( $return, 'can' ) ) { if ( !$return->can( 'view' ) ) { throw new OutOfRangeException; } } return $return; } throw new InvalidArgumentException; } /** * Get the page based on the database ID * * @param int $databaseId * @return static object * @throws OutOfRangeException */ public static function loadByDatabaseId( int $databaseId ) : static { return static::load( Databases::load( $databaseId )->page_id ); } /** * Resets a page path * * @param int $folderId Folder ID to reset * @return void */ public static function resetPath( int $folderId ) : void { $path = $folderId ? Folder::load( $folderId )->path : ''; $children = static::getChildren( $folderId ); foreach( $children as $id => $obj ) { $obj->setFullPath( $path ); } } /** * Get all children of a specific folder. * * @param INT $folderId Folder ID to fetch children from * @return array */ public static function getChildren( int $folderId=0 ) : array { $children = array(); foreach( Db::i()->select( '*', static::$databaseTable, array( 'page_folder_id=?', $folderId ), 'page_seo_name ASC' ) as $child ) { $children[ $child[ static::$databasePrefix . static::$databaseColumnId ] ] = static::load( $child[ static::$databasePrefix . static::$databaseColumnId ] ); } return $children; } /** * Returns a page object (or NULL) based on the path * * @param string $path Path /like/this/ok.html * @return NULL|Page object */ public static function loadFromPath( string $path ) : static|null { $path = trim( $path, '/' ); if ( ! array_key_exists( $path, static::$loadedPagesFromPath ) ) { static::$loadedPagesFromPath[ $path ] = NULL; /* Try the simplest option */ try { static::$loadedPagesFromPath[ $path ] = static::load( $path, 'page_full_path' ); } catch ( OutOfRangeException $e ) { /* Nope - try a folder */ try { if ( $path ) { $class = static::$parentNodeClass; /* @var $class Model */ $folder = $class::load( $path, 'folder_path' ); static::$loadedPagesFromPath[ $path ] = static::getDefaultPage( $folder->id ); } else { static::$loadedPagesFromPath[ $path ] = static::getDefaultPage(); } } catch ( OutOfRangeException $e ) { /* May contain a database path */ if ( strstr( $path, '/' ) ) { $bits = explode( '/', $path ); $pathsToTry = array(); while( count( $bits ) ) { $pathsToTry[] = implode( '/', $bits ); array_pop($bits); } try { static::$loadedPagesFromPath[ $path ] = static::constructFromData( Db::i()->select( '*', 'cms_pages', Db::i()->in( 'page_full_path', $pathsToTry ), 'page_full_path DESC' )->first() ); } catch( UnderFlowException $e ) { /* Last chance saloon */ foreach( Db::i()->select( '*', 'cms_pages', array( '? LIKE CONCAT( page_full_path, \'%\')', $path ), 'page_full_path DESC' ) as $page ) { if ( mb_stristr( $page['page_content'], '{database' ) ) { static::$loadedPagesFromPath[ $path ] = static::constructFromData( $page ); break; } } /* Still here? It's possible this is a legacy URL that starts with "page" - last ditch effort */ if ( static::$loadedPagesFromPath[ $path ] === NULL AND mb_substr( $path, 0, 5 ) === 'page/' ) { $pathWithoutPage = str_replace( 'page/', '', $path ); try { /* Pass back recursively so we don't have to duplicate all of the checks again */ static::$loadedPagesFromPath[ $path ] = static::loadFromPath( $pathWithoutPage ); } catch( OutOfRangeException $e ) {} } } } } } } if ( static::$loadedPagesFromPath[ $path ] === NULL ) { throw new OutOfRangeException; } return static::$loadedPagesFromPath[ $path ]; } /** * Load from path history so we can 301 to the correct record. * * @param string $slug Thing that lives in the garden and eats your plants * @param string|NULL $queryString Any query string to add to the end * @return Url */ public static function getUrlFromHistory( string $slug, ?string $queryString=NULL ) : Url { $slug = trim( $slug, '/' ); try { $row = Db::i()->select( '*', 'cms_url_store', array( 'store_type=? and store_path=?', 'page', $slug ) )->first(); return static::load( $row['store_current_id'] )->url(); } catch( UnderflowException $ex ) { /* Ok, perhaps this is a full URL with the page name at the beginning */ foreach( Db::i()->select( '*', 'cms_url_store', array( 'store_type=? and ? LIKE CONCAT( store_path, \'%\') OR store_path=?', 'page', $slug, $slug ) ) as $item ) { $url = static::load( $item['store_current_id'] )->url(); $url = $url->setPath( '/' . trim( str_replace( $item['store_path'], trim( $url->data['path'], '/' ), $slug ), '/' ) ); if ( $queryString !== NULL ) { $url = $url->setQueryString( $queryString ); } return $url; } /* Still here? Ok, now we may have changed the folder name at some point, so lets look for that */ foreach( Db::i()->select( '*', 'cms_url_store', array( 'store_type=? and ? LIKE CONCAT( store_path, \'%\') OR store_path=?', 'folder', $slug, $slug ) ) as $item ) { try { $folder = Folder::load( $item['store_current_id'] ); /* Attempt to build the new path */ $newPath = str_replace( $item['store_path'], $folder->path, $slug ); /* Do we have a page with this path? */ try { return static::load( $newPath, 'page_full_path' )->url(); } catch( OutOfRangeException $ex ) { /* This is not the path you are looking for */ } } catch( OutOfRangeException $ex ) { /* This also is not the path you are looking for */ } } } /* Still here? Consistent with AR pattern */ throw new OutOfRangeException(); } /** * Return the default page for this folder * * @param INT $folderId Folder ID to fetch children from * @return static|null */ public static function getDefaultPage( int $folderId=0 ) : static|null { if ( ! isset( static::$defaultPage[ $folderId ] ) ) { /* Try the easiest method first */ try { static::$defaultPage[ $folderId ] = Page::load( Db::i()->select( 'page_id', static::$databaseTable, array( 'page_default=? AND page_folder_id=?', 1, $folderId ) )->first() ); } catch( Exception $ex ) { throw new OutOfRangeException; } /* Got a page called index? */ if ( ! isset( static::$defaultPage[ $folderId ] ) ) { foreach( static::getChildren( $folderId ) as $id => $obj ) { if ( mb_substr( $obj->seo_name, 0, 5 ) === 'index' ) { return $obj; } } } } return ( isset( static::$defaultPage[ $folderId ] ) ) ? static::$defaultPage[ $folderId ] : NULL; } /** * Delete compiled versions * * @param int|array $ids Integer ID or Array IDs to remove * @return void */ public static function deleteCompiled( int|array $ids ) : void { if ( is_numeric( $ids ) ) { $ids = array( $ids ); } foreach( $ids as $id ) { $functionName = 'content_pages_' . $id; if ( isset( Store::i()->$functionName ) ) { unset( Store::i()->$functionName ); } } } /** * Removes all include objects from all pages * * @param Url|string|null $url The URL to find and remove * @param NULL|int $storageConfiguration Delete the cached includes from an alternate storage configuration * @note This method is called by \IPS\cms\extensions\core\FileStorage\Pages.php during a move, and in that process we want to remove resources from the old storage configuration, not the new one (which is what happens when the configuration id is not passed in to \IPS\File and a move is in progress) * @return void */ static public function deleteCachedIncludes( Url|string|null $url=NULL, ?int $storageConfiguration=NULL ) : void { /* Remove them all */ if ( $url === NULL ) { /* Remove from DB */ if ( Db::i()->checkForTable( 'cms_pages' ) ) { Db::i()->update( 'cms_pages', array( 'page_js_css_objects' => NULL ) ); } /* Remove from file system */ File::getClass( $storageConfiguration ?: 'cms_Pages' )->deleteContainer('page_objects'); } else { $bits = explode( '/', (string ) $url ); $name = array_pop( $bits ); /* Remove selectively */ foreach( Db::i()->select( '*', 'cms_pages', array( "page_js_css_objects LIKE '%" . Db::i()->escape_string( $name ) . "%'" ) ) as $row ) { Db::i()->update( 'cms_pages', array( 'page_js_css_objects' => NULL ), array( 'page_id=?', $row['page_id'] ) ); } } } /** * Show a custom error page * * @param string $title Title of the page * @param string $message language key for error message * @param mixed $code Error code * @param int $httpStatusCode HTTP Status Code * @param array $httpHeaders Additional HTTP Headers * @return void */ static public function errorPage( string $title, string $message, mixed $code, int $httpStatusCode, array $httpHeaders=array() ) : void { try { $page = static::load( Settings::i()->cms_error_page ); $content = $page->getHtmlContent(); $content = str_replace( '{error_message}', $message, $content ); $content = str_replace( '{error_code}', $code, $content ); /* Pages are compiled and cached, which we do not want for the error page as the {error_*} tags are saved with their text content */ $functionName = 'content_pages_' . $page->id; if ( isset( Store::i()->$functionName ) ) { unset( Store::i()->$functionName ); } $page->output( $title, $httpStatusCode, $httpHeaders, $content ); } catch( Exception $ex ) { if( $httpStatusCode !== 200 ) { /* Unset page token */ unset( Output::i()->jsVars['page_token'] ); } Output::i()->sidebar['enabled'] = FALSE; Output::i()->sendOutput( Theme::i()->getTemplate( 'global', 'core' )->globalTemplate( $title, Theme::i()->getTemplate( 'global', 'core' )->error( $title, $message, $code, NULL, Member::loggedIn() ), Dispatcher::i()->getLocationData() ), $httpStatusCode, 'text/html', $httpHeaders, FALSE ); } } /** * Form elements * * @param Page|null $item Page object or NULL * @return array */ static public function formElements( ?Page $item=NULL ) : array { $return = array(); $return['tab_details'] = array( 'content_page_form_tab__details', NULL, NULL, 'ipsForm--horizontal' ); $return['page_name'] = new Translatable( 'page_name', NULL, TRUE, array( 'app' => 'cms', 'key' => ( $item and $item->id ) ? "cms_page_" . $item->id : NULL, 'maxLength' => 64 ), function( $val ) { if ( empty( $val ) ) { throw new DomainException('form_required'); } }, NULL, NULL, 'page_name' ); $return['page_seo_name'] = new Text( 'page_seo_name', $item ? $item->seo_name : '', FALSE, array( 'maxLength' => 255 ), function( $val ) use ( $item ) { if ( empty( $val ) ) { $val = Friendly::seoTitle( $val ); } /* We cannot have a page name the same as a folder name in this folder */ try { $testFolder = Folder::load( $val, 'folder_name' ); /* Ok, we have a folder, but is it on the same tree as us ?*/ if ( intval( Request::i()->page_folder_id ) == $testFolder->parent_id ) { /* Yep, this will break designers' mode and may confuse the FURL engine so we cannot allow this */ throw new InvalidArgumentException('content_folder_name_furl_collision_pages'); } } catch ( OutOfRangeException $e ) { } /* If we hit here, we don't have an existing name so that's good */ if ( Page::isFurlCollision( $val ) ) { throw new InvalidArgumentException('content_folder_name_furl_collision_pages_app'); } try { $test = Db::i()->select( '*', 'cms_pages', [ 'page_seo_name=? and page_id <> ? and page_folder_id=?', $val, ( (int)$item?->_id ), Request::i()->page_folder_id ] )->first(); throw new InvalidArgumentException( 'content_page_file_name_in_use' ); } catch( UnderflowException ) { return true; } }, NULL, NULL, 'page_seo_name' ); $return['page_folder_id'] = new Node( 'page_folder_id', ( $item ? intval( $item->folder_id ) : ( ( isset( Request::i()->parent ) and Request::i()->parent ) ? Request::i()->parent : 0 ) ), FALSE, array( 'class' => 'IPS\cms\Pages\Folder', 'zeroVal' => 'node_no_parent', 'subnodes' => false ), NULL, NULL, NULL, 'page_folder_id' ); $return['page_ipb_wrapper'] = new YesNo( 'page_ipb_wrapper', ( $item AND $item->id ) ? $item->ipb_wrapper : 1, TRUE, array( 'togglesOff' => array( 'page_wrapper_template' ) ), NULL, NULL, NULL, 'page_ipb_wrapper' ); $wrapperTemplates = array( '_none_' => Member::loggedIn()->language()->addToStack('cms_page_wrapper_template_none') ); foreach( Templates::getTemplates( Templates::RETURN_PAGE + Templates::RETURN_DATABASE_AND_IN_DEV ) as $id => $obj ) { /* @var Templates $obj */ if ( $obj->isSuitableForCustomWrapper() ) { $wrapperTemplates[ Templates::readableGroupName( $obj->group ) ][ $obj->group . '__' . $obj->title . '__' . $obj->key ] = Templates::readableGroupName( $obj->title ); } } /* List of templates */ $return['page_wrapper_template'] = new Select( 'page_wrapper_template', ($item?->wrapper_template), null, array( 'options' => $wrapperTemplates ), function( $val ){ if( $val == '_none_' and !Request::i()->page_ipb_wrapper ) { throw new InvalidArgumentException('form_required'); } }, NULL, Theme::i()->getTemplate( 'pages', 'cms', 'admin' )->previewTemplateLink(), 'page_wrapper_template' ); if ( count( Theme::themes() ) > 1 ) { $themes = array( 0 => 'cms_page_theme_id_default' ); foreach ( Theme::themes() as $theme ) { $themes[ $theme->id ] = $theme->_title; } $return['page_theme'] = new Select( 'page_theme', $item ? $item->theme : 0, FALSE, array( 'options' => $themes ), NULL, NULL, NULL, 'page_theme' ); } /* Only show this dropdown if we are editing an existing page that already has a wrapper */ if( $item and $item->template and $item->template != 'page_builder__single_column__page_page_builder_single_column' and $item->template != 'page_builder__single_column__page_builder_single_column' ) { $builderTemplates = array( '' => Member::loggedIn()->language()->addToStack( 'cms_page_wrapper_template_none' ) ); foreach( Templates::getTemplates( Templates::RETURN_PAGE + Templates::RETURN_DATABASE_AND_IN_DEV ) as $id => $obj ) { if ( $obj->isSuitableForBuilderWrapper() ) { $builderTemplates[ Templates::readableGroupName( $obj->group ) ][ $obj->group . '__' . $obj->title . '__' . $obj->key ] = Templates::readableGroupName( $obj->title ); } } $return['page_template'] = new Select( 'page_template', $item->template, FALSE, array( 'options' => $builderTemplates ), NULL, NULL, NULL, 'page_template' ); } /* Page CSS and JS */ $js = Templates::getTemplates( Templates::RETURN_ONLY_JS + Templates::RETURN_DATABASE_ONLY ); $css = Templates::getTemplates( Templates::RETURN_ONLY_CSS + Templates::RETURN_DATABASE_ONLY ); if ( count( $js ) OR count( $css ) ) { $return['tab_js_css'] = array( 'content_page_form_tab__includes', NULL, NULL, 'ipsForm--horizontal ipsForm--page-includes' ); $return['msg_js_css'] = array( 'cms_page_includes_message', 'ipsMessage ipsMessage_info ipsCmsIncludesMessage' ); if ( count( $js ) ) { $jsincludes = array(); foreach( $js as $obj ) { $jsincludes[ $obj->key ] = Templates::readableGroupName( $obj->group ) . '/' . Templates::readableGroupName( $obj->title ); } ksort( $jsincludes ); $return['page_includes_js'] = new CheckboxSet( 'page_includes_js', $item ? $item->js_includes : FALSE, FALSE, array( 'options' => $jsincludes, 'multiple' => true ), NULL, NULL, NULL, 'page_includes_js' ); } if ( count( $css ) ) { $cssincludes = array(); foreach( $css as $obj ) { $cssincludes[ $obj->key ] = Templates::readableGroupName( $obj->group ) . '/' . Templates::readableGroupName( $obj->title ); } ksort( $cssincludes ); $return['page_includes_css'] = new CheckboxSet( 'page_includes_css', $item ? $item->css_includes : FALSE, FALSE, array( 'options' => $cssincludes, 'multiple' => true ), NULL, NULL, NULL, 'page_includes_css' ); } } $return['tab_meta'] = array( 'content_page_form_tab__meta', NULL, NULL, 'ipsForm--horizontal ipsForm--page-meta' ); $return['page_title'] = new Text( 'page_title', $item ? $item->title : '', FALSE, array( 'maxLength' => 64 ), NULL, NULL, NULL, 'page_title' ); $return['page_meta_description'] = new TextArea( 'page_meta_description', $item ? $item->meta_description : '', FALSE, array(), NULL, NULL, NULL, 'page_meta_description' ); $return['page_meta_image'] = new Form\Upload( 'page_meta_image', ( $item AND $item->meta_image ) ? File::get( 'cms_PagesImages', $item->meta_image ) : null, false, [ 'storageExtension' => 'cms_PagesImages', 'image' => true, 'multiple' => false ], null, null, null, 'page_meta_image' ); $disabledIndex = ( $item AND $item->id AND !$item->canView(new Member())); $return['page_meta_index'] = new YesNo('page_meta_index', !$disabledIndex ? ( $item ? $item->meta_index : TRUE ) : FALSE , FALSE, array( 'disabled' => $disabledIndex), NULL, NULL, NULL, 'page_meta_index' ); if( $disabledIndex ) { Member::loggedIn()->language()->words['page_meta_index_desc'] = Member::loggedIn()->language()->addToStack('page_meta_index_desc_disabled'); } Output::i()->globalControllers[] = 'cms.admin.pages.form'; Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'admin_pages.js', 'cms' ) ); return $return; } /** * Create a new page from a form. Pretty much what the function says. * * @param array $values Array of form values * @return Page object */ static public function createFromForm( array $values ) : static { $page = new self; $page->type = 'builder'; $page->save(); $page->saveForm( $page->formatFormValues( $values ) ); /* Set permissions */ Db::i()->update( 'core_permission_index', array( 'perm_view' => '*' ), array( 'app=? and perm_type=? and perm_type_id=?', 'cms', 'pages', $page->id ) ); return $page; } /** * Ensure there aren't any collision issues when the CMS is the default app and folders such as "forums" are created when * the forums app is installed. * * @param string $path Path to check * @return boolean */ static public function isFurlCollision( string $path ) : bool { $path = trim( $path , '/'); $bits = explode( '/', $path ); $folder = $bits[0]; /* Ensure we cannot have a structure that starts with core/interface as we have this partial URL blacklisted in \IPS\Text\Parser::safeIframeRegexp() */ if ( mb_substr( $path, 0, 15 ) == 'core/interface/' ) { return TRUE; } if ( mb_substr( $path, 0, 7 ) == 'static/' ) { return TRUE; } /* Cannot have /page/ as it confuses SEO pagination */ if ( mb_strstr( '/' . $path . '/', '/page/' ) ) { return TRUE; } /* What about system folders? */ try { $folder = Folder::load( Request::i()->page_folder_id ); $base = ROOT_PATH . '/' . $folder->path . '/'; } catch ( OutOfRangeException $e ) { $base = ROOT_PATH . '/'; } if( is_dir( $base . $path ) ) { return TRUE; } if( Request::i()->page_folder_id ) { return FALSE; } $defaultApplication = Db::i()->select( 'app_directory', 'core_applications', 'app_default=1' )->first(); foreach( Application::applications() as $key => $app ) { if ( $app->directory === 'cms' ) { continue; } $furlDefinitionFile = ROOT_PATH . "/applications/{$app->directory}/data/furl.json"; if ( file_exists( $furlDefinitionFile ) ) { $furlDefinition = json_decode( preg_replace( '/\/\*.+?\*\//s', '', file_get_contents( $furlDefinitionFile ) ), TRUE ); if ( isset( $furlDefinition['topLevel'] ) ) { if ( $furlDefinition['topLevel'] == $folder ) { return TRUE; } if ( isset( $furlDefinition['pages'] ) ) { foreach( $furlDefinition['pages'] as $name => $data ) { if ( isset( $data['friendly'] ) ) { $furlBits = explode( '/', $data['friendly'] ); if ( $furlBits[0] == $folder ) { return TRUE; } } } } } } } /* Still here? Some apps use very loose matching, like calendar looks for {id}-{?} which may conflict with a page with a filename of 123-foo */ try { $url = Url::createFromString( Url::baseUrl() . $path ); if ( $url instanceof Friendly and $url->seoTemplate !== 'content_page_path' ) { return TRUE; } } catch( Exception $ex ) { /* If we get an error, then it cannot be a legitimate link */ } return FALSE; } /** * Delete all stored includes so they can be rebuilt on demand. * * @return void */ public static function deleteCompiledIncludes() : void { Db::i()->update( 'cms_pages', array( 'page_js_css_objects' => NULL ) ); Templates::deleteCompiledFiles(); } /** * Create a datastore object of page IDs and URLs. * * @return void */ public static function buildPageUrlStore() : void { /* This fails because hooks are not installed when this is attempted to build via admin/install */ if ( Dispatcher::hasInstance() and Dispatcher::i()->controllerLocation === 'setup' ) { return; } /* This also fails if we're installing via ACP */ if ( Dispatcher::hasInstance() and Dispatcher::i()->controllerLocation === 'admin' and isset( Request::i()->do ) and Request::i()->do == 'install' ) { return; } $store = array(); foreach( new ActiveRecordIterator( Db::i()->select( '*', 'cms_pages' ), 'IPS\cms\Pages\Page' ) as $page ) { $perms = $page->permissions(); $store[ $page->id ] = array( 'url' => (string) $page->url(), 'perm' => $perms['perm_view'] ); } Store::i()->pages_page_urls = $store; } /** * Returns (and builds if required) the pages id => url datastore * * @return array */ public static function getStore(): array { if ( ! isset( Store::i()->pages_page_urls ) ) { static::buildPageUrlStore(); } return Store::i()->pages_page_urls; } /** * Get page path, returning stripped path and current page number * * @param string $path Page path * @return array Current path, Current page number */ public static function getStrippedPagePath( string $path ): array { /* Have a bash at pagination as it's not like we've much else to do */ $stripped = Friendly::stripPageComponent( '/' . trim( $path, '/' ) . '/' ); if ( trim( $path, '/' ) != trim( $stripped, '/' ) ) { if ( $stripped !== '/' ) { $pageStuff = str_replace( ltrim( $stripped, '/' ), '', $path ); } else { $pageStuff = $path; } } else { return array( $path, NULL ); } $bomb = explode( '/', $pageStuff ); if ( !empty( $bomb[1] ) ) { return array( trim( $stripped, '/' ), $bomb[1] ); } return array( $path, NULL ); } /** * Set JS/CSS include keys * * @param string|array $value * @return void */ public function set__js_css_ids( array|string $value ) : void { $this->_data['js_css_ids'] = ( is_array( $value ) ? json_encode( $value ) : $value ); } /** * Get JS/CSS include keys * * @return array|null */ protected function get__js_css_ids() : array|null { if ( $this->_data['js_css_ids'] !== NULL AND ! is_array( $this->_data['js_css_ids'] ) ) { $this->_data['js_css_ids'] = json_decode( $this->_data['js_css_ids'], true ); } return ( is_array( $this->_data['js_css_ids'] ) ) ? $this->_data['js_css_ids'] : array(); } /** * Get JS include keys * * @return array */ protected function get_js_includes() : array { /* Makes sure js_css_ids is unpacked if required */ $foo = $this->_js_css_ids; if ( isset( $this->_data['js_css_ids']['js'] ) ) { return $this->_data['js_css_ids']['js']; } return array(); } /** * Get CSS include keys * * @return array */ protected function get_css_includes() : array { /* Makes sure js_css_ids is unpacked if required */ $foo = $this->_js_css_ids; if ( isset( $this->_data['js_css_ids']['css'] ) ) { return $this->_data['js_css_ids']['css']; } return array(); } /** * Get JS/CSS Objects * * @return array|null */ public function getIncludes() : array|null { $return = array( 'css' => NULL, 'js' => NULL ); /* Empty? Lets take a look and see if we need to compile anything */ if ( empty( $this->_data['js_css_objects'] ) ) { /* Lock it up to prevent a race condition */ if ( Theme::checkLock( "page_object_build" . $this->id ) ) { return NULL; } Theme::lock( "page_object_build" . $this->id ); if ( count( $this->js_includes ) ) { /* Build a file object for each JS */ foreach( $this->js_includes as $key ) { try { $template = Templates::load( $key ); $object = $template->_file_object; $return['js'][ $key ] = $object; } catch( OutOfRangeException $e ) { continue; } } } if ( count( $this->css_includes ) ) { /* Build a file object for each JS */ foreach( $this->css_includes as $key ) { try { $template = Templates::load( $key ); $object = $template->_file_object; $return['css'][ $key ] = $object; } catch( Exception $e ) { continue; } } } /* Save this to prevent it looking for includes on every page refresh */ $this->js_css_objects = json_encode( $return ); $this->save(); Theme::unlock( "page_object_build" . $this->id ); } else { $return = json_decode( $this->_data['js_css_objects'], TRUE ); } foreach( $return as $type => $data ) { if ( is_array( $data ) ) { foreach( $data as $key => $object ) { $return[ $type ][ $key ] = (string) File::get( 'cms_Pages', $object )->url; } } } return $return; } /** * Get the content type of this page. Calculates based on page extension * * @return string */ public function getContentType() : string { $map = array( 'js' => 'text/javascript', 'css' => 'text/css', 'txt' => 'text/plain', 'xml' => 'text/xml', 'rss' => 'text/xml', 'html' => 'text/html', 'json' => 'application/json' ); $extension = mb_substr( $this->seo_name, ( mb_strrpos( $this->seo_name, '.' ) + 1 ) ); if ( in_array( $extension, array_keys( $map ) ) ) { return $map[ $extension ]; } return 'text/html'; } /** * Return the title for the publicly viewable HTML page * * @return string Title to use between