Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 31 Jan 2024 */ namespace IPS\core\modules\admin\developer; use DirectoryIterator; use DomainException; use InvalidArgumentException; use IPS\Db\Exception as DbException; use IPS\Developer\Controller; use IPS\Helpers\Form; use IPS\Helpers\Form\Text; use IPS\Helpers\Form\TextArea; use IPS\Helpers\Form\Upload; use IPS\Helpers\Tree\Tree; use IPS\IPS; use IPS\Member; use IPS\Output; use IPS\Request; use IPS\Session; use IPS\Theme; use IPS\Xml\SimpleXML; use ParseError; use function defined; use const IPS\IPS_FOLDER_PERMISSION; use const IPS\ROOT_PATH; /* To prevent PHP errors (extending class does not exist) revealing path */ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * versions */ class versions extends Controller { /** * @var bool */ public static bool $csrfProtected = true; /** * @var array */ protected array $json = []; /** * ... * * @return void */ protected function manage() : void { /* Create the file if it doesn't exist */ $this->json = $this->_getVersions(); /* Build node tree */ $appKey = $this->application->directory; $url = $this->url; $tree = new Tree( $this->url, 'dev_versions', /* Get Roots */ array( $this, '_getVersionRows' ), /* Get Row */ array( $this, '_getVersionRow' ), /* Get Row's Parent ID */ function( $id ) { return NULL; }, /* Get Children */ array( $this, '_getQueriesRows' ), /* Get Root Buttons */ function() use ( $appKey, $url ) { return array( 'add' => array( 'icon' => 'plus', 'title' => 'versions_add', 'link' => $url->setQueryString( array( 'do' => 'addVersion' ) ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('versions_add') ) ) ); }, FALSE, FALSE, TRUE ); Output::i()->output = (string) $tree; } /** * @return void */ protected function reorder() : void { Session::i()->csrfCheck(); /* Normalise AJAX vs non-AJAX */ if( isset( Request::i()->ajax_order ) ) { $order = array(); $position = array(); foreach( Request::i()->ajax_order as $id => $parent ) { if ( !isset( $order[ $parent ] ) ) { $order[ $parent ] = array(); $position[ $parent ] = 1; } $order[ $parent ][ $id ] = $position[ $parent ]++; } } /* Non-AJAX way */ else { $order = array( Request::i()->root ?: 'null' => Request::i()->order ); } /* Work out */ $queries = array(); $write = array(); foreach ( $order as $versionKey => $keys ) { if ( $versionKey != 'null' ) { asort( $keys ); foreach ( $keys as $key => $position ) { $versionNumber = mb_substr( $key, 0, mb_strpos( $key, '.' ) ); if ( !isset( $queries[ $versionNumber ] ) ) { $queries[ $versionNumber ] = $this->_getQueries( $versionNumber ); } $write[ $versionNumber ][] = $queries[ $versionNumber ][ mb_substr( $key, mb_strpos( $key, '.' ) + 1 ) ]; } } } foreach ( $write as $versionNumber => $queries ) { $this->_writeQueries( $versionNumber, $queries ); } } /** * Get all version rows * * @return array */ public function _getVersionRows() : array { $rows = array( 'install' => $this->_getVersionRow( 'install' ) ); foreach ( $this->json as $long => $human ) { array_unshift( $rows, $this->_getVersionRow( $long ) ); } array_unshift( $rows, $this->_getVersionRow( 'working' ) ); return $rows; } /** * Get individual version row * * @param int|string $long Version ID * @param bool $root Format this as the root node? * @return string */ public function _getVersionRow( int|string $long, bool $root=FALSE ) : string { $dir = ROOT_PATH . "/applications/{$this->application->directory}/setup/" . ( $long === 'install' ? $long : "upg_{$long}" ); $hasChildren = FALSE; if ( is_dir( $dir ) ) { foreach ( new DirectoryIterator( $dir ) as $file ) { if ( !$file->isDot() and mb_substr( $file, 0, 1 ) !== '.' and $file != 'index.html' ) { $hasChildren = TRUE; break; } } } $buttons = array(); if( $long != 'install' ) { $buttons['phpcode'] = array( 'icon' => 'code', 'title' => 'versions_code', 'link' => $this->url->setQueryString( array( 'do' => 'versionCode', 'id' => $long ) )->csrf() ); } $buttons['add'] = array( 'icon' => 'plus-circle', 'title' => 'versions_query', 'link' => $this->url->setQueryString( array( 'do' => 'addVersionQuery', 'id' => $long ) ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('versions_query'), 'ipsDialog-remoteVerify' => "false" ) ); if( $long != 'install' and $long !== 'working' ) { $buttons['delete'] = array( 'icon' => 'times-circle', 'title' => 'delete', 'link' => $this->url->setQueryString( array( 'do' => 'deleteVersion', 'id' => $long ) ), 'data' => array( 'delete' => '' ) ); } if ( $long === 'install' ) { $nameToDisplay = Member::loggedIn()->language()->addToStack('versions_install'); } elseif ( $long === 'working' ) { $nameToDisplay = Member::loggedIn()->language()->addToStack('versions_working'); } else { $nameToDisplay = $this->json[$long] ?? $long; } return Theme::i()->getTemplate( 'trees', 'core' )->row( $this->url, $long, $nameToDisplay, $hasChildren, $buttons, $long, NULL, NULL, $root ); } /** * Get versions * * @return array */ protected function _getVersions() : array { $result = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/versions.json" ); ksort( $result ); return $result; } /** * Write versions.json file * * @param array $json Data * @return void */ protected function _writeVersions( array $json ) : void { $this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/versions.json", $json ); } /** * Create a PHP class for a version * * @return void */ protected function versionCode() : void { Session::i()->csrfCheck(); /* Get version */ if ( Request::i()->id !== 'working' ) { $long = intval( Request::i()->id ); $json = $this->_getVersions(); if ( !isset( $json[ $long ] ) ) { Output::i()->error( 'node_error', '2C103/8', 404, '' ); } $human = $json[ $long ]; } else { $long = 'working'; $human = '{version_human}'; } /* Write the file if we don't already have one */ $phpFilePath = ROOT_PATH . "/applications/{$this->application->directory}/setup/upg_{$long}"; $phpFile = $phpFilePath . '/upgrade.php'; if ( !file_exists( $phpFile ) ) { /* Work out the contents */ $contents = str_replace( array( '{version_human}', "{subpackage}", '{date}', '{app}', '{version_long}', ), array( $human, ( $this->application->directory != 'core' ) ? ( " * @subpackage\t" . Member::loggedIn()->language()->get( "__app_{$this->application->directory}" ) ) : '', date( 'd M Y' ), $this->application->directory, $long, ), file_get_contents( ROOT_PATH . "/applications/core/data/defaults/Upgrade.txt" ) ); /* If this isn't an IPS app, strip out our header */ if ( !in_array( $this->application->directory, IPS::$ipsApps ) ) { $contents = preg_replace( '/(<\?php\s)\/*.+?\*\//s', '$1', $contents ); } /* Write */ if ( !is_dir( $phpFilePath ) ) { mkdir( $phpFilePath ); chmod( $phpFilePath, IPS_FOLDER_PERMISSION ); } if( @file_put_contents( $phpFile, $contents ) === FALSE ) { Output::i()->error( 'dev_could_not_write_setup', '1C103/9', 403, '' ); } } /* And redirect */ Output::i()->redirect( $this->url, 'file_created' ); } /** * Versions: Add Version * * @return void */ protected function addVersion() : void { /* Load existing versions.json file */ $json = $this->_getVersions(); /* Get form */ $activeTab = Request::i()->tab ?: 'new'; $form = new Form( 'versions_add' ); switch ( $activeTab ) { /* Create New */ case 'new': $form->addMessage( 'versions_add_information' ); $form->add( new Text( 'versions_human', NULL, TRUE, array( 'placeholder' => '1.0.0' ), function( $val ) { if ( !preg_match( '/^([0-9]+\.[0-9]+\.[0-9]+)/', $val ) ) { throw new DomainException( 'versions_human_bad' ); } } ) ); $form->add( new Text( 'versions_long', NULL, TRUE, array( 'placeholder' => '10000' ), function( $val ) use ( $json ) { if ( !preg_match( '/^\d*$/', $val ) ) { throw new DomainException( 'form_number_bad' ); } if( isset( $json[ $val ] ) ) { throw new DomainException( 'versions_long_exists' ); } } ) ); break; /* Upload */ case 'upload': $appKey = $this->application->directory; $form->add( new Upload( 'upload', NULL, TRUE, array( 'allowedFileTypes' => array( 'xml' ), 'temporary' => TRUE ) ) ); break; } /* Has the form been submitted? */ if( $values = $form->values() ) { /* Add values */ $toAdd = array(); switch ( $activeTab ) { /* New Version */ case 'new': $json[ $values['versions_long'] ] = $values['versions_human']; /* If this was for the core, add it to all IPS apps */ if ( $this->application->directory === 'core' ) { foreach ( IPS::$ipsApps as $_appKey ) { $appJson = $this->_getJson( ROOT_PATH . "/applications/{$_appKey}/data/versions.json" ); $appJson[ $values['versions_long'] ] = $values['versions_human']; $this->_writeJson( ROOT_PATH . "/applications/{$_appKey}/data/versions.json", $appJson ); } } break; /* Uploaded versions.xml file */ case 'upload': $xml = NULL; try { $xml = SimpleXML::loadFile( $values['upload'] ); } catch ( InvalidArgumentException $e ) {} if ( !$xml or $xml->getName() !== 'versions' ) { Output::i()->redirect( $this->url, 'versions_upload_badxml' ); } foreach ( $xml as $version ) { $json[ (int) $version->long ] = (string) $version->human; } unlink( $values['upload'] ); break; } /* Save a snapshot of the default theme for diffs */ if ( $this->application->directory === 'core' ) { Theme::master()->saveHistorySnapshot(); } /* Save it */ $this->_writeVersions( $json ); /* Redirect */ Output::i()->redirect( $this->url ); } if( Request::i()->isAjax() and $activeTab == 'upload' ) { Output::i()->output = $form; return; } /* If not, show it */ Output::i()->output = Theme::i()->getTemplate( 'global' )->tabs( array( 'new' => 'versions_add_new', 'upload' => 'versions_add_upload', ), $activeTab, $form, $this->url->setQueryString( array( 'do' => 'addVersion' ) ) ); if( Request::i()->isAjax() ) { if( Request::i()->existing ) { Output::i()->output = $form; } } } /** * Delete Version * * @return void */ protected function deleteVersion() : void { /* Make sure the user confirmed the deletion */ Request::i()->confirmedDelete(); $versionsFile = ROOT_PATH . "/applications/{$this->application->directory}/data/versions.json"; $json = $this->_getVersions(); if ( isset( $json[ intval( Request::i()->id ) ] ) ) { unset( $json[ intval( Request::i()->id ) ] ); } $this->_writeVersions( $json ); Output::i()->redirect( $this->url ); } /** * Add version query * * @return void */ protected function addVersionQuery() : void { /* Build Form */ $form = new Form( 'add_version_query' ); $form->add( new TextArea( 'versions_query_code', '\IPS\Db::i()->', TRUE, array( 'size' => 45 ), function( $val ) { /* Check it starts with \IPS\Db::i()-> */ $val = trim( $val ); if ( mb_substr( $val, 0, 14 ) !== '\IPS\Db::i()->' ) { throw new DomainException( 'versions_query_start' ); } /* Check there's only one query */ if ( mb_substr( $val, -1 ) !== ';' ) { $val .= ';'; } if ( mb_substr_count( $val, ';' ) > 1 ) { throw new DomainException( 'versions_query_one' ); } /* Check our Regex will be okay with it */ preg_match( '/^\\\IPS\\\Db::i\(\)->(.+?)\(\s*[\'"](.+?)[\'"]\s*(,\s*(.+?))?\)\s*;$/', $val, $matches ); if ( empty( $matches ) ) { throw new DomainException( 'versions_query_format' ); } /* Run it if we're adding it to the current working version */ if( Request::i()->id == 'working' ) { try { try { if ( @eval( $val ) === FALSE ) { throw new DomainException( 'versions_query_phperror' ); } } catch ( ParseError $e ) { throw new DomainException( 'versions_query_phperror' ); } } catch ( DbException $e ) { throw new DomainException( $e->getMessage() ); } } } ) ); /* If submitted, add to json file */ if ( $values = $form->values() ) { /* Get our file */ $version = Request::i()->id; $json = $this->_getQueries( $version ); /* Work out the different parts of the query */ $val = trim( $values['versions_query_code'] ); if ( mb_substr( $val, -1 ) !== ';' ) { $val .= ';'; } preg_match( '/^\\\IPS\\\Db::i\(\)->(.+?)\(\s*(.+?)\s*\)\s*;$/', $val, $matches ); /* Add it on */ $json[] = array( 'method' => $matches[1], 'params' => eval( 'return array( ' . $matches[2] . ' );' ) ); /* Write it */ $this->_writeQueries( $version, $json ); /* Redirect us */ Output::i()->redirect( $this->url->setQueryString( 'root', $version ) ); } /* Or display it */ else { Output::i()->output .= Theme::i()->getTemplate( 'global' )->block( 'versions_query', $form, FALSE ); } } /** * Delete Version Query * * @return void */ protected function deleteVersionQuery() : void { /* Make sure the user confirmed the deletion */ Request::i()->confirmedDelete(); $version = ( Request::i()->version == 'install' ? 'install' : Request::i()->version ); $json = $this->_getQueries( $version ); unset( $json[ intval( Request::i()->query ) ] ); $this->_writeQueries( $version, $json ); Output::i()->redirect( $this->url->setQueryString( 'root', $version ) ); } /** * Get the queries rows for a version * * @param int|string $long Version ID * @return array */ public function _getQueriesRows( int|string $long ) : array { $queries = $this->_getQueries( $long ); $order = 1; $rows = array(); foreach ( $queries as $qid => $data ) { $params = array(); if ( isset( $data['params'] ) and is_array( $data['params'] ) ) { foreach ( $data['params'] as $v ) { $params[] = var_export( $v, TRUE ); } } $rows["{$long}.{$qid}"] = Theme::i()->getTemplate( 'trees', 'core' )->row( $this->url, "{$long}.{$qid}", str_replace( '<?php', '', highlight_string( "{$data['method']}( ". implode( ', ', $params ) ." )", TRUE ) ), FALSE, array( 'delete' => array( 'icon' => 'times-circle', 'title' => 'delete', 'link' => $this->url->setQueryString( array( 'do' => 'deleteVersionQuery', 'version' => $long, 'query' => $qid ) ), 'data' => array( 'delete' => '' ) ) ), NULL, NULL, NULL, FALSE, NULL, NULL, NULL, TRUE, FALSE, FALSE, FALSE ); $order++; } return $rows; } }