Version 5.0.13

This commit is contained in:
Neo committed 2025-12-19 19:04:57 -08:00
1 parent b89858b2ff
commit 5efe744c5f
1407 files changed
+69690 -30177

No files matched your search

@@ -27,6 +27,8 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
/**
* keyCAPTCHA
* @deprecated
*
*/
class Keycaptcha implements CaptchaInterface
{
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* @brief Cloudflare Turnstile
* @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 13 June 2025
*/
namespace IPS\Helpers\Form\Captcha;
use IPS\Http\Request\Exception;
use IPS\Http\Url;
use IPS\Log;
use IPS\Member;
use IPS\Platform\Bridge;
use IPS\Request;
use IPS\Settings;
use IPS\Theme;
use RuntimeException;
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;
}
/**
* Cloudflare Turnstile
*/
class Turnstile implements CaptchaInterface
{
/**
* Does this CAPTCHA service support being added in a modal?
*/
public static bool $supportsModal = TRUE;
/**
* @brief Error
*/
protected ?string $error;
/**
* Display
*
* @return string
*/
public function getHtml(): string
{
return Theme::i()->getTemplate( 'forms', 'core', 'global' )->captchaTurnstile( Bridge::i()->getTurnstileCredentials()['site_key'], preg_replace( '/^(.+?)\..*$/', '$1', Member::loggedIn()->language()->short ) );
}
/**
* Verify
*
* @return bool|null TRUE/FALSE indicate if the test passed or not. NULL indicates the test failed, but the captcha system will display an error so we don't have to.
*/
public function verify(): ?bool
{
try
{
$response = Url::external( 'https://challenges.cloudflare.com/turnstile/v0/siteverify' )->request(5)->post( [
'secret' => Bridge::i()->getTurnstileCredentials()['secret_key'],
'response' => trim( Request::i()->__get('cf-turnstile-response') ),
'remoteip' => Request::i()->ipAddress(),
] )->decodeJson();
return ( ( $response['success'] ) and ( $response['hostname'] === Url::internal('')->data[ Url::COMPONENT_HOST ] ) );
}
catch( Exception $e )
{
Log::log( $e, 'turnstile_exception' );
return FALSE;
}
catch( RuntimeException $e )
{
if( $e->getMessage() == 'BAD_JSON' )
{
return FALSE;
}
else
{
throw $e;
}
}
}
}
+63 -26
View File
@@ -1002,6 +1002,12 @@ class Editor extends FormAbstract
return false;
}
/* Return if we have plugins already */
if( !empty( Store::i()->editorPluginJs ) )
{
return TRUE;
}
/* If we are not in dev, check if we have the bundled JS, and if not, build it */
$pluginJs = "";
$hasPlugins = false;
@@ -1063,10 +1069,8 @@ js;
$pluginJs = Minifier::minify( $pluginJs, array( 'flaggedComments' => false ) );
$jsFile = File::create( 'core_Theme', "editorPlugins.js", $pluginJs );
Settings::i()->changeValues([
'editor_plugin_js' => (string) $jsFile
]);
$jsFile = File::create( 'core_Theme', "editorPlugins.js", $pluginJs, container: 'editor', obscure: false );
Store::i()->editorPluginJs = (string) $jsFile;
}
return $hasPlugins;
@@ -1141,6 +1145,40 @@ js;
return false;
}
/**
* Get editor restrictions for the member based on group settings
*
* @param Member|null $member The member/null for logged in member
* @param bool $comments Whether to use restrictions for the comment editor rather than the normal one
*
* @return int
*/
public static function getMemberRestrictionLevel( ?Member $member=null, bool $comments = false ) : int
{
$member = $member ?: Member::loggedIn();
static $restrictLevelCache = [];
$cacheKey = ($member->member_id ?: 0) . '-' . ((int) $comments);
if ( !isset( $restrictLevelCache[$cacheKey] ) )
{
$restrictLevel = 0;
foreach ( $member->groups as $gid )
{
$group = Member\Group::load( $gid );
$restrictLevel = max( $group->getEditorRestrictionLevel( $comments ), $restrictLevel );
// 2 is the advanced editor, we won't find one more advanced
if ( $restrictLevel >= 2 )
{
$restrictLevel = 2;
break;
}
}
$restrictLevelCache[$cacheKey] = $restrictLevel;
}
return $restrictLevelCache[$cacheKey];
}
/**
* Get editor restrictions for the member based on group settings
*
@@ -1153,32 +1191,24 @@ js;
{
$member = $member ?: Member::loggedIn();
$settings = static::getRestrictionSetting();
Output::i()->jsVars['editor_restrictions'] = []; // this needs to be shared to make sure the editor uses saved restriction settings of custom plugins rather than the defaults
$restrictions = [];
$restrictLevel = 0;
foreach ( $member->groups as $gid )
{
$group = Member\Group::load( $gid );
$restrictLevel = max( $group->getEditorRestrictionLevel( $comments ), $restrictLevel );
// 2 is the advanced editor, we won't find one more advanced
if ( $restrictLevel >= 2 )
{
$restrictLevel = 2;
break;
}
}
$restrictLevel = static::getMemberRestrictionLevel( $member, $comments );
foreach ( $settings as $level => $_restrictions )
{
if ( (int) $restrictLevel > (int) $level )
{
continue;
}
foreach( $_restrictions as $restriction )
{
$restrictions[] = $restriction;
if ( preg_match( "/^ipsCustom(Node|Mark|Extension)__/", $restriction ) )
{
Output::i()->jsVars['editor_restrictions'][ $restriction ] = (int)$level;
}
if ( $restrictLevel <= (int) $level )
{
$restrictions[] = $restriction;
}
}
}
@@ -1191,6 +1221,11 @@ js;
}
}
if ( empty( Output::i()->jsVars['editor_restrictions'] ) )
{
unset( Output::i()->jsVars['editor_restrictions'] );
}
return $restrictions;
}
@@ -1270,10 +1305,12 @@ js;
/**
* Get the level of a restriction based on the settings
*
* @param string $restriction
* @param string $restriction The key of the restriction
* @param ?int $default=null The default value of the restriction
*
* @return int
*/
public static function getRestrictionLevel( string $restriction ) : int
public static function getRestrictionLevel( string $restriction, ?int $default =null ) : int
{
$settings = static::getRestrictionSetting();
foreach ( $settings as $level => $restrictions )
@@ -1291,6 +1328,6 @@ js;
}
}
return -1;
return $default ?? -1;
}
}
+8 -2
View File
@@ -511,7 +511,8 @@ class Form
protected function _insert( mixed $element, string $elementKey=NULL, string $tab=NULL, string $after=NULL ) : void
{
$tab = $tab ?: $this->currentTab;
$added = false;
if ( $after )
{
$elements = array();
@@ -521,15 +522,20 @@ class Form
if ( $key === $after )
{
$elements[ $elementKey ] = $element;
$added = true;
}
}
$this->elements[ $tab ] = $elements;
}
elseif( $elementKey )
{
$this->elements[ $tab ][ $elementKey ] = $element;
$added = true;
}
else
/* If we haven't added the field yet, tack it on to the end */
if( !$added )
{
$this->elements[ $tab ][] = $element;
}
+94 -13
View File
@@ -18,7 +18,9 @@ use IPS\Db;
use IPS\Dispatcher;
use IPS\IPS;
use IPS\Member;
use IPS\Node\Grouping;
use IPS\Node\Model;
use IPS\Node\NodeGroup;
use IPS\Output;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Request;
@@ -67,6 +69,7 @@ class Node extends FormAbstract
'noParentNodes' => 'Custom' // If a value is provided, subnodes of this class which have no parent node will be added into a pseudo-group with the title provided. e.g. custom packages in Nexus do not belong to any package group
'autoPopulate' => FALSE // Whether or not to autopopulate children of root nodes (defaults to TRUE which means the children are loaded, use FALSE to only show the parent nodes by default until they are clicked on)
'clubs' => TRUE, // If TRUE, will also show nodes inside clubs that the user can access. Defaults to FALSE.
'nodeGroups' => FALSE, // If TRUE, will show node groups for easier selection. Defaults to FALSE.
'maxResults' => 200, // Maximum number of nodes to load. NULL to load all nodes or FALSE to respect node class definition. Defaults to FALSE.
);
* @endcode
@@ -91,6 +94,7 @@ class Node extends FormAbstract
'noParentNodes' => NULL,
'autoPopulate' => TRUE,
'clubs' => FALSE,
'nodeGroups' => FALSE,
'maxResults' => FALSE,
);
@@ -121,6 +125,11 @@ class Node extends FormAbstract
$this->options['clubs'] = FALSE;
}
if( $this->options['nodeGroups'] and ! ( IPS::classUsesTrait( $this->options['class'], Grouping::class ) and Member::loggedIn()->isModerator() ) )
{
$this->options['nodeGroups'] = false;
}
/* Do we need to respect the node class's defined max results (which is the default)? */
if( $this->options['maxResults'] === FALSE )
{
@@ -237,6 +246,13 @@ class Node extends FormAbstract
Output::i()->sendOutput( Theme::i()->getTemplate( 'forms', 'core', 'global' )->nodeCascade( $results, TRUE, $selectable, $this->options['subnodes'], $this->options['togglePerm'], $this->options['toggleIds'], $disabledCallback, FALSE, NULL, $this->options['class'], $this->options['where'], $this->options['disabled'], NULL, array(), NULL, $this->options['togglePermPBR'], $this->options['toggleIdsOff'] ) );
}
}
/* Node Groups */
$nodeGroups = null;
if( $this->options['nodeGroups'] )
{
$nodeGroups = $nodeClass::availableNodeGroups();
}
/* Get initial nodes */
$nodes = array();
@@ -303,30 +319,68 @@ class Node extends FormAbstract
}
/* What is selected? */
if ( $this->options['zeroVal'] !== NULL and $this->value === 0 )
if ( $this->options['zeroVal'] !== NULL and !( $this->value instanceof Model ) and !is_array( $this->value ) and $this->value == 0 )
{
$selected = 0;
}
else
{
$selected = array();
$mergeNodes = array();
if ( is_array( $this->value ) )
{
/* Check to make sure we don't have a node group selected and do not have permission to select node groups */
foreach( $this->value as $index => $node )
{
if ( $node instanceof NodeGroup )
{
if ( ! $this->options['nodeGroups'] )
{
/* We don't, so all we can do is get the current node IDs and present them */
foreach( $node->nodes() as $groupedNode )
{
if ( $groupedNode instanceof $nodeClass )
{
$mergeNodes[ $groupedNode->_id ] = $groupedNode;
}
}
unset( $this->value[ $index ] );
}
}
}
if ( count( $mergeNodes ) )
{
$this->value = array_merge( $this->value, $mergeNodes );
}
foreach ( $this->value as $node )
{
$title = str_replace( "&#039;", "&apos;", htmlspecialchars( $node->_title, ENT_QUOTES | ENT_DISALLOWED, 'UTF-8', false ) );
$selected[ !( $node instanceof $nodeClass ) ? "{$node->_id}.s" : $node->_id ] = array( 'title' => $title, 'parents' => array_values( array_map( function( $val ){
$suffix = "";
if( !( $node instanceof $nodeClass ) )
{
$suffix = ( $node instanceof NodeGroup ) ? ".g" : ".s";
}
$selected[ "{$node->_id}{$suffix}" ] = array( 'title' => $title, 'parents' => array_values( array_map( function( $val ){
return str_replace( "&#039;", "&apos;", htmlspecialchars( $val->_title, ENT_QUOTES | ENT_DISALLOWED, 'UTF-8', false ) );
}, iterator_to_array( $node->parents() ) ) ) );
}
}
elseif ( $this->value instanceof Model )
{
$suffix = "";
if( !( $this->value instanceof $nodeClass ) )
{
$suffix = ( $this->value instanceof NodeGroup ) ? ".g" : ".s";
}
$class = get_class( $this->value );
$title = str_replace( "&#039;", "&apos;", htmlspecialchars( $this->value->_title, ENT_QUOTES | ENT_DISALLOWED, 'UTF-8', false ) );
$selected[ !( $this->value instanceof $nodeClass ) ? "{$this->value->_id}.s" : $this->value->_id ] = array( 'title' => $title, 'parents' => array_values( array_map( function( $val ){
$selected[ "{$this->value->_id}{$suffix}" ] = array( 'title' => $title, 'parents' => array_values( array_map( function( $val ){
return str_replace( "&#039;", "&apos;", htmlspecialchars( $val->_title, ENT_QUOTES | ENT_DISALLOWED, 'UTF-8', false ) );
}, iterator_to_array( $this->value->parents() ) ) ) );
}
@@ -348,7 +402,7 @@ class Node extends FormAbstract
}
/* Display */
return Theme::i()->getTemplate( 'forms', 'core', 'global' )->node( $this->name, $selected, $this->options['multiple'], $this->options['url'], $nodeClass::$nodeTitle, $nodes, $this->options['zeroVal'], $selectable, $this->options['subnodes'], $this->options['togglePerm'], $this->options['toggleIds'], $disabledCallback, $this->options['zeroValTogglesOn'], $this->options['zeroValTogglesOff'], $this->options['autoPopulate'], $children, $this->options['class'], $this->options['where'], is_array( $this->options['disabledIds'] ) ? $this->options['disabledIds'] : array(), $this->options['noParentNodes'], $noParentNodes, $this->options['clubs'], $this->options['togglePermPBR'], $this->options['toggleIdsOff'], $loadMoreLink );
return Theme::i()->getTemplate( 'forms', 'core', 'global' )->node( $this->name, $selected, $this->options['multiple'], $this->options['url'], $nodeClass::$nodeTitle, $nodes, $this->options['zeroVal'], $selectable, $this->options['subnodes'], $this->options['togglePerm'], $this->options['toggleIds'], $disabledCallback, $this->options['zeroValTogglesOn'], $this->options['zeroValTogglesOff'], $this->options['autoPopulate'], $children, $this->options['class'], $this->options['where'], is_array( $this->options['disabledIds'] ) ? $this->options['disabledIds'] : array(), $this->options['noParentNodes'], $noParentNodes, $this->options['clubs'], $this->options['togglePermPBR'], $this->options['toggleIdsOff'], $loadMoreLink, $nodeGroups );
}
/**
@@ -398,7 +452,7 @@ class Node extends FormAbstract
}
}
}
if ( $depthLimit === NULL OR $depth < $depthLimit )
{
if ( !$totalLimit or ( ( $currentCount += $node->childrenCount( $showable, NULL, $this->options['subnodes'], $this->options['where'] ) ) < $totalLimit ) )
@@ -463,25 +517,52 @@ class Node extends FormAbstract
$showable = $this->_getShowable();
if ( $this->value and !( $this->value instanceof Model ) )
{
{
$return = array();
foreach ( is_array( $this->value ) ? $this->value : explode( ',', $this->value ) as $v )
{
if ( $v instanceof Model )
{
$return[ !( $v instanceof $nodeClass ) ? "s{$v->_id}" : $v->_id ] = $v;
$prefix = '';
if( !( $v instanceof $nodeClass ) )
{
$prefix = ( $v instanceof NodeGroup ) ? 'g' : 's';
}
$return [ $prefix . $v->_id ] = $v;
}
elseif( $v )
{
try
{
$exploded = explode( '.', $v );
$classToUse = ( isset( $exploded[1] ) and $exploded[1] === 's' ) ? $nodeClass::$subnodeClass : $nodeClass;
$node = $classToUse::load( $exploded[0] );
if ( ( !$showable or $node->can( $showable ) ) and !$selectable or ( is_string( $selectable ) and $node->can( $selectable ) ) or ( is_callable( $selectable ) and $selectable( $node ) ) )
if( ( mb_substr( $v, 0, 1 ) === 'g' or mb_substr( $v, 0, 1 ) === 's' ) and is_numeric( mb_substr( $v, 1 ) ) )
{
$return[ isset( $exploded[1] ) ? "s{$node->_id}" : $node->_id ] = $node;
$exploded = [
mb_substr( $v, 1 ),
mb_substr( $v, 0, 1 )
];
}
else
{
$exploded = explode( '.', $v );
}
if( isset( $exploded[1] ) )
{
$classToUse = ( $exploded[1] === 'g' ) ? NodeGroup::class : $nodeClass::$subnodeClass;
}
else
{
$classToUse = $nodeClass;
}
$node = $classToUse::load( $exploded[0] );
if( $classToUse == NodeGroup::class )
{
$return[ 'g' . $node->_id ] = $node;
}
elseif ( ( !$showable or $node->can( $showable ) ) and !$selectable or ( is_string( $selectable ) and $node->can( $selectable ) ) or ( is_callable( $selectable ) and $selectable( $node ) ) )
{
$return[ ( $exploded[1] ?? '' ) . $node->_id ] = $node;
}
}
catch ( Exception $e ) {}
+12 -9
View File
@@ -65,7 +65,8 @@ class Text extends TextArea
'disallowedCharacters' => array(), // An array of disallowed characters (default < > ' ")
'minimized' => TRUE // Whether the autocomplete shows a 'choose' link to activate. Existing values or required = true will always override this.
'alphabetical' => FALSE, // Force values to be sorted alphabetically.,
'suggestionsOnly' => false, // Force values to come from the autocomplete suggestion form before submitting
'suggestionsOnly' => false, // Force values to come from the autocomplete suggestion form before submitting,
'separator' => null // Custom separator when multiple values are submitted in the request. Defaults to newline character.
),
'placeholder' => 'e.g. ...', // A placeholder (NB: Will only work on compatible browsers)
'regex' => '/[A-Z]+/i', // RegEx of acceptable value
@@ -203,7 +204,7 @@ class Text extends TextArea
$return = trim( $return );
}
}
if ( isset( $this->options['autocomplete'] ) and $return !== null )
{
if ( is_array( $return ) )
@@ -216,18 +217,19 @@ class Text extends TextArea
{
$return = htmlspecialchars_decode( $return, ENT_QUOTES );
}
if ( !is_array( $return ) and ( !isset( $this->options['autocomplete']['maxItems'] ) or $this->options['autocomplete']['maxItems'] != 1 ) )
{
$return = array_filter( array_map( 'trim', explode( "\n", $return ) ) );
$separator = $this->options['autocomplete']['separator'] ?? "\n";
$return = array_filter( array_map( 'trim', explode( $separator, $return ) ) );
}
if ( is_array( $return ) AND isset( $this->options['autocomplete']['alphabetical'] ) AND $this->options['autocomplete']['alphabetical'] )
{
natcasesort( $return );
}
}
/* Remove all invisible characters if this is a username */
if( $this->options['accountUsername'] )
{
@@ -268,17 +270,18 @@ class Text extends TextArea
return $return;
}
/**
* Format Value
*
* @return mixed
*/
public function formatValue(): mixed
{
{
if ( $this->options['autocomplete'] !== NULL and ( !isset( $this->options['autocomplete']['maxItems'] ) or $this->options['autocomplete']['maxItems'] != 1 ) and !is_array( $this->value ) and $this->value !== NULL )
{
return array_filter( array_map( 'trim', explode( "\n", $this->value ) ) );
$separator = $this->options['autocomplete']['separator'] ?? "\n";
return array_filter( array_map( 'trim', explode( $separator, $this->value ) ) );
}
return $this->value;
+7
View File
@@ -774,6 +774,13 @@ class Upload extends FormAbstract
$hasBeenResized = $image->resizeToMax( $options['image']['maxWidth'] ?? NULL, $options['image']['maxHeight'] ?? NULL );
}
/* If we rotated the image at this point, reset the image orientation so that
we don't rotate it an extra time */
if( $image->hasBeenRotated )
{
$image->setImageOrientation( 1 );
}
/* If type JPG, image handler may strip meta data on image output */
if( $hasBeenResized OR $image->hasBeenRotated OR $image->type == 'jpeg' )
{