Version 5.0.0 beta 1

This commit is contained in:
Neo committed 2025-12-19 16:27:35 -08:00
1 parent 25ddeb65d6
commit 15c7beabc5
6736 files changed
+627902 -497943

No files matched your search

@@ -0,0 +1,462 @@
<?php
/**
* @brief acpmenu
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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 http\Exception\InvalidArgumentException;
use IPS\Application\Module;
use IPS\Developer\Controller as DeveloperController;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Codemirror;
use IPS\Helpers\Form\Select;
use IPS\Helpers\Form\Stack;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Tree\Tree;
use IPS\Http\Url;
use IPS\Member;
use IPS\Output;
use IPS\Request;
use IPS\Session;
use IPS\Theme;
use function defined;
use const IPS\REBUILD_QUICK;
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;
}
/**
* acpmenu
*/
class acpmenu extends DeveloperController
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$modules = $this->_getModules();
$url = Url::internal( "app=core&module=developer&controller=acpmenu&appKey={$this->application->directory}" );
$menu = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json" );
$appKey = $this->application->directory;
/* Display the table */
$tree = new Tree( $url, 'dev_acpmenu',
/* Get Roots */
function () use ( $url, $appKey, $menu )
{
$rows = array();
$order = 1;
foreach ( array_keys($menu) as $k )
{
$lang = "menu__{$appKey}_{$k}";
$rows[ $k ] = Theme::i()->getTemplate( 'trees' )->row( $url, $k, Member::loggedIn()->language()->addToStack( $lang ), isset( $menu[ $k ] ), array(
'add' => array(
'icon' => 'plus-circle',
'title' => 'acpmenu_add',
'link' => Url::internal( "app=core&module=developer&controller=acpmenu&appKey={$appKey}&do=menuForm&module_key={$k}" )
)
), $lang, NULL, $order );
$order++;
}
return $rows;
},
/* Get Row */
function ( $k, $root ) use ( $url, $menu, $appKey )
{
$lang = "menu__{$appKey}_{$k}";
return Theme::i()->getTemplate( 'trees' )->row( $url, $k, Member::loggedIn()->language()->addToStack( $lang ), isset( $menu[ $k ] ), array(
'add' => array(
'icon' => 'plus-circle',
'title' => 'acpmenu_add',
'link' => Url::internal( "app=core&module=developer&controller=acpmenu&appKey={$appKey}&do=menuForm&module_key={$k}" )
)
), $lang, NULL, NULL, $root );
},
/* Get Row Parent */
function ()
{
return NULL;
},
/* Get Children */
function ( $k ) use ( $url, $menu, $appKey )
{
$rows = array();
$pos = 0;
foreach ( $menu[ $k ] as $id => $row )
{
$description = "app={$appKey}&amp;module=" . ( $row['module_url'] ?? $k ) . "&amp;controller=" . $row['controller'] . ( $row['do'] ? "&amp;do=" . $row['do'] : '' );
$lang = "menu__" . ( $row['activemenuitem'] ?? "{$appKey}_{$k}_{$id}" );
$rows[ 's@' . $id ] = Theme::i()->getTemplate( 'trees' )->row( $url, 's@' . $id, Member::loggedIn()->language()->addToStack( $lang ), FALSE, array(
'edit' => array(
'icon' => 'pencil',
'title' => 'edit',
'link' => Url::internal( "app=core&module=developer&controller=acpmenu&appKey={$appKey}&do=menuForm&module_key={$k}&id={$id}" ),
'hotkey'=> 'e'
),
'delete' => array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => Url::internal( "app=core&module=developer&controller=acpmenu&appKey={$appKey}&do=menuDelete&module_key={$k}&id={$id}" ),
'data' => array( 'delete' => '' )
),
), $description, NULL, ++$pos, FALSE, NULL, NULL, NULL, FALSE, FALSE, FALSE );
}
return $rows;
},
function() use ( $appKey ){
return [
'add' => [
'icon' => 'plus',
'title' => 'acpmenu_group_add',
'link' => Url::internal( "app=core&module=developer&controller=acpmenu&appKey={$appKey}&do=menuGroupForm" ),
'data' => [ 'ipsDialog' => '', 'ipsDialog-size' => 'narrow', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack( 'acpmenu_group_add' ) ]
]
];
},
FALSE,
FALSE,
TRUE
);
Output::i()->output = (string) $tree;
}
/**
* New menu group
* @return void
*/
protected function menuGroupForm() : void
{
$menu = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json" );
$form = new Form;
$form->add( new Text( 'acpmenu_group_key', null, true, array(), function( $val ) use ( $menu ){
if( isset( $menu[ $val ] ) )
{
throw new InvalidArgumentException( 'err_duplicate_acp_menu_group' );
}
} ) );
if( $values = $form->values() )
{
$menu[ $values['acpmenu_group_key'] ] = [];
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json", $menu );
Output::i()->redirect( $this->url );
}
Output::i()->output = (string) $form;
}
/**
* Menu Form Item
*
* @return void
*/
protected function menuForm() : void
{
/* Current Menu */
$menu = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json" );
/* Get module and controllers */
$menuGroup = Request::i()->module_key;
/* Load existing */
$current = NULL;
if ( Request::i()->id and isset( $menu[ $menuGroup ][ Request::i()->id ] ) )
{
$current = $menu[ $menuGroup ][ Request::i()->id ];
if( isset( $current['module_url'] ) and $current['module_url'] != Request::i()->module_key )
{
Request::i()->module_key = $current['module_url'];
}
}
$module = $this->_loadModule( true );
$adminModules = [ '' => '' ];
foreach( $this->_getModules()[ 'admin' ] as $k => $v )
{
$adminModules[ $k ] = $k;
}
/* And controllers */
$controllers = array();
if( $module !== null )
{
foreach ( new DirectoryIterator( ROOT_PATH . "/applications/{$this->application->directory}/modules/admin/{$module->key}" ) as $file )
{
if ( !$file->isDot() and mb_substr( $file, -4 ) === '.php' )
{
$controllers[ mb_substr( $file, 0, -4 ) ] = (string) $file;
}
}
}
else
{
foreach( new DirectoryIterator( ROOT_PATH . "/applications/{$this->application->directory}/modules/admin" ) as $moduleFolder )
{
if( $moduleFolder->isDir() and !$moduleFolder->isDot() )
{
foreach( new DirectoryIterator( $moduleFolder->getRealPath() ) as $file )
{
if ( !$file->isDot() and mb_substr( $file, -4 ) === '.php' )
{
$controllers[ $moduleFolder->getFilename() ][ mb_substr( $file, 0, -4 ) ] = (string) $file;
}
}
}
}
}
/* And restrictions */
$restrictions = $this->_getRestrictions( $module );
/* And tabs */
$tabs = $this->_getAcpMenuTabs();
/* Show Form */
$form = new Form();
$form->addTab( 'acpmenu_tab_general' );
$form->add( new Select( 'acpmenu_controller', ( $current ? $current['controller'] : NULL ), TRUE, array( 'options' => $controllers ) ) );
$form->add( new Text( 'acpmenu_tab', ( $current ? $current['tab'] : $module?->application ), TRUE, array( 'autocomplete' => array(
'source' => $tabs,
'maxItems' => 1,
) ) ) );
$form->add( new Select( 'acpmenu_module_url', ( $current and isset( $current['module_url'] ) ) ? $current['module_url'] : null, false, [ 'options' => $adminModules ] ) );
$form->add( new Text( 'acpmenu_doaction', ( $current ? $current['do'] : NULL ), FALSE, array(), NULL, 'do=' ) );
$form->add( new Select( 'acpmenu_restriction', ( $current ? explode( ',', $current['restriction'] ) : '' ), FALSE, array( 'options' => $restrictions, 'multiple' => true, 'noDefault' => true ) ) );
$form->add( new Select( 'acpmenu_subcontrollers', ( $current and isset( $current['subcontrollers'] ) ) ? explode( ',', $current['subcontrollers'] ) : null, false, array( 'options' => $controllers, 'multiple' => true, 'noDefault' => true ) ) );
$form->addTab( 'acpmenu_tab_advanced' );
$form->add( new Text( 'acpmenu_activemenuitem', ( $current and isset( $current['activemenuitem'] ) ) ? $current['activemenuitem'] : null, false ) );
$form->add( new Text( 'acpmenu_badge', ( $current and isset( $current['badge'] ) ) ? $current['badge'] : null, false ) );
$form->add( new Codemirror( 'acpmenu_callback', ( $current and isset( $current['callback'] ) ) ? $current['callback'] : null, false, [ 'codeModeAllowedLanguages' => [ 'php' ] ] ) );
$menuChecks = [];
if( $current and isset( $current['menu_checks'] ) )
{
foreach( $current['menu_checks'] as $param => $paramValues )
{
foreach( $paramValues as $pv )
{
$menuChecks[] = $param . '=' . $pv;
}
}
}
$form->add( new Stack( 'acpmenu_menu_checks', $menuChecks, false ) );
/* Handle submissions */
if ( $values = $form->values() )
{
$menuItemKey = $current ? Request::i()->id : $values['acpmenu_controller'];
if( $current === null and isset( $menu[ $menuGroup ][ $menuItemKey ] ) and $values['acpmenu_doaction'] )
{
$menuItemKey .= $values['acpmenu_doaction'];
}
$menuItem = array_merge( ( $current ?? [] ), [
'tab' => $values['acpmenu_tab'],
'controller' => $values['acpmenu_controller'],
'do' => $values['acpmenu_doaction'],
'restriction' => count( $values['acpmenu_restriction'] ) ? implode( ',', $values['acpmenu_restriction'] ) : ''
] );
if( $menuItem['restriction'] and $restrictionModule = $this->_checkRestrictionModule( $menuItem['restriction'] ) )
{
if( $module === null or $restrictionModule != $module->key )
{
$menuItem['restriction_module'] = $restrictionModule;
}
}
if( $values['acpmenu_subcontrollers'] )
{
$menuItem['subcontrollers'] = implode( ",", $values['acpmenu_subcontrollers'] );
}
foreach( [ 'module_url', 'callback', 'activemenuitem', 'badge' ] as $key )
{
if( $values[ 'acpmenu_' . $key ] )
{
$menuItem[ $key ] = $values['acpmenu_' . $key ];
}
}
if( !empty( $values['acpmenu_menu_checks'] ) )
{
$menuChecks = [];
foreach( $values['acpmenu_menu_checks'] as $menuCheck )
{
$_menuCheck = explode( "=", $menuCheck );
if( !isset( $menuChecks[ $_menuCheck[0] ] ) )
{
$menuChecks[ $_menuCheck[0] ] = [];
}
$menuChecks[ $_menuCheck[0] ][] = $_menuCheck[1];
}
$menuItem['menu_checks'] = $menuChecks;
}
$menu[ $menuGroup ][ $menuItemKey ] = $menuItem;
/* If we edited the item key, remove the old one */
if( $current !== null and Request::i()->id != $menuItemKey )
{
unset( $menu[ $menuGroup ][ Request::i()->id ] );
}
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json", $menu );
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=acpmenu&appKey={$this->application->directory}&root={$menuGroup}" ) );
}
/* Display */
Output::i()->output = Theme::i()->getTemplate( 'global' )->block( 'acpmenu_add', $form, FALSE );
}
/**
* Delete Menu Item
*
* @return void
*/
protected function menuDelete() : void
{
/* Make sure the user confirmed the deletion */
Request::i()->confirmedDelete();
/* Get Menu */
$menu = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json" );
/* Get module and controllers */
$menuGroup = Request::i()->module_key;
/* Delete It */
unset( $menu[ $menuGroup ][ Request::i()->id ] );
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json", $menu );
/* Redirect */
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=acpmenu&appKey={$this->application->directory}&root={$menuGroup}" ) );
}
/**
* @return void
*/
protected function reorder() : void
{
Session::i()->csrfCheck();
$menu = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json" );
/* Normalise AJAX vs non-AJAX */
if( isset( Request::i()->ajax_order ) )
{
$order = array();
$position = array();
foreach( Request::i()->ajax_order as $id => $parent )
{
/* We have to fudge children "ids" to prevent conflicts in the array */
$id = str_replace( 's@', '', $id );
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 );
}
/* Sort */
$_menu = $menu;
$menu = array();
if( isset( $order['null'] ) )
{
foreach( $order['null'] as $key => $position )
{
foreach ( $_menu as $_parent => $_items )
{
if ( $key == $_parent )
{
$menu[ $_parent ] = $_menu[ $_parent ];
break;
}
}
}
}
foreach( $_menu as $root => $items )
{
/* If we were sorting one level of the menu, and this is not it, then leave it as is */
if( isset( Request::i()->root ) and Request::i()->root != $root )
{
$menu[ $root ] = $items;
}
elseif( isset( $order[ $root ] ) )
{
$menu[ $root ] = array();
foreach( $order[ $root ] as $key => $position )
{
$menu[ $root ][ $key ] = $_menu[ $root ][ $key ];
}
}
}
/* Write */
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json", $menu );
}
/**
* Find the restriction module for the specified restriction key(s)
*
* @param string $restrictions
* @return string
*/
protected function _checkRestrictionModule( string $restrictions ) : string
{
$allRestrictions = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json" );
foreach( explode( ",", $restrictions ) as $_restriction )
{
foreach( $allRestrictions as $moduleKey => $groups )
{
foreach( $groups as $groupKey => $rows )
{
if( array_key_exists( $_restriction, $rows ) )
{
return $moduleKey;
}
}
}
}
return '';
}
}
@@ -0,0 +1,379 @@
<?php
/**
* @brief acprestrictions
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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 IPS\Developer\Controller;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Tree\Tree;
use IPS\Http\Url;
use IPS\Member;
use IPS\Output;
use IPS\Request;
use IPS\Session;
use IPS\Theme;
use function defined;
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;
}
/**
* acprestrictions
*/
class acprestrictions extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* @var array|null
*/
protected ?array $modules = null;
/**
* @var array|null
*/
protected ?array $restrictions = null;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$this->modules = $this->_getModules();
$this->url = Url::internal( "app=core&module=developer&controller=acprestrictions&appKey={$this->application->directory}" );
$this->restrictions = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json" );
$appKey = $this->application->directory;
/* Display the table */
$tree = new Tree( $this->url, 'dev_acprestrictions', array( $this, 'restrictionsGetRoots' ), array( $this, 'restrictionsGetRow' ),
function ( $k )
{
if ( mb_substr( $k, 0, 1 ) === 'M' )
{
return NULL;
}
else
{
return 'M' . mb_substr( $k, 1, mb_strpos( $k, '-' ) - 1 );
}
},
array( $this, 'restrictionsGetchildren' ),
NULL,
FALSE,
TRUE,
TRUE
);
Output::i()->output = (string) $tree;
}
/**
* @return void
*/
protected function reorder() : void
{
Session::i()->csrfCheck();
/* Figure out the items we are reordering, we would only be reordering a single group */
$newOrder = array();
$position = 1;
foreach( Request::i()->ajax_order as $i => $parent )
{
if( strpos( $i, '~' ) !== FALSE )
{
if( !isset( $newOrder[ $parent ] ) )
{
$newOrder[ $parent ] = array();
}
$newOrder[ $parent ][ $i ] = $position++;
}
}
foreach( $newOrder as $groupKey => $order )
{
if( isset( Request::i()->ajax_order[ $groupKey ] ) )
{
$moduleKey = Request::i()->ajax_order[ $groupKey ];
/* Sort */
uasort( $this->restrictions[ $moduleKey ][ $groupKey ], function( $a, $b ) use ( $order ) {
return ( isset( $order[ str_replace( '_', '~', $a ) ] ) ? $order[ str_replace( '_', '~', $a ) ] : 0 ) - ( isset( $order[ str_replace( '_', '~', $b ) ] ) ? $order[ str_replace( '_', '~', $b ) ] : 0 );
});
}
}
/* Write */
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json", $this->restrictions );
}
/**
* Restrictions: Get Root Rows
*
* @return array
*/
public function restrictionsGetRoots() : array
{
$rows = array();
if( !empty( $this->modules['admin'] ) )
{
foreach ( $this->modules['admin'] as $k => $module )
{
if ( empty( $module['protected'] ) )
{
$rows[ $k ] = $this->_restrictionsModuleRow( $this->url, $k, $this->restrictions, FALSE );
}
}
}
return $rows;
}
/**
* Restrictions: Get Individual Row
*
* @param string $k ID
* @param bool $root Is root row?
* @return string
*/
public function restrictionsGetRow( string $k, bool $root ) : string
{
if ( mb_substr( $k, 0, 1 ) === 'M' )
{
return $this->_restrictionsModuleRow( $this->url, mb_substr( $k, 1 ), $this->restrictions, $root );
}
else
{
$moduleKey = mb_substr( $k, 1, mb_strpos( $k, '-' ) - 1 );
$groupKey = mb_substr( $k, mb_strpos( $k, '-' ) + 1 );
return $this->_restrictionsGroupRow( $this->url, $groupKey, $moduleKey, $this->restrictions[ $moduleKey ][ $groupKey ], $root );
}
}
/**
* Restrictions: Get Child Rows
*
* @param string $k ID
* @return array
*/
public function restrictionsGetChildren( string $k ) : array
{
$rows = array();
if ( mb_substr( $k, 0, 1 ) === 'M' )
{
$k = mb_substr( $k, 1 );
foreach ( $this->restrictions[ $k ] as $groupKey => $r )
{
$rows[ $groupKey ] = $this->_restrictionsGroupRow( $this->url, $groupKey, $k, $r, FALSE );
}
}
else
{
$moduleKey = mb_substr( $k, 1, mb_strpos( $k, '-' ) - 1 );
$groupKey = mb_substr( $k, mb_strpos( $k, '-' ) + 1 );
$pos = 0;
foreach ( $this->restrictions[ $moduleKey ][ $groupKey ] as $rKey )
{
$lang = "r__{$rKey}";
$rows[ str_replace( '_', '~', $rKey ) ] = Theme::i()->getTemplate( 'trees' )->row( $this->url, $rKey, Member::loggedIn()->language()->addToStack( $lang ), FALSE, array(
'delete' => array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => Url::internal( "app=core&module=developer&controller=acprestrictions&appKey={$this->application->directory}&do=restrictionRowDelete&module_key={$moduleKey}&group={$groupKey}&id={$rKey}" ),
'data' => array( 'delete' => '' )
)
), $rKey, NULL, ++$pos );
}
}
return $rows;
}
/**
* Get Module Row
*
* @param string|Url $url URL
* @param string $k Key
* @param array $restrictions Restrictions JSON
* @param bool $root As root?
* @return string
*/
protected function _restrictionsModuleRow( string|Url $url, string $k, array $restrictions, bool $root ) : string
{
return Theme::i()->getTemplate( 'trees' )->row( $url, 'M'.$k, $k, isset( $restrictions[ $k ] ), array(
'add' => array(
'icon' => 'plus-circle',
'title' => 'acprestrictions_addgroup',
'link' => Url::internal( "app=core&module=developer&controller=acprestrictions&appKey={$this->application->directory}&do=restrictionGroupForm&module_key={$k}&id=0" ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('acprestrictions_addgroup') )
)
), '', NULL, NULL, $root );
}
/**
* Get Group Row
*
* @param string|Url $url URL
* @param string $groupKey Key
* @param string $moduleKey Module Key
* @param array $r Rows in this group
* @param bool $root As root?
* @return string
*/
protected function _restrictionsGroupRow( string|Url $url, string $groupKey, string $moduleKey, array $r, bool $root ) : string
{
$lang = "r__{$groupKey}";
return Theme::i()->getTemplate( 'trees' )->row( $url, "G{$moduleKey}-{$groupKey}", Member::loggedIn()->language()->addToStack( $lang ), !empty( $r ), array(
'add' => array(
'icon' => 'plus-circle',
'title' => 'acprestrictions_addrow',
'link' => Url::internal( "app=core&module=developer&controller=acprestrictions&appKey={$this->application->directory}&do=restrictionRowForm&module_key={$moduleKey}&group={$groupKey}" ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('acprestrictions_addrow') )
),
'edit' => array(
'icon' => 'pencil',
'title' => 'edit',
'link' => Url::internal( "app=core&module=developer&controller=acprestrictions&appKey={$this->application->directory}&do=restrictionGroupForm&module_key={$moduleKey}&id={$groupKey}" ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('edit') ),
'hotkey'=> 'e'
),
'delete'=> array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => Url::internal( "app=core&module=developer&controller=acprestrictions&appKey={$this->application->directory}&do=restrictionGroupDelete&module_key={$moduleKey}&id={$groupKey}" ),
'data' => array( 'delete' => '' )
)
), '', NULL, NULL, $root );
}
/**
* Restriction Group Form
*
* @return void
*/
public function restrictionGroupForm() : void
{
/* Get restriction data */
$restrictions = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json" );
/* Load module */
$module = $this->_loadModule();
/* Do we have a default value? */
$current = NULL;
if ( Request::i()->id and isset( $restrictions[ $module->key ][ Request::i()->id ] ) )
{
$current = Request::i()->id;
}
/* Build Form */
$form = new Form();
$form->add( new Text( 'acprestrictions_groupkey', $current ?: '', TRUE ) );
/* Handle submissions */
if ( $values = $form->values() )
{
$rows = array();
if ( $current !== NULL )
{
$rows = $restrictions[ $module->key ][ $current ];
unset( $restrictions[ $module->key ][ $current ] );
}
$restrictions[ $module->key ][ $values['acprestrictions_groupkey'] ] = $rows;
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json", $restrictions );
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=acprestrictions&appKey={$this->application->directory}&root=M{$module->key}" ) );
}
/* Display */
Output::i()->output = Theme::i()->getTemplate( 'global' )->block( 'acprestrictions_addgroup', $form, FALSE );
}
/**
* Delete Restriction Group
*
* @return void
*/
public function restrictionGroupDelete() : void
{
/* Make sure the user confirmed the deletion */
Request::i()->confirmedDelete();
$restrictions = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json" );
$module = $this->_loadModule();
if( isset( $restrictions[ $module->key ][ Request::i()->id ] ) )
{
unset( $restrictions[ $module->key ][ Request::i()->id ] );
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json", $restrictions );
}
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=acprestrictions&appKey={$this->application->directory}&root=M{$module->key}" ) );
}
/**
* Restriction Row Form
*
* @return void
*/
protected function restrictionRowForm() : void
{
$module = $this->_loadModule();
$group = Request::i()->group;
$form = new Form();
$form->add( new Text( 'acprestrictions_rowkey', NULL, TRUE ) );
if ( $values = $form->values() )
{
$restrictions = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json" );
$restrictions[ $module->key ][ $group ][ $values['acprestrictions_rowkey'] ] = $values['acprestrictions_rowkey'];
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json", $restrictions );
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=acprestrictions&appKey={$this->application->directory}&root=G{$module->key}-{$group}" ) );
}
Output::i()->output = Theme::i()->getTemplate( 'global' )->block( 'acprestrictions_addrow', $form, FALSE );
}
/**
* Delete Restriction Row
*
* @return void
*/
public function restrictionRowDelete() : void
{
/* Make sure the user confirmed the deletion */
Request::i()->confirmedDelete();
$restrictions = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json" );
$module = $this->_loadModule();
$group = Request::i()->group;
$id = Request::i()->id;
if( isset( $restrictions[ $module->key ][ $group ][ $id ] ) )
{
unset( $restrictions[ $module->key ][ $group ][ $id ] );
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json", $restrictions );
}
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=acprestrictions&appKey={$this->application->directory}&root=G{$module->key}-{$group}" ) );
}
}
@@ -0,0 +1,206 @@
<?php
/**
* @brief acpsearch
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @since 05 Feb 2024
*/
namespace IPS\core\modules\admin\developer;
use IPS\Developer\Controller;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Select;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Table\Custom;
use IPS\Http\Url;
use IPS\Member;
use IPS\Output;
use IPS\Request;
use function defined;
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;
}
/**
* acpsearch
*/
class acpsearch extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$json = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpsearch.json" );
$data = array();
foreach( $json as $k => $v )
{
$data[ $k ] = array(
'controller' => $k,
'lang' => $v['lang_key'],
'keywords' => implode( ", ", $v['keywords'] ),
'restriction' => $v['restriction']
);
}
$table = new Custom( $data, $this->url );
$table->quickSearch = 'keywords';
$table->include = [ 'controller', 'keywords' ];
$table->langPrefix = 'dev_acpsearch_';
$table->parsers = [
'controller' => function( $val, $row )
{
parse_str( $row['controller'], $params );
if( ( isset( $params['app'] ) and isset( $params['module'] ) ) and ( !isset( $row['restriction'] ) or Member::loggedIn()->hasAcpRestriction( $params['app'], $params['module'], $row['restriction'] ) ) )
{
$title = "<a href='" . Url::internal( $row['controller'] ) . "' target='_blank'>" . Member::loggedIn()->language()->addToStack( $row['lang'] ) . "</a>";
}
$title = Member::loggedIn()->language()->addToStack( $row['lang'] );
return $title . "<div class='i-font-size_-2 i-color_soft'>{$row['controller']}</div>";
}
];
$table->rootButtons = array(
'add' => array(
'icon' => 'plus',
'title' => 'add',
'link' => $this->url->setQueryString( 'do', 'form' ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('add') )
)
);
$url = $this->url;
$table->rowButtons = function( $row ) use ( $url )
{
return array(
'view' => array(
'icon' => 'search',
'title' => 'view',
'link' => Url::internal( $row['controller'] ),
'target' => '_blank'
),
'edit' => array(
'icon' => 'pencil',
'title' => 'edit',
'link' => $url->setQueryString( array( 'do' => 'form', 'key' => urlencode( $row['controller'] ) ) ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('edit') )
),
'delete' => array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => $url->setQueryString( array( 'do' => 'delete', 'key' => urlencode( $row['controller'] ) ) )->csrf(),
'data' => array( 'confirm' => '' )
)
);
};
Output::i()->output = (string) $table;
}
/**
* @return void
*/
protected function form() : void
{
$jsonFile = ROOT_PATH . "/applications/{$this->application->directory}/data/acpsearch.json";
$json = $this->_getJson( $jsonFile );
$controller = isset( Request::i()->key ) ? urldecode( Request::i()->key ) : null;
$row = ( $controller !== null and isset( $json[ $controller ] ) ) ? $json[ $controller ] : array();
$restrictions = array( '' => 'no_restriction' );
foreach( $this->application->modules( 'admin' ) as $module )
{
$moduleKey = 'menu__' . $this->application->directory . '_' . $module->key;
$moduleRestrictions = array();
foreach( $this->_getRestrictions( $module ) as $group => $r )
{
foreach( $r as $k => $v )
{
$moduleRestrictions[ $k ] = $v;
}
}
if( count( $moduleRestrictions ) )
{
$restrictions[ $moduleKey ] = $moduleRestrictions;
}
}
$form = new Form;
$form->add( new Text( 'dev_acpsearch_controller', $controller, true ) );
$form->add( new Text( 'dev_acpsearch_lang', $row['lang_key'] ?? null, true ) );
$form->add( new Select( 'dev_acpsearch_restriction', $row['restriction'] ?? null, false, array(
'options' => $restrictions
) ) );
$form->add( new Form\Stack( 'dev_acpsearch_keywords', $row['keywords'] ?? null, true ) );
if( $values = $form->values() )
{
$keywordData = [
'lang_key' => $values['dev_acpsearch_lang'],
'restriction' => $values['dev_acpsearch_restriction'] ?? null,
'keywords' => $values['dev_acpsearch_keywords']
];
if( $controller == $values['dev_acpsearch_controller'] )
{
$json[ $controller ] = $keywordData;
}
else
{
$json[ $values['dev_acpsearch_controller'] ] = $keywordData;
if( isset( $json[ $controller ] ) )
{
unset( $json[ $controller ] );
}
}
$this->_writeJson( $jsonFile, $json );
Output::i()->redirect( $this->url );
}
Output::i()->output = (string) $form;
}
/**
* @return void
*/
protected function delete() : void
{
Request::i()->confirmedDelete();
$jsonFile = ROOT_PATH . "/applications/{$this->application->directory}/data/acpsearch.json";
$json = $this->_getJson( $jsonFile );
$controller = isset( Request::i()->key ) ? urldecode( Request::i()->key ) : null;
if( isset( $json[ $controller ] ) )
{
unset( $json[ $controller ] );
}
$this->_writeJson( $jsonFile, $json );
Output::i()->redirect( $this->url );
}
}
@@ -0,0 +1,61 @@
<?php
/**
* @brief cmstemplates
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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 IPS\cms\Templates;
use IPS\Developer\Controller;
use IPS\Output;
use function defined;
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;
}
/**
* cmstemplates
*/
class cmstemplates extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$templateConfigFile = ROOT_PATH . "/applications/{$this->application->directory}/dev/cmsTemplates.json";
$preSelectedTemplates = array( 'templates_database' => [], 'templates_block' => [], 'templates_page' => [] );
if( file_exists( $templateConfigFile ) )
{
$preSelectedTemplates = json_decode( file_get_contents( $templateConfigFile ), TRUE );
}
$form = Templates::exportForm( TRUE, $preSelectedTemplates );
if( $values = $form->values() )
{
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/dev/cmsTemplates.json", $values );
}
Output::i()->output = (string) $form;
}
}
@@ -0,0 +1,243 @@
<?php
/**
* @brief customtemplates
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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 Exception;
use IPS\Developer\Controller;
use IPS\Helpers\Form;
use IPS\Helpers\Tree\Tree;
use IPS\Http\Url;
use IPS\Output;
use IPS\Request;
use IPS\Session;
use IPS\Theme;
use IPS\Theme\CustomTemplate;
use OutOfRangeException;
use function defined;
/* 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;
}
/**
* customtemplates
*/
class customtemplates extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$url = Url::internal( "app=core&module=developer&controller=customtemplates&appKey={$this->application->directory}" );
$appKey = $this->application->directory;
$templates = [];
foreach( CustomTemplate::all() as $template )
{
if ( $template->app == $appKey )
{
$templates[] = $template;
}
}
/* Display the table */
$tree = new Tree( $url, 'dev_custom_templates',
/* Get Roots */
function () use ( $url, $appKey, $templates )
{
$rows = array();
$order = 1;
foreach ( $templates as $template )
{
$rows[ $template->id ] = Theme::i()->getTemplate( 'trees' )->row( $url, $template->id, $template->name, false, array(
'add' => array(
'icon' => 'pencil',
'title' => 'edit',
'link' => Url::internal( "app=core&module=developer&controller=customtemplates&appKey={$appKey}&do=customTemplateForm&id=" . $template->id ),
),
$buttons['delete'] = array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => Url::internal( "app=core&module=developer&controller=customtemplates&appKey={$appKey}&do=customTemplateDelete&id=" . $template->id ),
'data' => ['delete' => '']
)
), NULL, NULL, $order, FALSE, NULL, NULL, $template->_badge );
$order++;
}
return $rows;
},
/* Get Row */
NULL,
/* Get Row Parent */
function ()
{
return NULL;
},
/* Get Children */
NULL,
/* Get Root Buttons */
function() use ( $appKey )
{
return array(
'add' => array(
'icon' => 'plus',
'title' => 'custom_template_create_new',
'link' => Url::internal( "app=core&module=developer&controller=customtemplates&appKey={$appKey}&do=customTemplateForm" ),
),
);
},
FALSE,
FALSE,
FALSE
);
Output::i()->output = (string) $tree;
}
/**
* Delete a template
*
* @return void
*/
protected function customTemplateDelete(): void
{
try
{
$current = CustomTemplate::load( Request::i()->id );
}
catch( Exception )
{
Output::i()->error( 'node_error', '2C124/1', 404 );
}
/* We just made you, now we must destroy you */
$current->delete();
$this->application->buildCustomTemplates();
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=customtemplates&appKey={$this->application->directory}" ), 'deleted' );
}
/**
* Custom template add/edit form
*
* @return void
* @throws Exception
*/
protected function customTemplateForm(): void
{
if ( isset( Request::i()->id ) )
{
$current = CustomTemplate::load( Request::i()->id );
}
else
{
$current = new CustomTemplate;
}
$current::$scopeKey = 'appKey';
$current::$scopeValue = Request::i()->appKey;
/* Show Form */
$form = new Form();
$current->form( $form );
/* Handle submissions */
if ( $values = $form->values() )
{
$current->saveForm( $current->formatFormValues( $values ) );
$this->application->buildCustomTemplates();
if( Request::i()->isAjax() )
{
Output::i()->json( array() );
}
else
{
if( isset( Request::i()->save_and_reload ) )
{
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=customtemplates&appKey={$this->application->directory}&do=customTemplateForm&id=" . $current->id ), 'saved' );
}
else
{
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=customtemplates&appKey={$this->application->directory}" ), 'saved' );
}
}
}
Output::i()->breadcrumb[] = array(
Url::internal( "app=core&module=developer&controller=customtemplates&appKey={$this->application->directory}" ),
'custom_templates'
);
/* Display */
Output::i()->output = Theme::i()->getTemplate( 'global' )->block( 'custom_templates', $form, FALSE );
}
/**
* @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 );
}
/* Okay, now order */
if ( isset( $order['null'] ) )
{
foreach ( $order['null'] as $id => $position )
{
try
{
$node = CustomTemplate::load( $id );
$node->order = (int) $position;
$node->save();
}
catch ( OutOfRangeException $e ){}
}
}
$this->application->buildCustomTemplates();
}
}
@@ -0,0 +1,949 @@
<?php
/**
* @brief details
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @since 06 Feb 2024
*/
namespace IPS\core\modules\admin\developer;
use DirectoryIterator;
use IPS\Content\Item;
use IPS\Developer\Controller;
use IPS\Extensions\NotificationsAbstract;
use IPS\Helpers\Form;
use IPS\IPS;
use IPS\Lang;
use IPS\Member;
use IPS\Node\Permissions;
use IPS\Output;
use IPS\Theme;
use function count;
use function defined;
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;
}
/**
* details
*/
class details extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
Output::i()->sidebar['actions']['edit'] = [
'icon' => 'pencil',
'title' => 'edit',
'link' => $this->url->setQueryString( 'do', 'edit' )
];
Output::i()->globalControllers[] = 'core.admin.developer.details';
Output::i()->title = Member::loggedIn()->language()->addToStack( 'dev_details' );
Output::i()->output = Theme::i()->getTemplate( 'developer' )->dashboard( $this->application );
}
/**
* @return void
*/
protected function edit() : void
{
$form = new Form;
$this->application->form( $form );
if( $values = $form->values() )
{
$this->application->saveForm( $this->application->formatFormValues( $values ) );
Output::i()->redirect( $this->url );
}
Output::i()->output = (string) $form;
}
/**
* @return void
*/
protected function scan() : void
{
/* If this is a 1st party app, and we don't have our super special constant, don't scan it */
if( in_array( $this->application->directory, IPS::$ipsApps ) and ( !defined( '\IPS\USE_DEVELOPMENT_BUILDS' ) or \IPS\USE_DEVELOPMENT_BUILDS === false ) )
{
Output::i()->json( array(
'html' => Theme::i()->getTemplate( 'support' )->supportBlockList( array() ),
'criticalIssues' => 0,
'recommendedIssues' => 0
) );
}
$scanResults = array( 'advice' => 0, 'failures' => 0, 'list' => array() );
$languageStrings = Lang::readLangFiles( $this->application->directory );
$missingAdminStrings = $this->_scanAdminStrings( $languageStrings );
if( count( $missingAdminStrings ) )
{
$total = 0;
foreach( $missingAdminStrings as $k => $v )
{
$total += count( $v );
}
$scanResults['failures']++;
$scanResults['list'][] = array(
'critical' => TRUE,
'advice' => FALSE,
'success' => FALSE,
'element' => 'acpDevLang',
'dialogTitle' => Member::loggedIn()->language()->addToStack( 'devscan__missing_admin_strings_title' ),
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__missing_admin_strings', false, array( 'pluralize' => array( $total, $total ) ) ),
'body' => Theme::i()->getTemplate( 'developer' )->missingStrings( $missingAdminStrings, $this->application->directory ),
'learnmore' => true
);
}
$missingFrontStrings = $this->_scanFrontStrings( $languageStrings );
if( count( $missingFrontStrings ) )
{
$total = 0;
foreach( $missingFrontStrings as $k => $v )
{
$total += count( $v );
}
$scanResults['failures']++;
$scanResults['list'][] = array(
'critical' => true,
'advice' => false,
'success' => false,
'element' => 'frontDevLang',
'dialogTitle' => Member::loggedIn()->language()->addToStack( 'devscan__missing_front_strings_title' ),
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__missing_front_strings', false, array( 'pluralize' => array( $total, $total ) ) ),
'body' => Theme::i()->getTemplate( 'developer' )->missingStrings( $missingFrontStrings, $this->application->directory ),
'learnmore' => true
);
}
$missingSearchKeywords = $this->_scanSearchKeywords( $languageStrings );
if( count( $missingSearchKeywords ) )
{
$total = 0;
foreach( $missingSearchKeywords as $k => $v )
{
$total += count( $v );
}
$scanResults['failures']++;
$scanResults['list'][] = array(
'critical' => true,
'advice' => false,
'success' => false,
'element' => 'acpDevSearchKeywords',
'dialogTitle' => Member::loggedIn()->language()->addToStack( 'devscan__missing_search_keywords_title' ),
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__missing_search_keywords', false, array( 'pluralize' => array( $total, $total ) ) ),
'body' => Theme::i()->getTemplate( 'developer' )->missingSearchKeywords( $missingSearchKeywords, $this->application->directory ),
'learnmore' => true
);
}
$duplicateSearchKeywords = $this->_scanSearchKeywordsForDupes();
if( count( $duplicateSearchKeywords ) )
{
$total = count( $duplicateSearchKeywords );
$scanResults['advice']++;
$scanResults['list'][] = array(
'critical' => false,
'advice' => true,
'success' => false,
'element' => 'acpDevSearchKeywordsDupes',
'dialogTitle' => Member::loggedIn()->language()->addToStack( 'devscan__dupe_search_keywords_title' ),
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__dupe_search_keywords', false, array( 'pluralize' => array( $total, $total ) ) ),
'body' => Theme::i()->getTemplate( 'developer' )->duplicateSearchKeywords( $duplicateSearchKeywords, $this->application->directory ),
'learnmore' => true
);
}
$missingEmailTemplates = $this->_checkEmailTemplates();
if( count( $missingEmailTemplates ) )
{
$scanResults['failures']++;
$scanResults['list'][] = array(
'critical' => true,
'advice' => false,
'success' => false,
'element' => 'frontDevEmail',
'dialogTitle' => Member::loggedIn()->language()->addToStack( 'devscan__missing_emailtpl_title' ),
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__missing_emailtpl', false, array( 'pluralize' => array( count( $missingEmailTemplates ), count( $missingEmailTemplates ) ) ) ),
'body' => Theme::i()->getTemplate( 'developer' )->missingTemplates( $missingEmailTemplates, $this->application->directory ),
'learnmore' => true
);
}
/* Check for content-related extensions */
foreach( $this->application->extensions( 'core', 'ContentRouter' ) as $ext )
{
/* @var Item $itemClass */
foreach( $ext->classes as $itemClass )
{
/* Bypass for Pages */
if( strpos( $itemClass, 'IPS\cms\Records' ) !== false )
{
continue;
}
if( isset( $itemClass::$containerNodeClass ) )
{
$class = $itemClass::$containerNodeClass;
if( in_array( Permissions::class, class_implements( $class ) ) )
{
$permissionsExtension = false;
foreach( $this->application->extensions( 'core', 'Permissions' ) as $ext )
{
if( array_key_exists( $class, $ext->getNodeClasses() ) )
{
$permissionsExtension = true;
break;
}
}
if( !$permissionsExtension )
{
$scanResults['advice']++;
$scanResults['list'][] = array(
'critical' => false,
'advice' => true,
'success' => false,
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__missing_permissions', false, array( 'sprintf' => $class ) )
);
}
}
}
/* Check EditorLocation for comments/reviews */
$missingExtensions = [];
$extensions = $this->_getJson( ROOT_PATH . "/applications/" . $this->application->directory . "/data/extensions.json" );
if( isset( $itemClass::$commentClass ) or isset( $itemClass::$reviewClass ) )
{
$key = IPS::mb_ucfirst( $itemClass::$module );
if( !isset( $extensions['core']['EditorLocations'] ) or !array_key_exists( $key, $extensions['core']['EditorLocations'] ) )
{
$missingExtensions[] = $this->application->directory . '_' . $key;
$scanResults['failures']++;
$scanResults['list'][] = array(
'critical' => true,
'advice' => false,
'success' => false,
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__missing_editor', false, array( 'sprintf' => $this->application->directory . '_' . $key ) )
);
}
}
/* Check form elements */
foreach( $itemClass::formElements( null, null ) as $element )
{
/* Check editor fields for missing EditorLocation extensions */
if( $element instanceof Form\Editor )
{
/* Did we already check this? */
if( in_array( $element->options['app'] . '_' . $element->options['key'], $missingExtensions ) )
{
continue;
}
$json = ( $element->options['app'] == $this->application->directory ) ? $extensions : $this->_getJson( ROOT_PATH . "/applications/" . $element->options['app'] . "/data/extensions.json" );
if( !isset( $json['core']['EditorLocations'] ) or !array_key_exists( $element->options['key'], $json['core']['EditorLocations'] ) )
{
$missingExtensions[] = $element->options['app'] . '_' . $element->options['key'];
$scanResults['failures']++;
$scanResults['list'][] = array(
'critical' => true,
'advice' => false,
'success' => false,
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__missing_editor', false, array( 'sprintf' => $element->options['app'] . '_' . $element->options['key'] ) )
);
}
}
elseif( $element instanceof Form\Upload and isset( $element->options['storageExtension'] ) )
{
/* Did we already check this? */
if( in_array( $element->options['storageExtension'], $missingExtensions ) )
{
continue;
}
list( $app, $key ) = explode( "_", $element->options['storageExtension'] );
$json = ( $app == $this->application->directory ) ? $extensions : $this->_getJson( ROOT_PATH . "/applications/" . $app . "/data/extensions.json" );
if( !isset( $json['core']['FileStorage'] ) or !array_key_exists( $key, $json['core']['FileStorage'] ) )
{
$missingExtensions[] = $element->options['storageExtension'];
$scanResults['failures']++;
$scanResults['list'][] = array(
'critical' => true,
'advice' => false,
'success' => false,
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__missing_filestorage', false, array( 'sprintf' => $element->options['storageExtension'] ) )
);
}
}
}
}
}
if( count( $this->application->modules( 'front' ) ) )
{
/* Check for Front Navigation items */
$frontNavigation = $this->application->extensions( 'core', 'FrontNavigation', false );
if( !count( $frontNavigation ) )
{
$scanResults['advice']++;
$scanResults['list'][] = array(
'critical' => false,
'advice' => true,
'success' => false,
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__missing_frontnav' )
);
}
/* Check FURLs */
$furls = $this->parseFurlsFile( ROOT_PATH . "/applications/" . $this->application->directory . "/data/furl.json" );
if( !isset( $furls['pages'] ) or empty( $furls['pages'] ) )
{
$scanResults['advice']++;
$scanResults['list'][] = array(
'critical' => false,
'advice' => true,
'success' => false,
'detail' => Member::loggedIn()->language()->addToStack( 'devscan__missing_furls' )
);
}
}
/* Sort so that critical errors are first */
$resultList = $scanResults['list'];
uasort( $resultList, function( $a, $b )
{
if( $a['critical'] and $b['critical'] )
{
return 0;
}
elseif( $a['critical'] and !$b['critical'] )
{
return -1;
}
else
{
return 1;
}
});
Output::i()->json( array(
'html' => Theme::i()->getTemplate( 'support' )->supportBlockList( $resultList ),
'criticalIssues' => $scanResults['failures'],
'recommendedIssues' => $scanResults['advice']
) );
}
/**
* Check for missing admin language strings
*
* @param array $languageStrings
* @return array
*/
protected function _scanAdminStrings( array $languageStrings ) : array
{
$missingAdminStrings = [ 'menu' => [], 'restrictions' => [], 'keywords' => [], 'settings' => [], 'notifications' => [] ];
/* Start with the acp menu */
$menu = $this->_getJson( ROOT_PATH . "/applications/" . $this->application->directory . "/data/acpmenu.json" );
foreach( $menu as $module => $controllers )
{
$string = 'menu__' . $this->application->directory . '_' . $module;
$hasSubMenuItems = false;
foreach( $controllers as $controller => $menuData )
{
if( !array_key_exists( 'menutab__' . $menuData['tab'], $languageStrings ) )
{
$missingAdminStrings['menu'][] = 'menutab__' . $menuData['tab'];
}
if( !array_key_exists( 'menutab__' . $menuData['tab'] . '_icon', $languageStrings ) )
{
$missingAdminStrings['menu'][] = 'menutab__' . $menuData['tab'] . '_icon';
}
if( isset( $menuData['activemenuitem'] ) and $menuData['activemenuitem'] )
{
if( !array_key_exists( 'menu__' . $menuData['activemenuitem'], $languageStrings ) )
{
$missingAdminStrings['menu'][] = 'menu__' . $menuData['activemenuitem'];
}
}
else
{
$hasSubMenuItems = true;
if( !array_key_exists( $string . '_' . $controller, $languageStrings ) )
{
$missingAdminStrings['menu'][] = $string . '_' . $controller;
}
}
}
if( $hasSubMenuItems and !array_key_exists( $string, $languageStrings ) )
{
$missingAdminStrings['menu'][] = $string;
}
}
/* Check ACP restrictions */
$restrictions = $this->_getJson( ROOT_PATH . "/applications/" . $this->application->directory . "/data/acprestrictions.json" );
foreach( $restrictions as $module => $groups )
{
foreach( $groups as $group => $keys )
{
if( !array_key_exists( 'r__' . $group, $languageStrings ) )
{
$missingAdminStrings['restrictions'][] = 'r__' . $group;
}
foreach( $keys as $k => $v )
{
if( !array_key_exists( 'r__' . $k, $languageStrings ) )
{
$missingAdminStrings['restrictions'][] = 'r__' . $k;
}
}
}
}
/* Check search keywords */
$keywords = $this->_getJson( ROOT_PATH . "/applications/" . $this->application->directory . "/data/acpsearch.json" );
foreach( $keywords as $keyword )
{
if( !array_key_exists( $keyword['lang_key'], $languageStrings ) )
{
$missingAdminStrings['keywords'][] = $keyword['lang_key'];
}
}
/* Check Settings */
$settings = $this->_getJson( ROOT_PATH . "/applications/" . $this->application->directory . "/data/settings.json" );
foreach( $settings as $setting )
{
if( !array_key_exists( $setting['key'], $languageStrings ) )
{
$missingAdminStrings['settings'][] = $setting['key'];
}
}
/* I really hate notifications */
foreach( $this->application->extensions( 'core', 'Notifications', false ) as $extensionKey => $ext )
{
/* @var NotificationsAbstract $ext */
$stringsToCheck = [];
foreach( $ext::configurationOptions() as $key => $config )
{
if( isset( $config['adminDescription'] ) )
{
$stringsToCheck[] = $config['adminDescription'];
}
if( isset( $config['admin_lang'] ) )
{
$stringsToCheck = array_merge( $stringsToCheck, array_values( $config['admin_lang'] ) );
}
foreach( $stringsToCheck as $string )
{
if( !array_key_exists( $string, $languageStrings ) )
{
$missingAdminStrings['notifications'][] = $string;
}
}
}
}
/* Check Email Templates */
if( file_exists( ROOT_PATH . "/applications/" . $this->application->directory . "/dev/email" ) )
{
foreach( new DirectoryIterator( ROOT_PATH . "/applications/" . $this->application->directory . "/dev/email" ) as $file )
{
if ( $file->isDir() or $file->isDot() )
{
continue;
}
$dot = strrpos( $file->getFilename(), '.' );
$extension = strtolower( substr( $file->getFilename(), $dot + 1 ) );
if ( $extension == 'txt' )
{
$emailKey = substr( $file->getFilename(), 0, $dot );
if( !array_key_exists( 'emailtpl_' . $emailKey, $languageStrings ) )
{
$missingAdminStrings['emails'][] = 'emailtpl_' . $emailKey;
}
}
}
}
/* FileStorage extensions */
foreach( $this->application->extensions( 'core', 'FileStorage', false ) as $extensionKey => $ext )
{
$stringToCheck = 'filestorage__' . $this->application->directory . '_' . $extensionKey;
if( !array_key_exists( $stringToCheck, $languageStrings ) )
{
$missingAdminStrings['filestorage'][] = $stringToCheck;
}
}
/* re-scan in case another app has the string */
return $this->_rescanStrings( $missingAdminStrings );
}
/**
* Scan for missing front-end strings
*
* @param array $languageStrings
* @return array
*/
protected function _scanFrontStrings( array $languageStrings ) : array
{
$missingFrontStrings = [ 'modules' => [], 'content' => [], 'contentform' => [],'widgets' => [], 'notifications' => [] ];
/* Check modules */
foreach( $this->application->modules( 'front' ) as $module )
{
$string = 'module__' . $this->application->directory . '_' . $module->key;
if( $module->key != 'ajax' and !array_key_exists( $string, $languageStrings ) )
{
$missingFrontStrings['modules'][] = $string;
}
}
/* Check items/comments/reviews */
foreach( $this->application->extensions( 'core', 'ContentRouter' ) as $ext )
{
/* @var Item $itemClass */
foreach( $ext->classes as $itemClass )
{
$classes = [ $itemClass ];
if( isset( $itemClass::$commentClass ) )
{
$classes[] = $itemClass::$commentClass;
}
if( isset( $itemClass::$reviewClass ) )
{
$classes[] = $itemClass::$reviewClass;
}
foreach( $classes as $class )
{
$stringsToCheck = array( $class::$title, '__indefart_' . $class::$title, '__defart_' . $class::$title, $class::$title . '_pl', $class::$title . '_pl_lc' );
foreach( $stringsToCheck as $string )
{
if( !array_key_exists( $string, $languageStrings ) )
{
/* We might have a _plural instead of a _pl */
if( $string == '__defart_' . $class::$title . '_pl' and array_key_exists( $string . 'ural', $languageStrings ) )
{
continue;
}
$missingFrontStrings['content'][] = $string;
}
}
}
if( isset( $itemClass::$containerNodeClass ) )
{
$class = $itemClass::$containerNodeClass;
$stringsToCheck = array( $class::$nodeTitle, $class::$nodeTitle . '_sg', $class::$nodeTitle . '_sg_lc' );
foreach( $stringsToCheck as $string )
{
if( !array_key_exists( $string, $languageStrings ) )
{
$missingFrontStrings['content'][] = $string;
}
}
}
/* Check form elements */
foreach( $itemClass::formElements( null, null ) as $element )
{
/* Hard-coding a list of strings to skip, mainly because we
have some nodes that are also items and this causes a whole bunch of issues here */
if( in_array( $element->name, [ 'container'] ) )
{
continue;
}
/* @var Form\FormAbstract $element */
if( !array_key_exists( $element->name, $languageStrings ) )
{
$missingFrontStrings['contentform'][] = $element->name;
}
}
}
}
/* Check Widgets */
$widgets = $this->_getJson( ROOT_PATH . "/applications/" . $this->application->directory . "/data/widgets.json" );
foreach( $widgets as $k => $v )
{
if( !array_key_exists( 'block_' . $k, $languageStrings ) )
{
$missingFrontStrings['widgets'][] = 'block_' . $k;
}
}
/* Friggin notifications */
foreach( $this->application->extensions( 'core', 'Notifications', false ) as $extensionKey => $ext )
{
/* @var NotificationsAbstract $ext */
$stringsToCheck = [ 'notifications__' . $this->application->directory . '_' . $extensionKey ];
foreach( $ext::configurationOptions() as $key => $config )
{
if( isset( $config['title'] ) )
{
$stringsToCheck[] = $config['title'];
}
if( isset( $config['description'] ) )
{
$stringsToCheck[] = $config['description'];
}
if( isset( $config['notificationTypes'] ) and count( $config['notificationTypes'] ) )
{
foreach( $config['notificationTypes'] as $type )
{
if( file_exists( ROOT_PATH . "/applications/" . $this->application->directory . "dev/email/notification_" . $type . ".phtml" ) )
{
$stringsToCheck[] = 'mailsub__' . $this->application->directory . '_notification_' . $type;
}
}
}
foreach( $stringsToCheck as $string )
{
if( !array_key_exists( $string, $languageStrings ) )
{
$missingFrontStrings['notifications'][] = $string;
}
}
}
}
/* Admin notification emails */
foreach( $this->application->extensions( 'core', 'AdminNotifications', false ) as $extensionKey => $ext )
{
$string = 'mailsub__' . $this->application->directory . '_acp_notification_' . $extensionKey;
if( !array_key_exists( $string, $languageStrings ) )
{
$missingFrontStrings['notifications'][] = $string;
}
}
/* Check Email Templates */
if( file_exists( ROOT_PATH . "/applications/" . $this->application->directory . "/dev/email" ) )
{
foreach( new DirectoryIterator( ROOT_PATH . "/applications/" . $this->application->directory . "/dev/email" ) as $file )
{
if ( $file->isDir() or $file->isDot() )
{
continue;
}
$dot = strrpos( $file->getFilename(), '.' );
$extension = strtolower( substr( $file->getFilename(), $dot + 1 ) );
if ( $extension == 'txt' )
{
$emailKey = substr( $file->getFilename(), 0, $dot );
if( !array_key_exists( 'mailsub__' . $this->application->directory . '_' . $emailKey, $languageStrings ) )
{
$missingFrontStrings['notifications'][] = 'mailsub__' . $this->application->directory . '_' . $emailKey;
}
}
}
}
/* re-scan in case another app has the string */
return $this->_rescanStrings( $missingFrontStrings );
}
/**
* Check missing strings against other apps
*
* @param array $missingStrings
* @return array
*/
protected function _rescanStrings( array $missingStrings ) : array
{
foreach( $missingStrings as $group => $missing )
{
$missing = array_unique( $missing );
if( count( $missing ) )
{
foreach( $missing as $index => $string )
{
if( Member::loggedIn()->language()->checkKeyExists( $string ) )
{
unset( $missing[ $index ] );
}
}
}
/* Do we still have anything missing? */
if( count( $missing ) )
{
$missingStrings[ $group ] = $missing;
}
else
{
unset( $missingStrings[ $group ] );
}
}
return $missingStrings;
}
/**
* Use the menu file to check for the presence of search keywords
*
* @param array $languageStrings
* @return array
*/
protected function _scanSearchKeywords( array $languageStrings=array() ) : array
{
$missingKeywords = [ 'menu' => [], 'settings' => [] ];
/* Load the current search keyword strings */
$keywords = $this->_getJson( ROOT_PATH . "/applications/" . $this->application->directory . "/data/acpsearch.json" );
$existingKeywords = [];
foreach( $keywords as $k => $v )
{
$existingKeywords[] = $v['lang_key'];
}
/* First check the menu items */
$menu = $this->_getJson( ROOT_PATH . "/applications/" . $this->application->directory . "/data/acpmenu.json" );
foreach( $menu as $module => $controllers )
{
$hasSubMenuItems = false;
foreach( $controllers as $controller => $menuData )
{
/* Skip the dev center */
if( $menuData['tab'] == 'developer' )
{
continue;
}
$string = 'menu__' . ( $menuData['activemenuitem'] ?? $this->application->directory . '_' . $module . '_' . $controller );
if( !in_array( $string, $existingKeywords ) )
{
/* Maybe we are referencing the URI somewhere with a different key */
$uri = "app=" . $this->application->directory . "&module=" . ( $menuData['module_url'] ?? $module ) . "&controller=" . $controller . ( ( isset( $menuData['do'] ) and $menuData['do'] ) ? "&do=" . $menuData['do'] : '' );
if( !array_key_exists( $uri, $keywords ) )
{
$missingKeywords['menu'][] = $string;
}
}
}
}
/* Now check settings */
$settings = $this->_getJson( ROOT_PATH . "/applications/" . $this->application->directory . "/data/settings.json" );
foreach( $settings as $setting )
{
/* Skip settings that don't have a language string, because those are likely hidden */
if( array_key_exists( $setting['key'], $languageStrings ) and !in_array( $setting['key'], $existingKeywords ) )
{
/* Skip sitemap settings */
if( str_starts_with( $setting['key'], 'sitemap' ) )
{
continue;
}
/* Check if the setting is somewhere in the list */
$found = false;
foreach( array_keys( $keywords ) as $uri )
{
if( strpos( $uri, "searchResult={$setting['key']}" ) !== false )
{
$found = true;
break;
}
}
if( !$found )
{
//$missingKeywords['settings'][] = $setting['key'];
}
}
}
/* Clear empty sections */
foreach( $missingKeywords as $group => $missing )
{
if( !count( $missing ) )
{
unset( $missingKeywords[ $group ] );
}
}
return $missingKeywords;
}
/**
* Scan the ACP Search keywords and check for duplicates
*
* @return array
*/
protected function _scanSearchKeywordsForDupes() : array
{
$usedLangs = [];
$dupes = [];
$usedParsedLangs = [];
$dupeParsedLangs = [];
$acpSearch = $this->_getJson( ROOT_PATH . '/applications/' . $this->application->directory . '/data/acpsearch.json' );
foreach( $acpSearch as $url => $data )
{
if( array_key_exists( $data['lang_key'], $usedLangs ) )
{
if( !array_key_exists( $data['lang_key'], $dupes ) )
{
$dupes[ $data['lang_key'] ] = [ $usedLangs[ $data['lang_key'] ] ];
}
$dupes[ $data['lang_key'] ][] = $url;
}
else
{
$usedLangs[ $data['lang_key'] ] = $url;
}
if( Member::loggedIn()->language()->checkKeyExists( $data['lang_key'] ) )
{
$parsedLangKey = Member::loggedIn()->language()->get( $data['lang_key'] );
}
else
{
$parsedLangKey = $data['lang_key'];
}
if( array_key_exists( $parsedLangKey, $usedParsedLangs ) )
{
if( !array_key_exists( $parsedLangKey, $dupeParsedLangs ) )
{
$dupeParsedLangs[ $parsedLangKey ] = [
$usedParsedLangs[ $parsedLangKey ]['key'] => [ $usedParsedLangs[ $parsedLangKey ]['url'] ]
];
}
if( !array_key_exists( $data['lang_key'], $dupeParsedLangs[ $parsedLangKey ] ) )
{
$dupeParsedLangs[ $parsedLangKey ][ $data['lang_key'] ] = [];
}
$dupeParsedLangs[ $parsedLangKey ][ $data['lang_key'] ][] = $url;
}
else
{
$usedParsedLangs[ $parsedLangKey ] = [
'key' => $data['lang_key'],
'url' => $url
];
}
}
$return = array_keys( $dupes );
foreach( $dupeParsedLangs as $parsedLangKey => $data )
{
foreach( $data as $langKey => $urls )
{
if( !in_array( $langKey, $return ) )
{
$return[] = $langKey;
}
}
}
return $return;
}
/**
* Compares the email templates against the templates
* in the dev/email folder
*
* @return array
*/
protected function _checkEmailTemplates() : array
{
/* First build a list of email templates that we have */
$templates = [];
$emailFolder = ROOT_PATH . "/applications/" . $this->application->directory . "/dev/email";
/* If we don't have an email folder, then we are missing all of them */
if( file_exists( $emailFolder ) )
{
foreach( new DirectoryIterator( $emailFolder ) as $file )
{
if( $file->isDir() or $file->isDot() )
{
continue;
}
$dot = strrpos( $file->getFilename(), '.' );
$extension = strtolower( substr( $file->getFilename(), $dot + 1 ) );
if ( $extension == 'txt' )
{
$templates[] = substr( $file->getFilename(), 0, $dot );
}
}
}
/* Check the notifications */
$missingTemplates = [];
foreach( $this->application->extensions( 'core', 'Notifications', false ) as $extensionKey => $ext )
{
/* @var NotificationsAbstract $ext */
foreach( $ext::configurationOptions() as $key => $config )
{
/* Don't bother with notifications that don't support email */
if( isset( $config['disabled'] ) and in_array( 'email', $config['disabled'] ) )
{
continue;
}
if( isset( $config['notificationTypes'] ) and count( $config['notificationTypes'] ) )
{
foreach( $config['notificationTypes'] as $type )
{
if( !in_array( 'notification_' . $type, $templates ) )
{
$missingTemplates[] = 'notification_' . $type;
}
}
}
}
}
return $missingTemplates;
}
}
@@ -0,0 +1,331 @@
<?php
/**
* @brief extensions
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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 IPS\Application;
use IPS\Developer;
use IPS\Developer\Controller;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Tree\Tree;
use IPS\Http\Url;
use IPS\Member;
use IPS\Output;
use IPS\Output\UI\UiExtension;
use IPS\Request;
use IPS\Session;
use IPS\Theme;
use function defined;
use function sprintf;
use const IPS\IPS_FOLDER_PERMISSION;
use const IPS\REBUILD_QUICK;
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;
}
/**
* extensions
*/
class extensions extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
/* Check for unsupported extensions */
$message = '';
if( $badClasses = UiExtension::unsupportedExtensions( $this->application ) )
{
$message = Theme::i()->getTemplate( 'global', 'core' )->message( Member::loggedIn()->language()->addToStack( 'dev_unsupported_uinode', true, array( 'sprintf' => implode( ", ", $badClasses ) ) ), 'warning' );
}
$this->url = Url::internal( "app=core&module=developer&controller=extensions&appKey={$this->application->directory}" );
$table = new Tree( $this->url, 'dev_extensions', array( $this, 'extGetRoots' ), array( $this, 'extGetRow' ), array( $this, 'extGetRowParentId' ), array( $this, 'extGetChildren' ), NULL, FALSE, TRUE, TRUE );
Output::i()->output = $message . $table;
}
/**
* Extensions: Get Root Rows
*
* @return array
*/
public function extGetRoots() : array
{
$rows = array();
foreach ( Application::applications() as $app )
{
$haveExtensions = FALSE;
if( is_dir( ROOT_PATH . "/applications/{$app->directory}/data/defaults/extensions" ) )
{
foreach ( new DirectoryIterator( ROOT_PATH . "/applications/{$app->directory}/data/defaults/extensions" ) as $file )
{
if ( mb_substr( $file, 0, 1 ) !== '.' AND $file != 'index.html' )
{
$haveExtensions = TRUE;
break;
}
}
}
if ( $haveExtensions === TRUE )
{
$rows[ $app->directory ] = Theme::i()->getTemplate( 'trees', 'core' )->row( $this->url, $app->directory, $app->directory, TRUE );
}
}
return $rows;
}
/**
* Extensions: Get row
*
* @param string $row Row ID
* @param bool $root Is root?
* @return string
*/
public function extGetRow( string $row, bool $root ) : string
{
return Theme::i()->getTemplate( 'trees', 'core' )->row( $this->url, $row, $row, TRUE, array(), '', NULL, NULL, $root );
}
/**
* Extensions: Get Children
*
* @param string $folder Folder
* @return array
*/
public function extGetChildren( string $folder ) : array
{
$rows = array();
if ( is_dir( ROOT_PATH . "/applications/{$this->application->directory}/extensions/{$folder}" ) )
{
foreach ( new DirectoryIterator( ROOT_PATH . "/applications/{$this->application->directory}/extensions/{$folder}" ) as $file )
{
if ( mb_substr( $file, 0, 1 ) !== '.' AND $file != 'index.html' )
{
$buttons = array();
if ( $file->isDir() )
{
if ( file_exists( ROOT_PATH . "/applications/{$folder}/data/defaults/extensions/{$file}.txt" ) )
{
$title = sprintf( Member::loggedIn()->language()->get( 'dev_extensions_create' ), $file );
$buttons['add'] = array(
'icon' => 'plus-circle',
'title' => $title,
'link' => Url::internal( "app=core&module=developer&controller=extensions&appKey={$this->application->directory}&do=addExtension&type={$file}&extapp={$folder}" ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => $title, 'ipsDialog-forceReload' => true )
);
}
}
else
{
$name = mb_substr( $file, 0, -4 );
if( $ideLink = Developer::getIdeHref( $file->getPathname() ) )
{
$buttons['ide'] = [
'icon' => 'fa-file-code',
'title' => 'open_in_ide',
'link' => $ideLink
];
}
$buttons['delete'] = array(
'icon' => 'times-circle',
'title' => Member::loggedIn()->language()->addToStack('delete'),
'link' => Url::internal( "app=core&module=developer&controller=extensions&appKey={$this->application->directory}&do=removeExtension&file={$name}&ext={$folder}" )->csrf(),
'data' => array( 'delete' => '' )
);
}
$name = str_replace( '\\', '/', mb_substr( $file->getPathName(), mb_strlen( ROOT_PATH . "/applications/{$this->application->directory}/extensions/" ) ) );
$rows[ $name ] = Theme::i()->getTemplate( 'trees', 'core' )->row( $this->url, $name, mb_substr( $name, mb_strlen( $folder ) + 1 ), $file->isDir(), $buttons, $file->isDir() ? ( Member::loggedIn()->language()->addToStack( 'ext__' . mb_substr( $name, mb_strlen( $folder ) + 1 ) ) ) : '', NULL, NULL, FALSE, NULL, NULL, NULL, FALSE, TRUE );
}
}
}
$extensionApp = explode( '/', $folder );
if( count( $extensionApp ) == 1 )
{
foreach ( new DirectoryIterator( ROOT_PATH . "/applications/{$extensionApp[0]}/data/defaults/extensions" ) as $file )
{
if ( mb_substr( $file, 0, 1 ) !== '.' AND $file != 'index.html' )
{
$name = $folder . '/' .mb_substr( $file->getPathName(), mb_strlen( ROOT_PATH . "/applications/{$extensionApp[0]}/data/defaults/extensions/" ), -4 );
if ( !isset( $rows[ $name ] ) )
{
$rows[ $name ] = Theme::i()->getTemplate( 'trees', 'core' )->row(
$this->url,
$name,
mb_substr( $name, mb_strlen( $folder ) + 1 ),
$file->isDir(),
array(
'add' => array(
'icon' => 'plus-circle',
'title' => Member::loggedIn()->language()->addToStack( 'dev_extensions_create', FALSE, array( 'sprintf' => array( mb_substr( $file, 0, -4 ) ) ) ),
'link' => Url::internal( "app=core&module=developer&controller=extensions&appKey={$this->application->directory}&do=addExtension&type=" . mb_substr( $file, 0, -4 ) . "&extapp={$folder}" ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('dev_extensions_create', FALSE, array( 'sprintf' => array( mb_substr( $file, 0, -4 ) ) ) ) )
)
),
Member::loggedIn()->language()->addToStack( 'ext__' . mb_substr( $name, mb_strlen( $folder ) + 1 ) ),
null, // icon
null, // draggablePosition
false, // root
null, // toggleStatus
null, // locked
null, // badge
true, // titleHtml
true // descriptionHtml
);
}
}
}
}
ksort( $rows );
return $rows;
}
/**
* Extensions: Get parent ID
*
* @param string $folder Folder name
* @return string|null
*/
public function extGetRowParentId( string $folder ) : ?string
{
$bits = explode( "/", $folder );
if( count( $bits ) > 1 )
{
return $bits[0];
}
return NULL;
}
/**
* Add Extension
*
* @return void
*/
protected function addExtension() : void
{
$form = new Form();
$form->hiddenFields['type'] = Request::i()->type;
if( Member::loggedIn()->language()->checkKeyExists( 'ext__' . Request::i()->type ) )
{
$form->addHeader( 'ext__' . Request::i()->type );
}
$form->add( new Text( 'dev_extensions_classname', NULL, TRUE, array( 'regex' => '/^[A-Z0-9]+$/i' ) ) );
$additionalFields = Application::load( Request::i()->extapp )->extensionHelper( Request::i()->type, $this->application->directory );
foreach( $additionalFields as $field )
{
$form->add( $field );
}
if ( $values = $form->values() )
{
/* Pre-process any custom fields */
$values = Application::load( Request::i()->extapp )->extensionGenerate( Request::i()->type, $this->application->directory, $values );
$search = array(
"{subpackage}",
'{date}',
'{app}',
'{class}',
);
$replace = array(
( $this->application->directory != 'core' ) ? ( " * @subpackage\t" . Member::loggedIn()->language()->get( "__app_{$this->application->directory}" ) ) : '',
date( 'd M Y' ),
$this->application->directory,
$values['dev_extensions_classname']
);
foreach( $additionalFields as $tag => $field )
{
if( array_key_exists( $field->name, $values ) )
{
if( is_array( $values[ $field->name ] ) and count( $values[ $field->name ] ) )
{
$values[ $field->name ] = implode( ",\n", $values[ $field->name] );
}
}
$search[] = $tag;
$replace[] = $values[ $field->name ];
}
$contents = str_replace(
$search,
$replace,
file_get_contents( ROOT_PATH . '/applications/' . Request::i()->extapp . '/data/defaults/extensions/' . Request::i()->type . '.txt' )
);
$dir = ROOT_PATH . "/applications/{$this->application->directory}/extensions/" . Request::i()->extapp . '/' . Request::i()->type;
if ( !is_dir( $dir ) )
{
mkdir( $dir, IPS_FOLDER_PERMISSION, TRUE );
}
file_put_contents( ROOT_PATH . "/applications/{$this->application->directory}/extensions/" . Request::i()->extapp . '/' . Request::i()->type . "/{$values['dev_extensions_classname']}.php", $contents );
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/extensions.json", $this->application->buildExtensionsJson() );
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=extensions&appKey={$this->application->directory}&root=" . Request::i()->extapp . '/' . Request::i()->type ), 'file_created' );
}
Output::i()->output = Theme::i()->getTemplate('global')->block( Member::loggedIn()->language()->addToStack('dev_extensions_create', FALSE, array( 'sprintf' => array( Request::i()->type ) ) ), $form, FALSE );
}
/**
* Remove Extension
*
* @return void
*/
protected function removeExtension() : void
{
Session::i()->csrfCheck();
if( is_file( ROOT_PATH ."/applications/{$this->application->directory}/extensions/". Request::i()->ext.'/'. Request::i()->file.'.php'))
{
unlink( ROOT_PATH ."/applications/{$this->application->directory}/extensions/". Request::i()->ext.'/'. Request::i()->file.'.php' );
}
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/extensions.json", $this->application->buildExtensionsJson() );
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=extensions&appKey={$this->application->directory}" ), 'deleted' );
}
}
@@ -0,0 +1,427 @@
<?php
/**
* @brief furls
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @since 05 Feb 2024
*/
namespace IPS\core\modules\admin\developer;
use InvalidArgumentException;
use IPS\Application;
use IPS\Developer\Controller;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\YesNo;
use IPS\Helpers\Tree\Tree;
use IPS\Member;
use IPS\Output;
use IPS\Request;
use IPS\Theme;
use function defined;
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;
}
/**
* furls
*/
class furls extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* @var array
*/
protected array $json = [];
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$this->json = $this->_getFurls();
$tree = new Tree(
$this->url,
'dev_furls',
array( $this, '_getFurlKeys' ),
array( $this, '_getFurlRow' ),
function( $folder ){
return null;
},
null,
function(){
return array(
'add' => array(
'icon' => 'plus',
'title' => 'add',
'link' => $this->url->setQueryString( 'do', 'form' )
),
'top' => array(
'icon' => 'edit',
'title' => 'dev_furl_toplevel_edit',
'link' => $this->url->setQueryString( 'do', 'topLevel' ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack( 'dev_furl_toplevel_edit' ) )
)
);
},
true
);
Output::i()->headerMessage = Theme::i()->getTemplate( 'global' )->message( 'dev_furls_info', 'info i-margin-bottom_1' );
Output::i()->output = (string) $tree;
}
/**
* FURL Roots
*
* @return array
*/
public function _getFurlKeys() : array
{
$roots = [];
foreach( $this->json['pages'] as $key => $data )
{
/* Replace all underscores because otherwise the sortable JS does not pick up the correct key */
$roots[ str_replace( '_', '|', $key ) ] = $this->_getFurlRow( $key, $data );
}
return $roots;
}
/**
* FURL Rows
*
* @param string $key
* @param array|null $data
* @return string
*/
public function _getFurlRow( string $key, ?array $data ) : string
{
$key = str_replace( '|', '_', $key );
if( empty( $data ) )
{
$data = $this->json[ 'pages' ][ $key ] ?? array();
}
$allKeys = array_keys( $this->json['pages'] );
$position = array_search( $key, $allKeys ) + 1;
return Theme::i()->getTemplate( 'trees' )->row(
$this->url,
str_replace( '_', '|', $key ),
$key,
false,
array(
'edit' => array(
'icon' => 'pencil',
'title' => 'edit',
'link' => $this->url->setQueryString( array( 'do' => 'form', 'key' => $key ) )
),
'delete' => array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => $this->url->setQueryString( array( 'do' => 'delete', 'key' => $key ) )->csrf(),
'data' => array( 'confirm' => '' )
)
),
Theme::i()->getTemplate( 'developer', 'core' )->furlRowDescription( $data, $this->json['topLevel'] ),
null,
$position,
false,
null,
null,
null,
false,
true
);
}
/**
* Search
*
* @return void
*/
protected function search() : void
{
$rows = [];
$this->json = $this->_getFurls();
foreach( $this->json['pages'] as $key => $data )
{
if( stripos( $data['friendly'], Request::i()->input ) !== false or ( isset( $data['alias'] ) and stripos( $data['alias'], Request::i()->input ) !== false ) or stripos( $data['real'], Request::i()->input ) !== false )
{
$rows[] = $this->_getFurlRow( $key, $data );
}
}
Output::i()->output = Theme::i()->getTemplate( 'trees', 'core' )->rows( $rows, '' );
}
/**
* @return void
*/
protected function reorder() : void
{
$json = $this->_getFurls();
$originalList = $json['pages'];
$json['pages'] = [];
foreach( Request::i()->ajax_order as $key => $empty )
{
$key = str_replace( '|', '_', $key );
$json['pages'][ $key ] = $originalList[ $key ];
}
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/furl.json", $json );
}
/**
* @return void
*/
protected function form() : void
{
$jsonFile = ROOT_PATH . "/applications/{$this->application->directory}/data/furl.json";
$json = $this->_getFurls();
$furlKey = Request::i()->key ?? null;
$row = ( $furlKey !== null and isset( $json['pages'][ $furlKey ] ) ) ? $json['pages'][ $furlKey ] : array();
$self = $this;
$form = new Form;
$form->add( new Text( 'dev_furl_key', $furlKey, true, array(), function( $val ) use ( $self, $furlKey, $json ){
/* Check the current app first, but only if we're adding a new key */
if( $furlKey === null and isset( $json['pages'][ $val ] ) )
{
throw new InvalidArgumentException( "err__dev_duplicate_furl_key" );
}
/* Now loop through all other apps and check for duplicate keys */
foreach( Application::applications() as $application )
{
if( $application->directory != $self->application->directory )
{
$furls = $self->parseFurlsFile( ROOT_PATH . "/applications/{$application->directory}/data/furl.json" );
if( isset( $furls['pages'][ $val ] ) )
{
throw new InvalidArgumentException( "err__dev_duplicate_furl_key" );
}
}
}
} ) );
$form->add( new Text( 'dev_furl_friendly', $row['friendly'] ?? null, true, array(), function( $val ) use ( $self, $json ){
/* Do we have a duplicate in this app? */
foreach( $json['pages'] as $key => $data )
{
if( $data['friendly'] == $val and $key != Request::i()->dev_furl_key )
{
throw new InvalidArgumentException( "err__dev_duplicate_furl_friendly" );
}
}
/* Check other apps for duplicates, this time including the topLevel in the test */
$val = ( $json['topLevel'] ? $json['topLevel'] . '/' : '' ) . $val;
foreach( Application::applications() as $application )
{
if( $application->directory != $self->application->directory )
{
$furls = $self->parseFurlsFile( ROOT_PATH . "/applications/{$application->directory}/data/furl.json" );
if( isset( $furls['pages'] ) )
{
foreach( $furls['pages'] as $key => $data )
{
$test = ( $furls['topLevel'] ? $furls['topLevel'] . '/' : '' ) . $data['friendly'];
if( $test == $val )
{
throw new InvalidArgumentException( "err__dev_duplicate_furl_friendly" );
}
}
}
}
}
}, ( $json['topLevel'] ? $json['topLevel'] . '/' : '' ) ) );
$form->add( new Text( 'dev_furl_real', $row['real'] ?? null, true ) );
$form->add( new Text( 'dev_furl_verify', $row['verify'] ?? null, false ) );
$form->add( new Text( 'dev_furl_alias', $row['alias'] ?? null, false, array(), function( $val ) use ( $self, $furlKey, $json ){
if( $val )
{
/* Does the alias match the FURL? */
if( $val == Request::i()->dev_furl_friendly )
{
throw new InvalidArgumentException( 'err__dev_equal_alias' );
}
/* Check the current app */
foreach( $json['pages'] as $k => $v )
{
if( $k != $furlKey and $v['alias'] == $val )
{
throw new InvalidArgumentException( "err__dev_duplicate_alias" );
}
}
/* Now loop through all other apps and check for duplicate aliases */
foreach( Application::applications() as $application )
{
if( $application->directory != $self->application->directory )
{
$furls = $self->parseFurlsFile( ROOT_PATH . "/applications/{$application->directory}/data/furl.json" );
foreach( $furls['pages'] as $k => $v )
{
if( $v['alias'] == $val )
{
throw new InvalidArgumentException( "err__dev_duplicate_alias" );
}
}
}
}
}
} ) );
$form->add( new YesNo( 'dev_furl_seopagination', $row['seoPagination'] ?? false, false ) );
$form->addHtml( Theme::i()->getTemplate( 'forms' )->blurb( 'dev_furl_seotitle_class_info', true, true ) );
$form->add( new Text( 'dev_furl_seotitle_class', isset( $row['seoTitles'] ) ? mb_substr( $row['seoTitles'][0]['class'], 5 ) : null, false, array(), function( $val ){
if( empty( $val ) and ( Request::i()->dev_furl_seotitle_id or Request::i()->dev_furl_seotitle_property ) )
{
throw new InvalidArgumentException( 'form_required' );
}
}, 'IPS\\' ) );
$form->add( new Text( 'dev_furl_seotitle_id', isset( $row['seoTitles'] ) ? $row['seoTitles'][0]['queryParam'] : null, false, array(), function( $val ){
if( empty( $val ) and ( Request::i()->dev_furl_seotitle_class or Request::i()->dev_furl_seotitle_property ) )
{
throw new InvalidArgumentException( 'form_required' );
}
} ) );
$form->add( new Text( 'dev_furl_seotitle_property', isset( $row['seoTitles'] ) ? $row['seoTitles'][0]['property'] : null, false, array(), function( $val ){
if( empty( $val ) and ( Request::i()->dev_furl_seotitle_class or Request::i()->dev_furl_seotitle_id ) )
{
throw new InvalidArgumentException( 'form_required' );
}
} ) );
if( $values = $form->values() )
{
/* If we changed the key, remove the old one */
if( $furlKey !== null and $values['dev_furl_key'] != $furlKey )
{
unset( $json['pages'][ $furlKey ] );
}
$data = array(
'friendly' => $values['dev_furl_friendly'],
'real' => $values['dev_furl_real']
);
if( $values['dev_furl_verify'] )
{
$data['verify'] = $values['dev_furl_verify'];
}
if( $values['dev_furl_alias'] )
{
$data['alias'] = $values['dev_furl_alias'];
}
if( $values['dev_furl_seopagination'] )
{
$data['seoPagination'] = true;
}
/* Check for SEO Title */
if( $values['dev_furl_seotitle_class'] and $values['dev_furl_seotitle_id'] and $values['dev_furl_seotitle_property'] )
{
$data['seoTitles'] = [
[
'class' => '\\IPS\\' . $values['dev_furl_seotitle_class'],
'queryParam' => $values['dev_furl_seotitle_id'],
'property' => $values['dev_furl_seotitle_property']
]
];
}
$json['pages'][ $values['dev_furl_key'] ] = $data;
$this->_writeJson( $jsonFile, $json );
Output::i()->redirect( $this->url );
}
Output::i()->output = (string) $form;
}
/**
* @return void
*/
protected function delete() : void
{
Request::i()->confirmedDelete();
$json = $this->_getFurls();
if( isset( $json['pages'][ Request::i()->key ] ) )
{
unset( $json['pages'][ Request::i()->key ] );
}
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/furl.json", $json );
Output::i()->redirect( $this->url );
}
/**
* Change the top-level for the FURLs of this app
*
* @return void
*/
protected function topLevel() : void
{
$jsonFile = ROOT_PATH . "/applications/{$this->application->directory}/data/furl.json";
$json = $this->_getFurls();
$form = new Form;
$form->add( new Text( 'dev_furl_toplevel', $json['topLevel'], false ) );
if( $values = $form->values() )
{
$json['topLevel'] = $values['dev_furl_toplevel'];
$this->_writeJson( $jsonFile, $json );
Output::i()->redirect( $this->url );
}
Output::i()->output = (string) $form;
}
/**
* Load and parse the FURL json file.
* We need to account for the very long illegal comments in there
*
* @return array
*/
protected function _getFurls() : array
{
$file = ROOT_PATH . "/applications/{$this->application->directory}/data/furl.json";
if( !file_exists( $file ) )
{
return array(
'topLevel' => '',
'pages' => array()
);
}
return $this->parseFurlsFile( $file );
}
}
@@ -0,0 +1,245 @@
<?php
/**
* @brief listeners
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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 DomainException;
use IPS\Application;
use IPS\Data\Store;
use IPS\Developer;
use IPS\Developer\Controller;
use IPS\Events\ListenerType;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Table\Custom;
use IPS\Http\Url;
use IPS\Member;
use IPS\Output;
use IPS\Request;
use IPS\Session;
use OutOfRangeException;
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;
}
/**
* listeners
*/
class listeners extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$json = ROOT_PATH . "/applications/" . $this->application->directory . "/data/listeners.json";
if ( !file_exists( $json ) )
{
file_put_contents( $json, json_encode( array() ) );
}
$data = array();
foreach ( json_decode( file_get_contents( $json ), TRUE ) as $k => $f )
{
$data[ $k ] = array(
'dev_listeners_filename' => $k,
'dev_listeners_fires' => $f['classname']::getExtendedClass()
);
}
$table = new Custom( $data, Url::internal( "app=core&module=developer&controller=listeners&appKey=" . $this->application->directory ) );
$table->include = array( 'dev_listeners_filename', 'dev_listeners_fires' );
$table->sortBy = $table->sortBy ?: 'dev_listeners_filename';
$table->sortDirection = $table->sortDirection ?: 'asc';
$table->rootButtons = array(
'add' => array(
'icon' => 'plus',
'title' => 'add',
'link' => Url::internal( "app=core&module=developer&controller=listeners&do=listenerAdd&appKey=" . $this->application->directory ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('add') )
)
);
$table->rowButtons = function( $row )
{
$buttons = [];
$path = ROOT_PATH . "/applications/" . $this->application->directory . '/listeners/' . $row['dev_listeners_filename'] . '.php';
if( $ideLink = Developer::getIdeHref( $path ) )
{
$buttons['ide'] = [
'icon' => 'fa-file-code',
'title' => 'open_in_ide',
'link' => $ideLink
];
};
$buttons['delete'] = array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => Url::internal( "app=core&module=developer&controller=listeners&do=listenerDelete&appKey=" . $this->application->directory . "&key=" . $row['dev_listeners_filename'] )->csrf(),
'data' => array( 'delete' => '' )
);
return $buttons;
};
Output::i()->output = (string) $table;
}
/**
* Add a listener
*
* @return void
*/
protected function listenerAdd() : void
{
$form = new Form;
$types = array();
$toggles = array();
foreach( ListenerType::listenerTypes() as $k => $v )
{
/** @var $v ListenerType */
$types[$k] = 'dev_listeners_' . $k;
if( $v::$requiresClassDeclaration )
{
$toggles[$k] = array( 'dev_listeners_classname' );
}
}
$form->add( new Form\Select( 'dev_listeners_type', NULL, TRUE, array(
'options' => $types,
'toggles' => $toggles
) ) );
$form->add( new Text( 'dev_listeners_classname', NULL, NULL, array(), function( $val )
{
$class = 'IPS\\' . $val;
if( !class_exists( $class ) )
{
throw new DomainException( 'dev_listeners_not_supported' );
}
/* @var ListenerType $listenerType */
$listenerType = 'IPS\\Events\\ListenerType\\' . Request::i()->dev_listeners_type;
if( !$listenerType::supportsObject( $class ) )
{
throw new DomainException( 'dev_listeners_not_supported' );
}
}, 'IPS\\', NULL, 'dev_listeners_classname' ) );
$form->add( new Text( 'dev_listeners_filename', NULL, TRUE, array( 'regex' => '/^[a-z0-9_]*$/i' ) ) );
if ( $values = $form->values() )
{
$json = ROOT_PATH . "/applications/" . $this->application->directory . "/data/listeners.json";
$listenerDirectory = ROOT_PATH . "/applications/" . $this->application->directory . "/listeners";
/* @var ListenerType $baseListener */
$baseListener = 'IPS\\Events\\ListenerType\\' . $values['dev_listeners_type'];
$extendedClass = ( isset( $values['dev_listeners_classname'] ) AND $values['dev_listeners_classname'] ) ? 'IPS\\' . $values['dev_listeners_classname'] : NULL;
/* Write PHP file */
$listenerFile = $listenerDirectory . "/{$values['dev_listeners_filename']}.php";
if( !file_exists( $listenerFile ) )
{
if( !is_dir( $listenerDirectory ) )
{
mkdir( $listenerDirectory );
chmod( $listenerDirectory, IPS_FOLDER_PERMISSION );
}
file_put_contents( $listenerFile, str_replace(
array(
'{app}',
'{filename}',
'{date}',
'{class}'
),
array(
$this->application->directory,
$values['dev_listeners_filename'],
date( 'd M Y' ),
'\\' . ( $extendedClass ? $extendedClass . '::class' : '' )
),
file_get_contents( ROOT_PATH . "/applications/core/data/defaults/listeners/{$values['dev_listeners_type']}.txt" )
) );
}
/* Update JSON file */
$listeners = json_decode( file_get_contents( $json ), TRUE );
$listeners[ $values['dev_listeners_filename'] ] = array(
'type' => $values['dev_listeners_type'],
'classname' => 'IPS\\' . $this->application->directory . '\\listeners\\' . $values['dev_listeners_filename'],
'extends' => ( $extendedClass ?: $baseListener::getExtendedClass() )
);
Application::writeJson( $json, $listeners );
/* Clear listener cache */
try
{
unset( Store::i()->listeners );
}
catch( OutOfRangeException ){}
/* Redirect */
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=listeners&appKey=" . $this->application->directory ), 'saved' );
}
Output::i()->output = (string) $form;
}
/**
* Delete a listener
*
* @return void
*/
protected function listenerDelete() : void
{
Session::i()->csrfCheck();
$json = ROOT_PATH . "/applications/" . $this->application->directory . "/data/listeners.json";
$listenerDirectory = ROOT_PATH . "/applications/" . $this->application->directory . "/listeners";
$listeners = json_decode( file_get_contents( $json ), TRUE );
if ( array_key_exists( Request::i()->key, $listeners ) )
{
unset( $listeners[ Request::i()->key ] );
Application::writeJson( $json, $listeners );
if ( file_exists( $listenerDirectory . "/" . Request::i()->key . ".php" ) )
{
unlink( $listenerDirectory . "/" . Request::i()->key . ".php" );
}
/* Clear listener cache */
try
{
unset( Store::i()->listeners );
}
catch( OutOfRangeException ){}
}
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=listeners&do=listenerDelete&appKey=" . $this->application->directory ), 'saved' );
}
}
@@ -0,0 +1,584 @@
<?php
/**
* @brief modulesadmin
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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 IPS\Application\Module;
use IPS\Developer;
use IPS\Developer\Controller;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Select;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\YesNo;
use IPS\Helpers\Tree\Tree;
use IPS\Http\Url;
use IPS\IPS;
use IPS\Member;
use IPS\Output;
use IPS\Request;
use IPS\Session;
use IPS\Theme;
use OutOfRangeException;
use function defined;
use const IPS\FILE_PERMISSION_NO_WRITE;
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;
}
/**
* modulesadmin
*/
class modules extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
/* Get modules */
$appKey = $this->application->directory;
$modules = $this->_getModules();
$location = Request::i()->location ?? 'front';
$this->header = 'dev_modules' . $location;
$this->url = $this->url->setQueryString( 'location', $location );
/* Show tree */
$url = $this->url;
$tree = new Tree(
$url,
Member::loggedIn()->language()->addToStack('dev_modules', FALSE, array( 'sprintf' => array( ucwords( $location ) ) ) ),
/* Get Roots */
function() use ( $appKey, $location, $modules, $url )
{
$rows = array();
if( !empty($modules[ $location ]) AND is_array($modules[ $location ]) )
{
foreach ( $modules[ $location ] as $k => $module )
{
$rows[ $k ] = Theme::i()->getTemplate( 'trees' )->row( $url, $k, $k, TRUE, array(
'default'=> array(
'icon' => ( array_key_exists( 'default', $module ) ) ? ( $module['default'] ? 'star' : 'regular fa-star' ) : 'regular fa-star',
'title' => 'make_default_module',
'link' => $url->setQueryString( array( 'do' => 'setDefaultModule', 'id' => $module['id'] ) )->csrf(),
),
'add' => array(
'icon' => 'plus-circle',
'title' => 'modules_add_controller',
'link' => $url->setQueryString( array( 'do' => 'addController', 'module_key' => $k ) ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('modules_add_controller') )
),
'edit' => array(
'icon' => 'pencil',
'title' => 'edit',
'link' => $url->setQueryString( array( 'do' => 'moduleForm', 'key' => $k ) ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('edit') ),
'hotkey' => 'e'
),
'delete' => array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => $url->setQueryString( array( 'do' => 'deleteModule', 'key' => $k ) ),
'data' => array( 'delete' => '' )
)
), "", NULL, NULL );
}
}
return $rows;
},
/* Get Row */
function( $key, $root=FALSE ) use ( $url, $appKey, $location )
{
return Theme::i()->getTemplate( 'trees' )->row( $url, $key, $key, TRUE, array(
'add' => array(
'icon' => 'plus-circle',
'title' => 'modules_add_controller',
'link' => $url->setQueryString( array( 'do' => 'addController', 'module_key' => $key ) ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('modules_add_controller') )
),
'edit' => array(
'icon' => 'pencil',
'title' => 'edit',
'link' => $url->setQueryString( array( 'do' => 'moduleForm', 'key' => $key ) ),
'data' => array( 'ipDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('edit') ),
'hotkey' => 'e'
),
'delete' => array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => $url->setQueryString( array( 'do' => 'deleteModule', 'key' => $key ) ),
'data' => array( 'delete' => '' )
)
), '', NULL, NULL, $root );
},
/* Get Row's Parent ID */
function( $id )
{
return NULL;
},
/* Get Children */
function( $key ) use ( $appKey, $location, $modules, $url )
{
$rows = array();
foreach ( new DirectoryIterator( ROOT_PATH . "/applications/{$appKey}/modules/{$location}/{$key}" ) as $controller )
{
if ( $controller->isFile() and substr( $controller, 0, 1 ) !== '.' and $controller->getFilename() !== 'index.html' )
{
$buttons = [];
if( $ideLink = Developer::getIdeHref( $controller->getPathname() ) )
{
$buttons['ide'] = [
'icon' => 'fa-file-code',
'title' => 'open_in_ide',
'link' => $ideLink
];
};
$buttons['default'] = array(
'icon' => ( str_replace( '.php', '', $controller ) == $modules[ $location ][ $key ]['default_controller'] ) ? 'fa-star' : 'regular fa-star',
'title' => 'modules_make_default',
'link' => $url->setQueryString( array( 'do' => 'setDefaultController', 'root' => $key, 'default' => (string)$controller ) )->csrf(),
);
$buttons[ 'delete'] = array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => $url->setQueryString( array( 'do' => 'deleteController', 'root' => $key, 'delete' => (string) $controller ) )->csrf(),
'data' => array( 'delete' => '' )
);
$rows[] = Theme::i()->getTemplate( 'trees' )->row( $url, $controller, $controller, FALSE, $buttons, '', NULL, NULL, FALSE, NULL, NULL, ( $modules[ $location ][ $key ]['default_controller'] == $controller ? array( 'green', 'modules_default' ) : NULL ) );
}
}
return $rows;
},
/* Get Root Buttons */
function() use ( $appKey, $location, $url )
{
return array(
'add' => array(
'icon' => 'plus',
'title' => 'modules_add',
'link' => $url->setQueryString( 'do', 'moduleForm' ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('modules_add') )
),
);
},
FALSE,
TRUE,
TRUE
);
Output::i()->output = (string) $tree;
}
/**
* Make this the default module
*
* @return void
*/
public function setDefaultModule() : void
{
Session::i()->csrfCheck();
try
{
$module = Module::load( Request::i()->id );
$module->setAsDefault();
$this->_writeModules( $this->_getModules() );
}
catch ( OutOfRangeException $e )
{
Output::i()->error( 'no_module_for_default', '2C133/A', 403, '' );
}
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=modules&appKey={$module->application}&location={$module->area}" ), 'saved' );
}
/**
* Add/Edit Module
*
* @return void
*/
protected function moduleForm() : void
{
/* Get JSON */
$modules = $this->_getModules();
$application = $this->application;
$location = Request::i()->location;
/* Load existing module if we're editing */
if ( Request::i()->key )
{
if ( !isset( $modules[ $location ][ Request::i()->key ] ) )
{
Output::i()->error( 'node_error', '2C103/J', 404, '' );
}
$current = array( 'module_key' => Request::i()->key, 'protected' => $modules[ $location ][ Request::i()->key ]['protected'], 'default_controller' => $modules[ $location ][ Request::i()->key ]['default_controller'] );
}
else
{
$current = array( 'module_key' => NULL, 'protected' => FALSE, 'default_controller' => NULL );
}
/* Build the form */
$form = new Form();
$form->add( new Text( 'module_key', $current['module_key'], TRUE, array( 'maxLength' => 32 ), function( $val ) use ( $application, $modules, $location, $current )
{
if ( !preg_match( '/^[a-z]*$/', $val ) )
{
throw new DomainException( 'module_key_bad' );
}
try
{
$module = ( !$current['module_key'] OR $current['module_key'] != $val ) ? Module::load( $val, 'sys_module_key', array( 'sys_module_application=? and sys_module_area=?', $application->directory, $location ) ) : NULL;
if( $module === NULL )
{
throw new OutOfRangeException;
}
throw new DomainException( 'module_key_exists' );
}
catch ( OutOfRangeException $e ) {}
} ) );
$form->add( new YesNo( 'module_protected', $current['protected'], TRUE ) );
if ( Request::i()->key )
{
$form->add( new Text( 'module_default_controller', $current['default_controller'] ) );
}
Output::i()->output = Theme::i()->getTemplate( 'global' )->block( 'modules_add', $form, FALSE );
if ( Request::i()->isAjax() )
{
return;
}
/* Handle submissions */
if ( $values = $form->values() )
{
if( $current['module_key'] )
{
$module = Module::get( $this->application->directory, $current['module_key'], $location );
}
else
{
$module = new Module;
$module->application = $this->application->directory;
$module->area = $location;
}
$module->key = $values['module_key'];
$module->protected = $values['module_protected'];
$module->default_controller = $values['module_default_controller'] ?? '';
$module->save();
$modules[ $location ][ $module->key ] = array(
'default_controller' => $module->default_controller,
'protected' => $module->protected,
'default' => $module->default
);
if( $current['module_key'] AND $current['module_key'] != $module->key )
{
unset( $modules[ $location ][ $current['module_key'] ] );
}
$this->_writeModules( $modules );
if( $current['module_key'] )
{
$oldDir = ROOT_PATH . "/applications/{$this->application->directory}/modules/{$location}/{$current['module_key']}";
$newDir = ROOT_PATH . "/applications/{$this->application->directory}/modules/{$location}/{$module->key}";
@rename( $oldDir, $newDir );
chmod( $newDir, IPS_FOLDER_PERMISSION );
}
else
{
$dir = ROOT_PATH . "/applications/{$this->application->directory}/modules/{$location}/{$module->key}";
@mkdir( $dir );
chmod( $dir, IPS_FOLDER_PERMISSION );
}
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=modules&appKey={$this->application->directory}&location={$location}" ), 'saved' );
}
}
/**
* Delete Module
*
* @return void
*/
protected function deleteModule() : void
{
/* Make sure the user confirmed the deletion */
Request::i()->confirmedDelete();
/* Load the module */
try
{
$module = Module::get( $this->application->directory, Request::i()->key, Request::i()->location );
}
catch ( OutOfRangeException $e )
{
Output::i()->error( 'node_error', '2C103/K', 404, '' );
}
/* Remove it from the JSON */
$modules = $this->_getModules();
unset( $modules[ $module->area ][ $module->key ] );
$this->_writeModules( $modules );
/* Delete it */
$location = $module->area;
$module->delete();
/* Redirect */
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=modules&appKey={$this->application->directory}&location={$location}" ) );
}
/**
* Create Controller
*
* @return void
*/
protected function addController() : void
{
$module = $this->_loadModule();
$targetDir = ROOT_PATH . "/applications/{$this->application->directory}/modules/" . Request::i()->location . '/' . $module->key . '/';
$form = new Form();
$form->add( new Text( 'filename', NULL, TRUE, array(), function( $val ) use ( $targetDir )
{
if ( file_exists( $targetDir . $val . '.php' ) )
{
throw new DomainException( 'modules_controller_exists' );
}
}, NULL, '.php' ) );
if ( Request::i()->location === 'admin' )
{
$form->add( new Select( 'type', NULL, TRUE, array(
'options' => array(
'blank' => 'controllertype_blank',
'node' => 'controllertype_node',
'list' => 'controllertype_list',
),
'toggles' => array(
'node' => array( 'model_name' ),
'list' => array( 'database_table_name' ),
)
) ) );
$form->add( new Text( 'model_name', NULL, FALSE, array(),NULL, NULL, NULL, 'model_name' ) );
$form->add( new Text( 'database_table_name', NULL, FALSE, array(),NULL, NULL, NULL, 'database_table_name' ) );
$form->add( new Text( 'acpmenu_tab', $this->application->directory, FALSE, array( 'autocomplete' => array(
'source' => $this->_getAcpMenuTabs(),
'maxItems' => 1
) ) ) );
$form->add( new Select( 'acpmenu_restriction', '__create', FALSE, array( 'options' => array_merge( array(
'' => 'acpmenu_norestriction',
'__create' => 'controller_rcreate',
), $this->_getRestrictions( $module ) ) ) ) );
}
if ( $values = $form->values() )
{
if( !isset($values['type']) )
{
$values['type'] = 'blank';
}
/* Create a restriction? */
$restriction = NULL;
if ( isset( $values['acpmenu_restriction'] ) and $values['acpmenu_restriction'] )
{
if ( $values['acpmenu_restriction'] === '__create' )
{
$restrictions = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json" );
$restrictions[ $module->key ][ $values['filename'] ]["{$values['filename']}_manage"] = "{$values['filename']}_manage";
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json", $restrictions );
$restriction = "{$values['filename']}_manage";
}
else
{
$restriction = $values['acpmenu_restriction'];
}
}
/* Work out the contents */
$contents = str_replace(
array(
'{controller}',
"{subpackage}",
'{date}',
'{app}',
'{module}',
'{location}',
'{restriction}',
'{node_model}',
'{table_name}'
),
array(
$values['filename'],
( $this->application->directory != 'core' ) ? ( " * @subpackage\t" . Member::loggedIn()->language()->get( "__app_{$this->application->directory}" ) ) : '',
date( 'd M Y' ),
$this->application->directory,
Request::i()->module_key,
Request::i()->location,
$restriction ? '\IPS\Dispatcher::i()->checkAcpPermission( \''.$restriction.'\' );' : '',
$values['model_name'] ?? NULL,
$values['database_table_name'] ?? NULL,
),
file_get_contents( ROOT_PATH . "/applications/core/data/defaults/Controller" . IPS::mb_ucfirst( $values['type'] ) . '.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( @file_put_contents( $targetDir . $values['filename'] . '.php', $contents ) === FALSE )
{
Output::i()->error( 'dev_could_not_write_controller', '1C103/H', 403, '' );
}
@chmod( $targetDir . $values['filename'] . '.php', FILE_PERMISSION_NO_WRITE );
/* Add to the menu? */
if ( isset( $values['acpmenu_tab'] ) and $values['acpmenu_tab'] )
{
$menu = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json" );
$menu[ $module->key ][ $values['filename'] ] = array(
'tab' => $values['acpmenu_tab'],
'controller' => $values['filename'],
'do' => '',
'restriction' => $restriction,
'subcontrollers' => ''
);
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json", $menu );
}
/* Boink */
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=modules&appKey={$this->application->directory}&location=" . Request::i()->location . "&root=" . Request::i()->module_key ) );
}
Output::i()->output = Theme::i()->getTemplate( 'global' )->block( 'modules_add_controller', $form, FALSE );
}
/**
* Set a default controller for a module
*
* @return void
*/
protected function setDefaultController() : void
{
Session::i()->csrfCheck();
$location = Request::i()->location;
$modules = $this->_getModules();
$modules[ $location ][ Request::i()->root ]['default_controller'] = mb_substr( Request::i()->default, 0, -4 );
$this->_writeModules( $modules );
$module = Module::get( $this->application->directory, Request::i()->root, $location );
$module->default_controller = mb_substr( Request::i()->default, 0, -4 );
$module->save();
if( !Request::i()->isAjax() )
{
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=modules&location={$location}&appKey=" . $this->application->directory ) );
}
}
/**
* Delete a controller
*
* @return void
*/
protected function deleteController() : void
{
Session::i()->csrfCheck();
$appKey = $this->application->directory;
$location = Request::i()->location;
if ( @unlink( ROOT_PATH . "/applications/{$appKey}/modules/{$location}/" . Request::i()->root . '/' . Request::i()->delete ) === FALSE )
{
Output::i()->error( 'dev_could_not_write_controller', '1C103/I', 403, '' );
}
/* delete all the other stuff */
$restrictions = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json" );
if (isset ( $restrictions[ Request::i()->root ][ mb_substr(Request::i()->delete,0 ,-4 )] ) )
{
unset($restrictions[ Request::i()->root ][ mb_substr(Request::i()->delete,0 ,-4 )] );
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acprestrictions.json", $restrictions );
}
$menu = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json" );
if (isset ( $menu[ Request::i()->root ][ mb_substr(Request::i()->delete,0 ,-4 )] ) )
{
unset($menu[ Request::i()->root ][ mb_substr(Request::i()->delete,0 ,-4 )] );
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/acpmenu.json", $menu );
}
if( !Request::i()->isAjax() )
{
Output::i()->redirect( Url::internal( "app=core&module=developer&controller=modules&appKey={$appKey}&location={$location}" ) );
}
}
/**
* Write modules.json file
*
* @param array $json Data
* @return void
*/
protected function _writeModules( array $json ) : void
{
foreach( $json as $location => $module )
{
foreach( $module as $name => $data )
{
foreach( $data as $k => $v )
{
if ( ! in_array( $k, array( 'protected', 'default_controller', 'default' ) ) )
{
unset( $json[ $location ][ $name ][ $k ] );
}
}
}
}
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/modules.json", $json );
}
}
@@ -0,0 +1,70 @@
<?php
/**
* @brief parser
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @since 27 Mar 2024
*/
namespace IPS\core\modules\admin\developer;
use IPS\Developer\Controller;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Stack;
use IPS\Member;
use IPS\Output;
use function defined;
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;
}
/**
* parser
*/
class parser extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$jsonFile = ROOT_PATH . "/applications/{$this->application->directory}/data/parser.json";
$json = $this->_getJson( $jsonFile );
$form = new Form( 'form' );
$form->addMessage( Member::loggedIn()->language()->addToStack('editor_allowed_formmsg') );
$form->add( new Stack( 'editor_allowed_classes', $json['css'] ?? [], false ) );
$form->add( new Stack( 'editor_allowed_datacontrollers', $json['controllers'] ?? [], false ) );
$form->add( new Stack( 'editor_allowed_iframe_bases', $json['iframe'] ?? [], false ) );
if( $values = $form->values() )
{
$json = [
'css' => $values['editor_allowed_classes'],
'controllers' => $values['editor_allowed_datacontrollers'],
'iframe' => $values['editor_allowed_iframe_bases']
];
$this->_writeJson( $jsonFile, $json );
Output::i()->redirect( $this->url );
}
Output::i()->output = (string) $form;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,134 @@
<?php
/**
* @brief settings
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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 IPS\Db;
use IPS\Developer\Controller;
use IPS\Helpers\Form\Matrix;
use IPS\IPS;
use IPS\Output;
use function defined;
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;
}
/**
* settings
*/
class settings extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$settings = $this->_getJson( ROOT_PATH . "/applications/{$this->application->directory}/data/settings.json" );
$form = new Matrix();
$form->langPrefix = 'dev_settings_';
$form->columns = array(
'key' => array( 'Text', NULL, TRUE ),
'default' => array( 'Text' ),
);
if ( in_array( $this->application->directory, IPS::$ipsApps ) )
{
$form->columns['report'] = array( 'Select', 'none', FALSE, array( 'options' => array(
'none' => 'dev_settings_none',
'full' => 'dev_settings_full',
'bool' => 'dev_settings_bool',
) ) );
}
$form->rows = $settings;
if ( $form->values() !== FALSE )
{
$values = $form->values();
if ( !empty( $form->changedRows ) )
{
foreach ( $form->changedRows as $key )
{
Db::i()->update( 'core_sys_conf_settings', array( 'conf_key' => $values[ $key ]['key'], 'conf_default' => $values[ $key ]['default'] ), array( 'conf_key=?', $form->rows[ $key ]['key'] ) );
}
}
if ( !empty( $form->removedRows ) )
{
$delete = array();
foreach ( $form->removedRows as $key )
{
$delete[] = $form->rows[ $key ]['key'];
}
Db::i()->delete( 'core_sys_conf_settings', Db::i()->in( 'conf_key', $delete ) );
}
/* Load existing keys before we add new ones, to prevent duplicates */
$existingKeys = iterator_to_array(
Db::i()->select( 'conf_key', 'core_sys_conf_settings', [ 'conf_app=?', $this->application->directory ] )
);
if ( !empty( $form->addedRows ) )
{
$insert = array();
foreach ( $form->addedRows as $key )
{
if( !in_array( $values[ $key ]['key'], $existingKeys ) )
{
$insert[] = array( 'conf_key' => $values[ $key ]['key'], 'conf_value' => $values[ $key ]['default'], 'conf_default' => $values[ $key ]['default'], 'conf_app' => $this->application->directory );
}
else
{
/* If this is a duplicate, remove it from the values,
otherwise it will add it to the JSON file */
unset( $values[ $key ] );
}
}
if( count( $insert ) )
{
Db::i()->insert( 'core_sys_conf_settings', $insert );
}
}
$save = array();
foreach ( $values as $data )
{
if ( $data['key'] )
{
$save[] = $data;
}
}
usort( $save, function( $a, $b )
{
return strnatcmp( $a['key'], $b['key'] );
} );
$this->_writeJson( ROOT_PATH . "/applications/{$this->application->directory}/data/settings.json", $save );
Output::i()->redirect( $this->url );
}
Output::i()->output = (string) $form;
}
}
@@ -0,0 +1,340 @@
<?php
/**
* @brief tasks
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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 DateInterval;
use DomainException;
use Exception;
use InvalidArgumentException;
use IPS\DateTime;
use IPS\Db;
use IPS\Developer;
use IPS\Developer\Controller;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Table\Custom;
use IPS\Http\Url;
use IPS\Member;
use IPS\Output;
use IPS\Request;
use IPS\Session;
use IPS\Task;
use IPS\Theme;
use OutOfRangeException;
use UnderflowException;
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;
}
/**
* tasks
*/
class tasks extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$json = ROOT_PATH . "/applications/{$this->application->directory}/data/tasks.json";
$data = array();
foreach ( json_decode( file_get_contents( $json ), TRUE ) as $k => $f )
{
$data[ $k ] = array(
'dev_task_key' => $k,
'dev_task_frequency' => $f
);
}
$table = new Custom( $data, $this->url );
$table->quickSearch = 'dev_task_key';
$table->rootButtons = array(
'add' => array(
'icon' => 'plus',
'title' => 'add',
'link' => $this->url->setQueryString( 'do', 'taskForm' ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('add') )
)
);
$url = $this->url;
$table->rowButtons = function( $row ) use ( $url )
{
$task = null;
try
{
$task = Task::load( $row['dev_task_key'], 'key' );
}
catch( OutOfRangeException ){}
$buttons = array();
$fileLink = ROOT_PATH . "/applications/{$this->application->directory}/tasks/{$row['dev_task_key']}.php";
if( $ideLink = Developer::getIdeHref($fileLink ) )
{
$buttons['ide'] = [
'icon' => 'fa-file-code',
'title' => 'open_in_ide',
'link' => $ideLink
];
};
$buttons['edit'] = array(
'icon' => 'pencil',
'title' => 'edit',
'link' => $url->setQueryString( array( 'do' => 'taskForm', 'key' => $row['dev_task_key'] ) ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('edit') )
);
$buttons['delete'] = array(
'icon' => 'times-circle',
'title' => 'delete',
'link' => $url->setQueryString( array( 'do' => 'taskDelete', 'key' => $row['dev_task_key'] ) )->csrf(),
'data' => array( 'delete' => '' )
);
if( $task !== null )
{
if( $task->running )
{
$buttons['unlock'] = [
'icon' => 'unlock',
'title' => 'task_manager_unlock',
'link' => Url::internal( "app=core&module=settings&controller=advanced&do=unlockTask&id={$task->id}" )->csrf()
];
}
else
{
$buttons['run'] = [
'icon' => 'play-circle',
'title' => 'task_manager_run',
'link' => Url::internal( "app=core&module=settings&controller=advanced&do=runTask&id={$task->id}" )->csrf()
];
}
$buttons['logs'] = array(
'icon' => 'search',
'title' => 'task_manager_logs',
'link' => Url::internal( "app=core&module=settings&controller=advanced&do=taskLogs&id={$task->id}" )
);
}
return $buttons;
};
$table->parsers = array(
'dev_task_frequency' => function( $v )
{
$interval = new DateInterval( $v );
$return = array();
foreach ( array( 'y' => 'years', 'm' => 'months', 'd' => 'days', 'h' => 'hours', 'i' => 'minutes', 's' => 'seconds' ) as $k => $v )
{
if ( $interval->$k )
{
$return[] = Member::loggedIn()->language()->addToStack( 'every_x_' . $v, FALSE, array( 'pluralize' => array( $interval->format( '%' . $k ) ) ) );
}
}
return Member::loggedIn()->language()->formatList( $return );
}
);
Output::i()->output = (string) $table;
}
/**
* @return void
*/
protected function taskForm() : void
{
$json = ROOT_PATH . "/applications/{$this->application->directory}/data/tasks.json";
$form = new Form;
$current = NULL;
if ( isset( Request::i()->key ) )
{
$tasks = json_decode( file_get_contents( $json ), TRUE );
if ( array_key_exists( Request::i()->key, $tasks ) )
{
$current = array(
'dev_task_key' => Request::i()->key,
'dev_task_frequency' => new DateInterval( $tasks[ Request::i()->key ] )
);
try
{
$current['id'] = Db::i()->select( 'id', 'core_tasks', array( '`key`=?', Request::i()->key ) )->first();
}
catch( UnderflowException $e ){}
$form->hiddenValues['old'] = $current['dev_task_key'];
}
unset( $tasks );
}
$form->add( new Text( 'dev_task_key', $current ? $current['dev_task_key'] : NULL, TRUE, array( 'maxLength' => 255, 'regex' => '/^[a-z0-9_]*$/i' ), function( $val ) use ( $current )
{
$where = array( array( '`key`=?', $val ) );
if ( isset( $current['id'] ) )
{
$where[] = array( 'id<>?', $current['id'] );
}
if ( Db::i()->select( 'count(*)', 'core_tasks', $where )->first() )
{
throw new DomainException( 'dev_task_key_err' );
}
} ) );
$form->add( new Form\Custom( 'dev_task_frequency', $current ? $current['dev_task_frequency'] : NULL, TRUE, array(
'getHtml' => function( $element )
{
return Theme::i()->getTemplate( 'forms', 'core' )->dateinterval( $element->name, $element->value ?: new DateInterval( 'P0D' ) );
},
'formatValue' => function ( $element )
{
if ( !( $element->value instanceof DateInterval ) )
{
if( !empty($element->value) )
{
try
{
$interval = new DateInterval( "P{$element->value['y']}Y{$element->value['m']}M{$element->value['d']}DT{$element->value['h']}H{$element->value['i']}M{$element->value['s']}S" );
}
catch( Exception $e )
{
$interval = DateInterval::createFromDateString('1 day');
}
}
else
{
$interval = DateInterval::createFromDateString('1 day');
}
return $interval;
}
return $element->value;
}
), function ( $val )
{
foreach ( $val as $k => $v )
{
if ( $v )
{
return;
}
}
throw new InvalidArgumentException( 'form_required' );
} ) );
if ( $values = $form->values() )
{
/* Write PHP file */
$taskDirectory = ROOT_PATH . "/applications/{$this->application->directory}/tasks";
$taskFile = $taskDirectory . "/{$values['dev_task_key']}.php";
if ( isset( $values['old'] ) and $values['old'] !== $values['dev_task_key'] and file_exists( $taskDirectory . "/{$values['old']}.php" ) )
{
@rename( $taskDirectory . "/{$values['old']}.php", $taskFile );
Db::i()->delete( 'core_tasks', array( '`key`=?', $values['old'] ) );
}
if ( !file_exists( $taskFile ) )
{
if ( !is_dir( $taskDirectory ) )
{
mkdir( $taskDirectory );
chmod( $taskDirectory, IPS_FOLDER_PERMISSION);
}
file_put_contents( $taskFile, str_replace(
array(
'{key}',
"{subpackage}\n",
'{date}',
'{namespace}',
),
array(
$values['dev_task_key'],
( $this->application->directory != 'core' ) ? ( " * @subpackage\t" . $this->application->directory . "\n" ) : '',
date( 'd M Y' ),
$this->application->directory . '\tasks',
),
file_get_contents( ROOT_PATH . "/applications/core/data/defaults/Task.txt" )
) );
}
/* Add to DB */
$frequency = "P{$values['dev_task_frequency']->y}Y{$values['dev_task_frequency']->m}M{$values['dev_task_frequency']->d}DT{$values['dev_task_frequency']->h}H{$values['dev_task_frequency']->i}M{$values['dev_task_frequency']->s}S";
Db::i()->replace( 'core_tasks', array(
'app' => $this->application->directory,
'key' => $values['dev_task_key'],
'frequency' => $frequency,
'next_run' => DateTime::create()->add( new DateInterval( $frequency ) )->getTimestamp(),
'running' => 0,
) );
/* Add to JSON file */
$tasks = json_decode( file_get_contents( $json ), TRUE );
$tasks[ $values['dev_task_key'] ] = $frequency;
if ( isset( $values['old'] ) and $values['old'] !== $values['dev_task_key'] and isset( $tasks[ $values['old'] ] ) )
{
unset( $tasks[ $values['old'] ] );
}
file_put_contents( $json, json_encode( $tasks ) );
/* Redirect */
Output::i()->redirect( $this->url, 'saved' );
}
Output::i()->output = (string) $form;
}
/**
* @return void
*/
protected function taskDelete() : void
{
Session::i()->csrfCheck();
$json = ROOT_PATH . "/applications/{$this->application->directory}/data/tasks.json";
$tasks = json_decode( file_get_contents( $json ), TRUE );
if ( array_key_exists( Request::i()->key, $tasks ) )
{
unset( $tasks[ Request::i()->key ] );
file_put_contents( $json, json_encode( $tasks ) );
$taskDirectory = ROOT_PATH . "/applications/{$this->application->directory}/tasks";
if ( file_exists( $taskDirectory . "/" . Request::i()->key . ".php" ) )
{
unlink( $taskDirectory . "/" . Request::i()->key . ".php" );
}
Db::i()->delete( 'core_tasks', array( 'app=? AND `key`=?', $this->application->directory, Request::i()->key ) );
}
Output::i()->redirect( $this->url, 'saved' );
}
}
@@ -0,0 +1,546 @@
<?php
/**
* @brief themeeditor
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @since 16 Feb 2024
*/
namespace IPS\core\modules\admin\developer;
use IPS\Application;
use IPS\Db;
use IPS\Developer\Controller;
use IPS\Helpers\Badge;
use IPS\Helpers\Form;
use IPS\Helpers\Tree\Tree;
use IPS\Member;
use IPS\Output;
use IPS\Request;
use IPS\Session;
use IPS\Theme;
use IPS\Theme\Editor\Category;
use IPS\Theme\Editor\Setting;
use OutOfRangeException;
use function defined;
/* 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;
}
/**
* themeeditor
*/
class themeeditor extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
$tree = new Tree(
$this->url,
'dev_themeeditor',
array( $this, '_getCategories' ),
array( $this, '_getCategoryRow' ),
function( $id ){
return null;
},
array( $this, '_getCategoryChildren' ),
function(){
return [
'add' => [
'icon' => 'plus',
'title' => 'add',
'link' => $this->url->setQueryString( 'do', 'categoryForm' ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('add') )
]
];
},
true
);
Output::i()->output = (string) $tree;
}
/**
* @return array
*/
public function _getCategories() : array
{
$return = [];
foreach( Category::roots( null ) as $category )
{
/* @var Category $category */
if( $category->app and !$category->set_id )
{
$return[ $category->id ] = $this->_getCategoryRow( $category );
}
}
return $return;
}
/**
* @param int|Category $category
* @return string
*/
public function _getCategoryRow( int|Category $category ) : string
{
if( is_numeric( $category ) )
{
$category = Category::load( $category );
}
$buttons = [];
if( $category->app == $this->application->directory )
{
$buttons['edit'] = [
'icon' => 'pencil',
'title' => 'edit',
'link' => $this->url->setQueryString( array( 'do' => 'categoryForm', 'id' => $category->id ) ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('edit') )
];
$buttons['delete'] = [
'icon' => 'times-circle',
'title' => 'delete',
'link' => $this->url->setQueryString( array( 'do' => 'categoryDelete', 'id' => $category->id ) )->csrf(),
'data' => ( $category->hasChildren( NULL ) or $category->showDeleteOrMoveForm() ) ? array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('delete') ) : array( 'delete' => '' ),
'hotkey'=> 'd'
];
}
if( $category->hasChildren( null, null, false ) or !$category->hasContents() )
{
$buttons['child'] = [
'icon' => 'plus',
'title' => 'themeeditor_cat_add',
'link' => $this->url->setQueryString( array( 'do' => 'categoryForm', 'parent' => $category->id ) )
];
}
elseif( $category->hasSettings() or $category->hasColors() or !$category->hasContents() )
{
$buttons['add'] = array(
'icon' => 'plus-circle',
'title' => Category::$nodeTitle . '_add_child',
'link' => $this->url->setQueryString( array( 'do' => 'settingForm', 'category' => $category->id ) )
);
}
$badge = null;
if( $category->app and $category->app != $this->application->directory )
{
$badge = [
0 => 'ipsBadge ipsBadge--intermediary',
1 => Application::$titleLangPrefix . $category->app,
2 => new Badge( Badge::BADGE_INTERMEDIARY, Member::loggedIn()->language()->addToStack( Application::$titleLangPrefix . $category->app ), icon: 'fa-lock' )
];
}
return Theme::i()->getTemplate( 'trees', 'core' )->row(
$this->url,
$category->id,
$category->_title,
$category->hasContents(),
$buttons,
'', // description
$category->icon(),
$category->position,
false,
null, // toggleStatus
( $category->app != $this->application->directory ), // locked
$badge, // badge
false, // titleHtml
false // descriptionHtml
);
}
public function _getCategoryChildren( int|Category $category ) : array
{
if( is_numeric( $category ) )
{
$category = Category::load( $category );
}
$children = [];
foreach( $category->children() as $child )
{
if( $child->set_id )
{
continue;
}
if( $child instanceof Setting )
{
$children[ 's.' . $child->id ] = $this->_getSettingRow( $child );
}
else
{
$children[ $child->id ] = $this->_getCategoryRow( $child );
}
}
return $children;
}
/**
* @param int|Setting $setting
* @return string
*/
public function _getSettingRow( int|Setting $setting ) : string
{
if( is_numeric( $setting ) )
{
$setting = Setting::load( $setting );
}
$buttons = [];
if( $setting->app == $this->application->directory )
{
$buttons['edit'] = [
'icon' => 'pencil',
'title' => 'edit',
'link' => $this->url->setQueryString( array( 'do' => 'settingForm', 'id' => $setting->id ) )
];
$buttons['delete'] = [
'icon' => 'times-circle',
'title' => 'delete',
'link' => $this->url->setQueryString( array( 'do' => 'settingDelete', 'id' => $setting->id ) )->csrf(),
'data' => array( 'delete' => '' ),
'hotkey'=> 'd'
];
}
$badge = null;
if( $setting->app != $this->application->directory )
{
$badge = [
0 => 'ipsBadge ipsBadge--intermediary',
1 => Application::$titleLangPrefix . $setting->app,
2 => new Badge( Badge::BADGE_INTERMEDIARY, Member::loggedIn()->language()->addToStack( Application::$titleLangPrefix . $setting->app ), icon: 'fa-lock' )
];
}
return Theme::i()->getTemplate( 'trees', 'core' )->row(
$this->url,
's.' . $setting->id,
$setting->_title,
false,
$buttons,
$setting->description, // description
null, // icon
$setting->position,
false,
null, // toggleStatus
null, // locked
$badge, // badge
false, // titleHtml
true, // descriptionHtml
false, // acceptsChildren
false // canBeRoot
);
}
/**
* @return void
*/
protected function categoryForm() : void
{
$form = new Form;
if( isset( Request::i()->id ) )
{
try
{
$category = Category::load( Request::i()->id );
if( !$category->canEdit() or $category->app != $this->application->directory )
{
Output::i()->error( 'node_noperm_edit', '2D101/A', 403 );
}
}
catch( OutOfRangeException )
{
Output::i()->error( 'node_error', '2D101/B', 404 );
}
}
else
{
$category = new Category;
$category->app = $this->application->directory;
$category->parent = Request::i()->parent ?? 0;
}
$category->form( $form );
if( $values = $form->values() )
{
$category->saveForm( $category->formatFormValues( $values ) );
$this->application->buildThemeEditorSettings();
Output::i()->redirect( $this->url );
}
Output::i()->output = (string) $form;
}
/**
* @return void
*/
protected function categoryDelete() : void
{
try
{
$category = Category::load( Request::i()->id );
if( !$category->canEdit() or $category->app != $this->application->directory )
{
Output::i()->error( 'node_noperm_edit', '2D101/C', 403 );
}
}
catch( OutOfRangeException )
{
Output::i()->error( 'node_error', '2D101/D', 404 );
}
/* Do we have any children or content? */
if ( $category->hasChildren( NULL ) or $category->showDeleteOrMoveForm() or isset( Request::i()->ajaxValidate ) )
{
$form = $category->deleteOrMoveForm();
if ( $values = $form->values() )
{
$category->deleteOrMoveFormSubmit( $values );
if ( isset( $values['node_move_children'] ) AND $values['node_move_children'] )
{
$moveToId = ( isset( $values['node_destination'] ) ) ? $values['node_destination'] : Request::i()->node_move_children;
Session::i()->log( 'acplog__node_deleted_m', array( Category::$nodeTitle => TRUE, $category->titleForLog() => FALSE, $category::load( $moveToId )->titleForLog() => FALSE ) );
}
else
{
Session::i()->log( 'acplog__node_deleted_c', array( Category::$nodeTitle => TRUE, $category->titleForLog() => FALSE ) );
}
$this->application->buildThemeEditorSettings();
Output::i()->redirect( $this->url, 'deleted' );
}
else
{
/* Show form */
Output::i()->output = $form;
return;
}
}
else
{
/* Make sure the user confirmed the deletion */
Request::i()->confirmedDelete();
}
/* Delete it */
Session::i()->log( 'acplog__node_deleted', array( Category::$nodeTitle => TRUE, $category->titleForLog() => FALSE ) );
$category->delete();
$this->application->buildThemeEditorSettings();
/* Boink */
if( Request::i()->isAjax() )
{
Output::i()->json( "OK" );
}
else
{
Output::i()->redirect( $this->url->setQueryString( array( 'root' => ( $category->parent() ? $category->parent()->_id : '' ) ) ), 'deleted' );
}
}
/**
* @return void
*/
protected function settingForm() : void
{
$form = new Form;
if( isset( Request::i()->id ) )
{
try
{
$setting = Setting::load( Request::i()->id );
if( !$setting->canEdit() or $setting->app != $this->application->directory )
{
Output::i()->error( 'node_noperm_edit', '2D101/E', 403 );
}
}
catch( OutOfRangeException )
{
Output::i()->error( 'node_error', '2D101/F', 404 );
}
}
else
{
$setting = new Setting;
$setting->app = $this->application->directory;
$setting->category_id = Request::i()->category ?? 0;
}
$setting->form( $form );
if( $values = $form->values() )
{
$setting->saveForm( $setting->formatFormValues( $values ) );
$this->application->buildThemeEditorSettings();
Output::i()->redirect( $this->url );
}
Output::i()->output = (string) $form;
}
/**
* @return void
*/
protected function settingDelete() : void
{
Request::i()->confirmedDelete();
try
{
$setting = Setting::load( Request::i()->id );
Session::i()->log( 'acplog__node_deleted', array( Setting::$nodeTitle => TRUE, $setting->titleForLog() => FALSE ) );
$setting->delete();
$this->application->buildThemeEditorSettings();
}
catch( OutOfRangeException ){}
Output::i()->redirect( $this->url, 'deleted' );
}
/**
* Reorder
*
* @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 );
}
/* Okay, now order */
foreach( $order as $parent => $settings )
{
foreach( $settings as $id => $position )
{
if( mb_substr( $id, 0, 2 ) == 's.' )
{
try
{
$setting = Setting::load( mb_substr( $id, 2 ) );
$setting->category_id = $parent;
$setting->position = $position;
$setting->save();
}
catch( OutOfRangeException ){}
}
else
{
try
{
$category = Category::load( $id );
$category->position = $position;
if( is_numeric( $parent ) )
{
$category->parent = $parent;
}
$category->save();
}
catch( OutOfRangeException ){}
}
}
}
$this->application->buildThemeEditorSettings();
/* If this is an AJAX request, just respond */
if( Request::i()->isAjax() )
{
return;
}
/* Otherwise, redirect */
else
{
Output::i()->redirect( $this->url->setQueryString( array( 'root' => Request::i()->root ) ) );
}
}
/**
* Search
*
* @return void
*/
protected function search() : void
{
$rows = array();
/* Get results */
$nodeClass = Category::class;
$results = $nodeClass::search( '_title', Request::i()->input, '_title' );
/* Get results of subnodes */
if ( isset( $nodeClass::$subnodeClass ) )
{
$subnodeClass = $nodeClass::$subnodeClass;
$results = array_merge( $results, array_values( $subnodeClass::search( '_title', Request::i()->input, '_title' ) ) );
usort( $results, function( $a, $b ) {
return strnatcasecmp( $a->_title, $b->_title );
} );
}
/* Convert to HTML */
foreach ( $results as $result )
{
if( $result instanceof Category )
{
$rows[ $result->_id ] = $this->_getCategoryRow( $result );
}
else
{
$rows[ 's.' . $result->_id ] = $this->_getSettingRow( $result );
}
}
Output::i()->output = Theme::i()->getTemplate( 'trees', 'core' )->rows( $rows, '' );
}
}
@@ -0,0 +1,641 @@
<?php
/**
* @brief versions
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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( '&lt;?php', '', highlight_string( "<?php \\IPS\\Db::i()->{$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;
}
}
@@ -0,0 +1,53 @@
<?php
/**
* @brief widgets
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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 IPS\Developer\Controller;
use IPS\Output;
use IPS\Widget;
use function defined;
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;
}
/**
* widgets
*/
class widgets extends Controller
{
/**
* @var bool
*/
public static bool $csrfProtected = true;
/**
* ...
*
* @return void
*/
protected function manage() : void
{
Output::i()->output = Widget::devTable(
ROOT_PATH . "/applications/{$this->application->directory}/data/widgets.json",
$this->url,
ROOT_PATH . "/applications/{$this->application->directory}/widgets",
$this->application->directory,
$this->application->directory,
$this->application->directory
);
}
}