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

@@ -12,6 +12,3 @@ represents (i.e. the id of the row in the database). Be sure to add a
seperator (like an underscore) at the end. Warning: this directive will
not work unless %Attr.IDPrefix is set to a non-empty value!
--# vim: et sw=4 sts=4
# 3053798PNZ0EJZ
@@ -1,4 +1 @@
patreon: FlorentMorselli
# 315662YNVPFR37N8S30
@@ -1,6 +1,3 @@
Please do not submit any Pull Requests here. It will be automatically closed.
You should submit it here: https://github.com/web-token/jwt-framework/pulls
# 921JAOJGJ
+21 -7
View File
@@ -17,6 +17,7 @@ use IPS\core\Form\WebhookSelector;
use IPS\Data\Store;
use IPS\Db;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Node;
use IPS\Helpers\Form\Select;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\Url as FormUrl;
@@ -25,6 +26,7 @@ use IPS\Log;
use IPS\Member;
use IPS\Node\Model;
use IPS\Theme;
use OutOfRangeException;
use function count;
use function defined;
use function in_array;
@@ -139,6 +141,8 @@ class Webhook extends Model
}
$form->add( new WebhookSelector('webhook_events', $this->events ?? [], TRUE , [ 'options' => $events] ) );
$form->add( new Node( 'webhook_api_key', $this->api_key, false, array( 'class' => Key::class, 'multiple' => false ) ) );
}
/**
@@ -159,8 +163,9 @@ class Webhook extends Model
{
$values['content_type'] = $values['webhook_content_type'];
}
$values['api_key'] = ( $values['webhook_api_key'] instanceof Key ) ? $values['webhook_api_key']->id : '';
unset( $values['webhook_events'], $values['webhook_content_type'], $values['webhook_content_type_other'] );
unset( $values['webhook_events'], $values['webhook_content_type'], $values['webhook_content_type_other'], $values['webhook_api_key'] );
return $values;
}
@@ -293,22 +298,31 @@ class Webhook extends Model
/**
* Get API Key
*
* @return Key
* @return Key|null
*/
public function get_api_key() : Key
public function get_api_key() : ?Key
{
return Key::load( $this->_data['api_key'] );
if( isset($this->_data['api_key'] ) && $this->_data['api_key'] )
{
try
{
return Key::load( $this->_data['api_key'] );
}
catch( OutOfRangeException ){}
}
return null;
}
/**
* Set API Key
*
* @param Key $key The API Key
* @param Key|string|null $key The API Key
* @return void
*/
public function set_api_key(Key $key ) : void
public function set_api_key( Key|string|null $key ) : void
{
$this->_data['api_key'] = $key->id;
$key = ( $key instanceof Key ) ? $key->id : $key;
$this->_data['api_key'] = $key;
}
/**
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -533,9 +533,9 @@ class CommentController extends Controller
try
{
$index = Report::loadByClassAndId( get_class( $object ), $object->$idColumn );
$report = Db::i()->select( '*', 'core_rc_reports', array( 'rid=? and report_by=? and date_reported > ?', $index->id, $member, time() - ( Settings::i()->automoderation_report_again_mins * 60 ) ) )->first();
$report = Db::i()->select( '*', 'core_rc_reports', ['rid=? and report_by=? and date_reported > ?', $index->id, $member->member_id, time() - ( Settings::i()->automoderation_report_again_mins * 60 )] )->first();
/* They have aleady reported, so do nothing */
/* They have already reported, so do nothing */
throw new Exception( 'REPORTED_ALREADY', '1S425/C', 404 );
}
catch( \Exception $e )
+20 -4
View File
@@ -231,10 +231,19 @@ class ItemController extends Controller
$class = $this->class;
$item = $class::load( $id );
if ( $this->member and !$item->can( 'read', $this->member ) )
if( $this->member )
{
throw new OutOfRangeException;
if ( method_exists($item, 'canView') and !$item->canView( $this->member ) )
{
throw new OutOfRangeException;
}
else if ( !$item->can( 'read', $this->member ) )
{
throw new OutOfRangeException;
}
}
return new Response( 200, $item->apiOutput( $this->member ) );
}
@@ -571,9 +580,16 @@ class ItemController extends Controller
* @var Comment $commentClass */
$itemClass = $this->class;
$item = $itemClass::load( $id );
if ( $this->member and !$item->can( 'read', $this->member ) )
if( $this->member )
{
throw new OutOfRangeException;
if ( method_exists($item, 'canView') and !$item->canView( $this->member ) )
{
throw new OutOfRangeException;
}
else if ( !$item->can( 'read', $this->member ) )
{
throw new OutOfRangeException;
}
}
$itemIdColumn = $itemClass::$databaseColumnId;
$where [] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['item'] . '=?', $item->$itemIdColumn );
+12 -1
View File
@@ -15,6 +15,7 @@ namespace IPS\Content;
/* To prevent PHP errors (extending class does not exist) revealing path */
use DomainException;
use IPS\Api\Webhook;
use IPS\core\Assignments\Assignment;
use IPS\Data\Cache;
use IPS\Data\Store;
@@ -94,6 +95,12 @@ trait Assignable
return false;
}
/* Extensions go first */
if( $permCheck = Permissions::can( 'assign', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
return $this->containerAllowsAssignable() AND static::modPermission( 'assign', $member, $this->container() );
}
@@ -290,6 +297,8 @@ trait Assignable
$this->save();
}
Webhook::fire( 'content_assigned', $assignment );
return $assignTo;
}
@@ -302,7 +311,9 @@ trait Assignable
{
try
{
Assignment::loadByItem( $this )->close();
$assignment = Assignment::loadByItem( $this );
$assignment->close();
Webhook::fire( 'content_unassigned', $assignment );
}
catch( OutOfRangeException ){}
}
+62 -11
View File
@@ -20,11 +20,13 @@ use IPS\Helpers\Form\Text;
use IPS\Lang;
use IPS\Member;
use IPS\Member\Club;
use IPS\Member\Club\Template;
use IPS\Node\Model;
use IPS\Output;
use IPS\Output\UI\UiExtension;
use IPS\Request;
use IPS\Settings;
use IPS\Task;
use OutOfRangeException;
use UnderflowException;
use function count;
@@ -107,8 +109,6 @@ trait ClubContainer
*/
public function club(): Club|NULL
{
if ( Settings::i()->clubs )
{
$clubIdColumn = $this->clubIdColumn();
if ( $this->$clubIdColumn )
{
@@ -118,7 +118,6 @@ trait ClubContainer
}
catch ( OutOfRangeException $e ) { }
}
}
return NULL;
}
@@ -179,9 +178,10 @@ trait ClubContainer
*
* @param Club $club The club
* @param array $values Values
* @param Template|null $template
* @return void
*/
public function saveClubForm( Club $club, array $values ): void
public function saveClubForm( Club $club, array $values, ?Template $template = null ): void
{
/* @var Model $nodeClass */
$nodeClass = get_called_class();
@@ -219,11 +219,18 @@ trait ClubContainer
'node_class' => $nodeClass,
'node_id' => $this->_id,
'name' => $values['club_node_name'],
'public' => $values['club_node_public'] ?? 0,
'creators' => $values['club_node_creators'] ?? 0
'public' => $values['club_node_public'] ?? Club::NODE_PRIVATE,
'creators' => $values['club_node_creators'] ?? 0,
'template_id' => $template ? $template->id : 0
) );
$needToUpdatePermissions = TRUE;
/* If auto-follow is enabled in the club, queue the task so all members will follow the node */
if( $this->auto_follow )
{
Task::queue( 'core', 'AutoFollowClubs', [ 'club' => $this->club()->id, 'node' => $nodeClass, 'nodeId' => $this->_id ], 3, [ 'club', 'node', 'nodeId' ] );
}
}
else
{
@@ -235,7 +242,7 @@ trait ClubContainer
$this->save();
Db::i()->update( 'core_clubs_node_map', array(
'name' => $values['club_node_name'],
'public' => isset( $values['club_node_public'] ) ? (int) $values['club_node_public'] : 0,
'public' => isset( $values['club_node_public'] ) ? (int) $values['club_node_public'] : Club::NODE_PRIVATE,
'creators' => isset( $values['club_node_creators'] ) ? (int) $values['club_node_creators'] : 0
), array( 'club_id=? AND node_class=? AND node_id=?', $club->id, $nodeClass, $this->_id ) );
}
@@ -313,10 +320,21 @@ trait ClubContainer
$insert[ 'perm_' . $v ] = '*';
break;
case $club::TYPE_CLOSED:
$insert[ 'perm_' . $v ] = ( $this->isPublic() ) ? "*" : "cm,c{$club->id}";
switch( $this->isPublic() )
{
case Club::NODE_PRIVATE:
$insert['perm_' . $v ] = "cm,c{$club->id}";
break;
case Club::NODE_PUBLIC:
$insert['perm_' . $v ] = '*';
break;
case Club::NODE_MODERATORS:
$insert['perm_' . $v ] = 'cm';
break;
}
break;
case $club::TYPE_PRIVATE:
$insert[ 'perm_' . $v ] = "cm,c{$club->id}";
$insert[ 'perm_' . $v ] = ( $this->isPublic() == Club::NODE_MODERATORS ) ? "cm" : "cm,c{$club->id}";
break;
}
}
@@ -492,7 +510,7 @@ trait ClubContainer
return TRUE;
}
}
elseif ( $member->modPermission( "can_{$type}_{$title}" ) and is_array( $member->modPermission( static::$modPerm ) ) and $member->modPermission('can_access_all_clubs') )
elseif ( $member->modPermission( "can_{$type}_{$title}" ) and ( is_array( $member->modPermission( static::$modPerm ) ) or $member->modPermission( static::$modPerm ) === true ) and $member->modPermission('can_access_all_clubs') )
{
if ( in_array( $type, explode( ',', Settings::i()->clubs_modperms ) ) )
{
@@ -518,7 +536,7 @@ trait ClubContainer
}
catch ( UnderflowException $e )
{
return 0;
return Club::NODE_PRIVATE;
}
}
@@ -538,4 +556,37 @@ trait ClubContainer
return 0;
}
}
/**
* @return Template|null
*/
public function template() : ?Template
{
try
{
$templateId = Db::i()->select( 'template_id', 'core_clubs_node_map', [ 'node_class=? and node_id=?', get_called_class(), $this->_id ] )->first();
return Template::load( $templateId );
}
catch( UnderflowException | OutOfRangeException ){}
return null;
}
/**
* Determines if the club leader can manage this node
*
* @return bool
*/
public function canLeaderManage() : bool
{
if( $template = $this->template() )
{
if( array_key_exists( 'clubtemplates_leader_edit', $template->node_data ) )
{
return $template->node_data['clubtemplates_leader_edit'];
}
}
return true;
}
}
+73 -54
View File
@@ -291,6 +291,31 @@ abstract class Comment extends Content
$member->save();
}
/* Send webhook */
if ( IPS::classUsesTrait( $obj, 'IPS\Content\Hideable' ) and in_array( $obj->hidden(), array( -1, 0, 1 ) ) ) // i.e. not post before register or pending deletion
{
Webhook::fire( str_replace( '\\', '', substr( get_called_class(), 3 ) ) . '_create', $obj, $obj->webhookFilters() );
}
/* DataLayer Event */
if ( DataLayer::enabled() and static::dataLayerEventActive( 'comment_create' ) and !( $item::$firstCommentRequired and $obj->isFirst() ) )
{
$obj->_dataLayerProperties = [];
$event = 'comment_create';
$properties = $obj->getDataLayerProperties();
/* todo this should probably be deleted as I'm not sure why it's here. Leaving here for a reference just in case */
// if ( $properties['content_area'] === 'personal_messages' )
// {
// return NULL;
// }
DataLayer::i()->addEvent( $event, $properties );
}
/* Update item */
$obj->postCreate();
/* Send notifications and dish out points */
if ( !in_array( 'IPS\Content\Review', class_parents( get_called_class() ) ) )
{
@@ -307,34 +332,6 @@ abstract class Comment extends Content
$obj->sendUnapprovedNotification();
}
}
/* Send webhook */
if ( IPS::classUsesTrait( $obj, 'IPS\Content\Hideable' ) and in_array( $obj->hidden(), array( -1, 0, 1 ) ) ) // i.e. not post before register or pending deletion
{
Webhook::fire( str_replace( '\\', '', substr( get_called_class(), 3 ) ) . '_create', $obj, $obj->webhookFilters() );
}
/* DataLayer Event */
if ( Settings::i()->core_datalayer_enabled and !( $item::$firstCommentRequired and $obj->isFirst() ) )
{
$event = 'comment_create';
$properties = $obj->getDataLayerProperties();
if ( $properties['content_area'] === 'personal_messages' )
{
return NULL;
}
$properties = array_merge( $properties, array(
'author_id' => $obj->author()->member_id,
'author_name' => $obj->author()->name ?? 'Guest',
) );
DataLayer::i()->addEvent( $event, $properties );
}
/* Update item */
$obj->postCreate();
/* This needs to happen before the index is added */
if( $item::$firstCommentRequired and $obj->isFirst() )
@@ -372,13 +369,7 @@ abstract class Comment extends Content
{
if ( empty( $this->_dataLayerProperties ) )
{
$item = $this->item();
$this->_dataLayerProperties = $item->getDataLayerProperties( $this );
if ( $this->_dataLayerProperties['content_type'] === 'question' )
{
$this->_dataLayerProperties['comment_type'] = 'answer';
}
$this->_dataLayerProperties = DataLayer::i()->filterProperties( $this->_dataLayerProperties );
$this->_dataLayerProperties = $this->item()->getDataLayerProperties( $this, clearCache: true );
}
return $this->_dataLayerProperties;
@@ -728,6 +719,12 @@ abstract class Comment extends Content
*/
public function canView( Member|null $member= null ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'view', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( $member === NULL )
{
$member = Member::loggedIn();
@@ -749,6 +746,12 @@ abstract class Comment extends Content
*/
public function canEdit( Member|null $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'edit', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'edit', $member ) )
{
return false;
@@ -805,6 +808,12 @@ abstract class Comment extends Content
*/
public function canDelete( Member|null $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'delete', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'delete', $member ) )
{
return false;
@@ -823,6 +832,12 @@ abstract class Comment extends Content
*/
public function canSplit( Member|null $member=null ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'split', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'split', $member ) )
{
return false;
@@ -869,14 +884,7 @@ abstract class Comment extends Content
*/
public function dateLine(): string
{
if( $this->mapped('first') )
{
return Member::loggedIn()->language()->addToStack( static::$formLangPrefix . 'date_started', FALSE, array( 'htmlsprintf' => array( DateTime::ts( $this->mapped('date') )->html() ) ) );
}
else
{
return Member::loggedIn()->language()->addToStack( static::$formLangPrefix . 'date_replied', FALSE, array( 'htmlsprintf' => array( DateTime::ts( $this->mapped('date') )->html() ) ) );
}
return Member::loggedIn()->language()->addToStack( static::$formLangPrefix . 'date_replied', FALSE, array( 'htmlsprintf' => array( DateTime::ts( $this->mapped('date') )->html() ) ) );
}
/**
@@ -1014,11 +1022,8 @@ abstract class Comment extends Content
Db::i()->update( 'core_reputation_index', array( 'item_id' => $item->$idColumn ), array( 'class_type_id_hash=?', md5( get_class( $this ) . ':' . $oldItem->$idColumn ) ) );
}
/* Update solved */
if ( IPS::classUsesTrait( $this, 'IPS\Content\Solvable' ) )
{
Db::i()->update( 'core_solved_index', array( 'item_id' => $item->$idColumn ), array( 'comment_class=? and comment_id=?', get_class( $this ), $oldItem->$idColumn ) );
}
/* Update solved - do this without checking for the trait, because multiple traits use it */
Db::i()->update( 'core_solved_index', array( 'item_id' => $item->$idColumn ), array( 'comment_class=? and comment_id=?', get_class( $this ), $this->$commentIdColumn ) );
/* Update member map */
try
@@ -1037,6 +1042,13 @@ abstract class Comment extends Content
if( $skip === FALSE )
{
/* Update Helpfuls */
if( IPS::classUsesTrait( $this, Helpful::class ) and isset( $item::$databaseColumnMap['num_helpful'] ) )
{
$oldItem->recountHelpfuls();
$item->recountHelpfuls();
}
$oldItem->rebuildFirstAndLastCommentData();
$item->rebuildFirstAndLastCommentData();
@@ -1119,7 +1131,7 @@ abstract class Comment extends Content
}
/* Remove featured comment associations */
if( $item and IPS::classUsesTrait( $item, 'IPS\Content\MetaData' ) and $this->isFeatured() )
if( $item and $this->isFeaturedComment() )
{
Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->unfeatureComment( $item, $this );
}
@@ -1186,16 +1198,23 @@ abstract class Comment extends Content
if ( $item->mapped('solved_comment_id') and $item->mapped('solved_comment_id') == $this->$idColumn )
{
Db::i()->update( $itemClass::$databaseTable, array( $itemClass::$databaseColumnMap['solved_comment_id'] => 0 ), array( $itemIdColumn . '=?', $itemId ) );
Db::i()->delete( 'core_solved_index', array( 'app=? and comment_class=? and item_id=? and comment_id=?', static::$application, get_called_class(), $itemId, $this->$idColumn ) );
}
}
/* Clear the solved index regardless of trait; multiple traits use it */
Db::i()->delete( 'core_solved_index', array( 'app=? and comment_class=? and item_id=? and comment_id=?', static::$application, get_called_class(), $itemId, $this->$idColumn ) );
if ( $item AND IPS::classUsesTrait( $this->item(), 'IPS\Content\Statistics' ) )
{
$item->clearCachedStatistics( TRUE );
}
/* Update helpfuls */
if( IPS::classUsesTrait( $this, Helpful::class ) and IPS::classUsesTrait( $item, Helpful::class ) )
{
$item->recountHelpfuls();
}
/* Delete any notifications telling people about this */
$memberIds = array();
@@ -1516,7 +1535,7 @@ abstract class Comment extends Content
/* @var array $databaseColumnMap */
/* Finally, we do not want posts from future items */
if ( in_array( 'IPS\Content\FuturePublishing', class_implements( $itemClass ) ) )
if( IPS::classUsesTrait( $itemClass, FuturePublishing::class ) )
{
$itemWhere[] = array( $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['is_future_entry'] . '=0' );
}
@@ -1739,9 +1758,9 @@ abstract class Comment extends Content
*/
public function isFeaturedComment(): bool
{
if ( IPS::classUsesTrait( $this->item(), 'IPS\Content\MetaData' ) )
if ( !( $this instanceof Review ) and IPS::classUsesTrait( $this->item(), 'IPS\Content\MetaData' ) )
{
return Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->isFeaturedComment( $this );
return Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->isCommentShownAtTheTop( $this );
}
return FALSE;
+68 -3
View File
@@ -25,7 +25,9 @@ use IPS\Content\Item;
use IPS\Content\Review;
use IPS\Content\Solvable;
use IPS\core\Approval;
use IPS\core\DataLayer;
use IPS\core\Reports\Report;
use IPS\core\Reports\Types;
use IPS\Db\Select;
use IPS\Events\Event;
use IPS\Http\Url;
@@ -76,6 +78,7 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
* @mixin \IPS\Content\EditHistory
* @mixin \IPS\Content\Followable
* @mixin \IPS\Content\FuturePublishing
* @mixin \IPS\Content\Helpful
* @mixin \IPS\Content\Hideable
* @mixin \IPS\Content\ItemTopic
* @mixin \IPS\Content\Lockable
@@ -125,6 +128,12 @@ abstract class Content extends ActiveRecord
*/
public static bool $canBeModeratedFromFrontend = TRUE;
/**
* An array containing data layer event keys to bypass. Optionally, this can be set to 'true' to bypass all events.
* @var bool|string[]
*/
protected static array|bool $_bypassDataLayerEvents = [];
/**
* Should posting this increment the poster's post count?
*
@@ -680,6 +689,7 @@ abstract class Content extends ActiveRecord
Webhook::fire( str_replace( '\\', '', substr( get_called_class(), 3 ) ) . '_modaction', ['action' => $action, 'item' => $this->apiOutput()], $this->webhookFilters() );
/* These ones just need a property setting */
$dataLayerEvent = null;
if ( in_array( $action, array( 'pin', 'unpin', 'feature', 'unfeature', 'lock', 'unlock' ) ) )
{
$val = TRUE;
@@ -714,6 +724,11 @@ abstract class Content extends ActiveRecord
$this->$column = $val;
$this->save();
if ( $action === 'pin' or $action === 'unpin' )
{
$dataLayerEvent = $action === 'pin' ? 'pin' : 'unpin';
}
/* Fire events for these here, the others will be fired at a later point */
Event::fire( 'onStatusChange', $this, array( $action ) );
}
@@ -721,10 +736,12 @@ abstract class Content extends ActiveRecord
/* Hide is a tiny bit more complicated */
elseif ( $action === 'hide' )
{
$dataLayerEvent = 'hide';
$this->hide( $member, $reason );
}
elseif ( $action === 'unhide' )
{
$dataLayerEvent = 'unhide';
$this->unhide( $member );
}
@@ -737,8 +754,8 @@ abstract class Content extends ActiveRecord
$this->logDelete( $member );
return;
}
$idColumn = static::$databaseColumnId;
$dataLayerEvent = 'delete';
$this->delete();
}
@@ -769,6 +786,11 @@ abstract class Content extends ActiveRecord
$this->move( $args[2][0], $args[2][1] );
}
}
if ( $dataLayerEvent and DataLayer::enabled( 'analytics_full' ) and static::dataLayerEventActive( 'content_' . $dataLayerEvent ) )
{
DataLayer::i()->addEvent( 'content_' . $dataLayerEvent, $this->getDataLayerProperties() );
}
}
/**
@@ -902,6 +924,7 @@ abstract class Content extends ActiveRecord
$item = ( $this instanceof Comment ) ? $this->item() : $this;
$itemIdColumn = $item::$databaseColumnId;
$new = false;
try
{
$index = Report::load( $this->$idColumn, 'content_id', array( 'class=?', get_called_class() ) );
@@ -909,6 +932,7 @@ abstract class Content extends ActiveRecord
}
catch ( OutOfRangeException $e )
{
$new = true;
$index = new Report;
$index->class = get_called_class();
$index->content_id = $this->$idColumn;
@@ -991,7 +1015,7 @@ abstract class Content extends ActiveRecord
}
}
$notification = new Notification( Application::load('core'), 'report_center', $index, array( $index, $reportInsert, $this ) );
$notification = new Notification( Application::load('core'), 'report_center', $index, array( $index, $reportInsert, $this, $member ) );
foreach (Db::i()->select( '*', 'core_members', ( count( $moderators['m'] ) ? Db::i()->in( 'member_id', $moderators['m'] ) . ' OR ' : '' ) . Db::i()->in( 'member_group_id', $moderators['g'] ) . ' OR ' . Db::i()->findInSet( 'mgroup_others', $moderators['g'] ) ) as $mem )
{
$memberObj = Member::constructFromData( $mem );
@@ -1022,6 +1046,33 @@ abstract class Content extends ActiveRecord
$this->alreadyReported = TRUE;
$this->reportData = $reportInsert;
/* If this is new, we need to add a data layer event */
if ( $index->status === 1 and DataLayer::enabled( 'analytics_full' ) and static::dataLayerEventActive( 'content_report' ) )
{
$properties = $this->getDataLayerProperties();
try
{
$reportNode = ( ( $member->member_id or !Settings::i()->automoderation_enabled ) and $reportType > 0 ) ? Types::load( $reportType ) : null;
}
catch ( Exception )
{
$reportNode = null;
}
$properties['report_type_name'] = Lang::load( Lang::defaultLanguage() )->addToStack( $reportNode?->_titleLanguageKey ?: ( $this instanceof Content\Comment ? 'report_message_comment' : 'report_message_item' ) );
$properties['report_type_id'] = $reportNode ? $reportType : 0;
Lang::load( Lang::defaultLanguage() )->parseOutputForDisplay( $properties['report_type_name'] );
DataLayer::i()->addEvent( 'content_report', DataLayer::i()->filterProperties( $properties ) );
}
if( $new )
{
Webhook::fire( 'content_reported', $index);
Event::fire( 'onReport', $this, [ $index ] );
}
/* Return */
return $index;
}
@@ -2002,4 +2053,18 @@ abstract class Content extends ActiveRecord
return (string) $url;
}
/**
* Check whether an event is active for this content type based on the event key. For example, reports don't fire any content_* or comment_* events. This uses the $_bypassDataLayerEvents static property
*
* @see Content::$_bypassDataLayerEvents
*
* @param string $eventKey The data layer event key.
*
* @return bool
*/
public static function dataLayerEventActive( string $eventKey ) : bool
{
return empty( static::$_bypassDataLayerEvents ) or ( is_array( static::$_bypassDataLayerEvents ) and !in_array( $eventKey, static::$_bypassDataLayerEvents ) );
}
}
+50 -28
View File
@@ -120,6 +120,7 @@ class Controller extends CoverPhotoController
/* Ensure JS loaded for forms/content functions such as moderation */
Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'front_core.js', 'core' ) );
Output::i()->bodyAttributes['contentClass'] = static::$contentModel;
parent::execute();
@@ -152,6 +153,7 @@ class Controller extends CoverPhotoController
try
{
$class = static::$contentModel;
/**@var Content $item */
$item = $class::loadAndCheckPerms( Request::i()->id );
/* Have we moved? If a user has loaded a forum, and a moderator merges two topics leaving a link, then we need to account for this if the
@@ -215,9 +217,8 @@ class Controller extends CoverPhotoController
}
/* Get ready to store data layer info */
$dataLayer = Settings::i()->core_datalayer_enabled;
$dataLayerProperties = array();
if ( $dataLayer )
if ( DataLayer::enabled() )
{
$dataLayerProperties = $item->getDataLayerProperties();
}
@@ -343,9 +344,9 @@ class Controller extends CoverPhotoController
);
/* Data Layer */
if ( $dataLayer )
if ( DataLayer::enabled() )
{
if ( !Request::i()->isAjax() )
if ( !Request::i()->isAjax() and $item::dataLayerEventActive( 'content_view' ) )
{
DataLayer::i()->addEvent( 'content_view', $dataLayerProperties );
}
@@ -414,9 +415,17 @@ class Controller extends CoverPhotoController
{
try
{
if ( ! is_int( Request::i()->id ) )
{
throw new BadMethodCallException( $e );
}
Output::i()->redirect( $class::getRedirectFrom( Request::i()->id )->url() );
}
catch( OutOfRangeException $e ) { }
catch( BadMethodCallException|UnderflowException $e )
{
/* There is no valid redirect, so we will just show an error */
Output::i()->error( 'page_doesnt_exist', '2S136/1Z', 404, '' );
}
return NULL;
}
@@ -460,9 +469,9 @@ class Controller extends CoverPhotoController
/**
* AJAX - check for new replies
*
* @return Item|NULL
* @return void
*/
protected function checkForNewReplies(): Item|NULL
protected function checkForNewReplies(): void
{
Session::i()->csrfCheck();
@@ -1610,7 +1619,7 @@ class Controller extends CoverPhotoController
'blurb' => ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $item )
);
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() )
{
$output['datalayer'] = array_replace( $item->getDataLayerProperties(), ['reaction_type' => $reaction->_title] );
}
@@ -1619,7 +1628,7 @@ class Controller extends CoverPhotoController
}
else
{
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() )
{
DataLayer::i()->addEvent( 'content_react', array_replace( $item->getDataLayerProperties(), [ 'reaction_type' => $reaction->_title ] ) );
}
@@ -1750,10 +1759,12 @@ class Controller extends CoverPhotoController
{
$class = static::$contentModel;
$item = $class::loadAndCheckPerms( Request::i()->id );
$reactionId = isset( Request::i()->reaction ) ? (int) Request::i()->reaction : null;
if ( Request::i()->isAjax() and isset( Request::i()->tooltip ) and isset( Request::i()->reaction ) )
if ( Request::i()->isAjax() and isset( Request::i()->tooltip ) and !empty( $reactionId ) )
{
$reaction = Reaction::load( Request::i()->reaction );
$reaction = Reaction::load( $reactionId );
$numberToShowInPopup = 10;
$where = $item->getReactionWhereClause( $reaction );
@@ -1779,12 +1790,8 @@ class Controller extends CoverPhotoController
$tabs[ $reaction->id ] = array( 'title' => $reaction->_title, 'icon' => $reaction->_icon, 'count' => $blurb[$reaction->id] ?? 0 );
}
}
$activeTab = 'all';
if ( isset( Request::i()->reaction ) )
{
$activeTab = Request::i()->reaction;
}
$activeTab = $reactionId ?: 'all';
$url = $item->url('showReactions');
$url = $url->setQueryString( 'changed', 1 );
@@ -3017,7 +3024,13 @@ class Controller extends CoverPhotoController
/* Moderator log */
Session::i()->modLog( 'modlog__comment_edit', array( $comment->url()->__toString() => FALSE, $item::$title => TRUE, $item->url()->__toString() => FALSE, $item->mapped( 'title' ) => FALSE ), $item );
/* Add a data layer event indicating this was updated */
if ( DataLayer::enabled( 'analytics_full' ) and $comment::dataLayerEventActive( 'content_edit' ) )
{
DataLayer::i()->addEvent( 'content_edit', $comment->getDataLayerProperties() );
}
/* If this is an AJAX request and the comment hidden status has not changed just output the comment HTML */
if ( Request::i()->isAjax() AND $isHidden == $comment->hidden() AND $reloadPage === false )
{
@@ -3117,6 +3130,12 @@ class Controller extends CoverPhotoController
$item->topic_answered_pid = 0;
$item->save();
}
/* This doesn't go through modAction, so we will add the datalayer event here */
if ( $comment and DataLayer::enabled( 'analytics_full' ) and $comment::dataLayerEventActive( 'content_delete' ) )
{
DataLayer::i()->addEvent( 'content_delete', $comment->getDataLayerProperties() );
}
if ( Request::i()->isAjax() )
{
@@ -3239,6 +3258,8 @@ class Controller extends CoverPhotoController
$comment->save();
}
Event::fire( 'onItemSplit', $item, array( $oldItem ) );
}
else
{
@@ -3520,7 +3541,7 @@ class Controller extends CoverPhotoController
'blurb' => ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $comment )
);
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() )
{
$item = $item ?? $comment->item();
$object = ( !isset( $item::$databaseColumnMap['content'] ) AND ( $item::$commentClass ) AND $comment->isFirst() ) ? $item : $comment;
@@ -3533,7 +3554,7 @@ class Controller extends CoverPhotoController
else
{
/* Data Layer Event */
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() )
{
$properties = $comment->getDataLayerProperties();
$properties['reaction_type'] = $reaction->_title;
@@ -3651,10 +3672,11 @@ class Controller extends CoverPhotoController
{
/* @var Comment $commentClass */
$idColumn = $commentClass::$databaseColumnId;
if ( Request::i()->isAjax() and isset( Request::i()->tooltip ) and isset( Request::i()->reaction ) )
$reactionId = isset( Request::i()->reaction ) ? (int) Request::i()->reaction : null;
if ( Request::i()->isAjax() and isset( Request::i()->tooltip ) and !empty( $reactionId ) )
{
$reaction = Reaction::load( Request::i()->reaction );
$reaction = Reaction::load( $reactionId );
$numberToShowInPopup = 10;
$where = $comment->getReactionWhereClause( $reaction );
@@ -3680,11 +3702,7 @@ class Controller extends CoverPhotoController
}
}
$activeTab = 'all';
if ( isset( Request::i()->reaction ) )
{
$activeTab = Request::i()->reaction;
}
$activeTab = $reactionId ?: 'all';
$url = $comment->url('showReactions');
$url = $url->setQueryString( 'changed', 1 );
@@ -3721,6 +3739,10 @@ class Controller extends CoverPhotoController
Session::i()->csrfCheck();
$checkAgainst = Request::i()->modaction;
if( !$checkAgainst )
{
throw new DomainException;
}
$classToCheck = $commentClass;
if( $checkAgainst == 'split' OR $checkAgainst == 'merge' )
+12 -3
View File
@@ -12,6 +12,7 @@ namespace IPS\Content;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Content\Item;
use IPS\IPS;
use IPS\Settings;
use IPS\Theme;
@@ -90,15 +91,23 @@ trait EditHistory
{
$logEdit = 'comment_log_edit';
$editReason = 'comment_edit_reason';
$column = 'comment_value';
$column = static::$formLangPrefix . 'comment_value';
/* If this is the first comment and the first comment is required, the content will be in a different array element */
/* @var Item $itemClass */
$itemClass = static::$itemClass;
if( $itemClass::$firstCommentRequired and $this->isFirst() )
{
$column = $itemClass::$formLangPrefix . 'content';
}
}
$editIsPublic = Member::loggedIn()->group['g_append_edit'] ? $values[ $logEdit ] : TRUE;
$idField = static::$databaseColumnId;
if ( Settings::i()->edit_log == 2 and isset( $values[static::$formLangPrefix . $column] ) )
if ( Settings::i()->edit_log == 2 and isset( $values[ $column ] ) )
{
$content = $values[static::$formLangPrefix . $column];
$content = $values[ $column ];
Db::i()->insert( 'core_edit_history', array(
'class' => get_class( $this ),
+12 -3
View File
@@ -13,13 +13,13 @@ namespace IPS\Content;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\core\Feature;
use IPS\Events\Event;
use IPS\File;
use IPS\IPS;
use IPS\Member;
use IPS\Patterns\ActiveRecordIterator;
use OutOfRangeException;
use function class_implements;
use function get_called_class;
use function in_array;
if( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
@@ -37,6 +37,12 @@ trait Featurable
*/
public function canFeature( ?Member $member=NULL ) : bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'feature', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'feature', $member ) )
{
return false;
@@ -113,6 +119,9 @@ trait Featurable
$this->$featuredColumn = $featured;
$this->save();
/* Fire an event */
Event::fire( 'onStatusChange', $this, array( ( $featured ? 'feature' : 'unfeature' ) ) );
}
return true;
@@ -130,7 +139,7 @@ trait Featurable
{
$where = array( array( static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnMap['featured'] . '=?', 1 ) );
if ( in_array( 'IPS\Content\FuturePublishing', class_implements( get_called_class() ) ) )
if( IPS::classUsesTrait( get_called_class(), FuturePublishing::class ) )
{
$where[] = array( static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnMap['is_future_entry'] . '=?', 0 );
}
+111
View File
@@ -13,8 +13,11 @@ namespace IPS\Content;
use ArrayObject;
use BadMethodCallException;
use IPS\Content;
use IPS\core\Followed\Follow;
use IPS\Db;
use IPS\Events\Event;
use IPS\IPS;
use IPS\Member;
use IPS\Notification;
use IPS\Application;
use IPS\DateTime;
@@ -22,6 +25,7 @@ use IPS\Node\Model;
use IPS\Db\Select;
use ArrayIterator;
use Iterator;
use OutOfRangeException;
use UnderflowException;
use function defined;
use function header;
@@ -44,6 +48,113 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
*/
trait Followable
{
/**
* Follow an object. Using a static method here
* because Model does not use the Followable trait
*
* @param object $object
* @param string $frequency
* @param bool $public
* @param Member|null $member
* @return void
*/
public static function staticFollow( object $object, string $frequency, bool $public=true, ?Member $member=null ) : void
{
$member = $member ?: Member::loggedIn();
try
{
/* Are we already following this? */
$follow = Follow::loadByObject( $object, $member );
}
catch( OutOfRangeException )
{
$follow = new Follow;
$follow->object = $object;
$follow->member_id = $member->member_id;
}
$follow->is_anon = !$public;
$follow->notify_do = ( $frequency == 'none' ? 0 : 1 );
$follow->notify_freq = $frequency;
$follow->save();
$followParams = Follow::getFollowParameters( $object );
$idCol = $object::$databaseColumnId;
$member->clearFollowingCache( $followParams['app'], $followParams['area'], $object->$idCol );
/* Fire the event */
Event::fire( 'onFollow', $member, [ $object, $public ] );
}
/**
* Follow this object
*
* @param string $frequency ( 'none', 'immediate', 'daily', 'weekly' )
* @param bool $public
* @param Member|null $member
* @return void
*/
public function follow( string $frequency, bool $public=true, ?Member $member=null ) : void
{
static::staticFollow( $this, $frequency, $public, $member );
/* If this is an item, handle achievements */
if( $this instanceof Item )
{
Member::loggedIn()->achievementAction( 'core', 'FollowContentItem', [
'item' => $this,
'author' => $this->author()
] );
}
}
/**
* Nodes don't use followable, so we have a static method on the item level
*
* @param Model $container
* @param Member|null $member
* @return void
*/
public static function containerUnfollow( Model $container, ?Member $member=null ) : void
{
try
{
$member = $member ?: Member::loggedIn();
Follow::loadByObject( $container, $member )->delete();
}
catch( OutOfRangeException ){}
}
/**
* Unfollow this object
*
* @param Member|null $member
* @param string|null $followId
* @return void
*/
public function unfollow( ?Member $member=null, ?string $followId=null ) : void
{
$member = $member ?: Member::loggedIn();
try
{
/* We might already have the follow ID (like in the notifications controller) */
if( $followId === null )
{
Follow::loadByObject( $this, $member )->delete();
$followParams = Follow::getFollowParameters( $this );
$idCol = $this::$databaseColumnId;
$member->clearFollowingCache( $followParams['app'], $followParams['area'], $this->$idCol );
}
else
{
Follow::load( $followId )->delete();
}
}
catch( OutOfRangeException ){}
}
/**
* @brief Cache for current follow data, used on "My Followed Content" screen
*/
+6
View File
@@ -75,6 +75,12 @@ trait FuturePublishing
*/
public function canPublish( ?Member $member=NULL, ?Model $container = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'publish', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'publish', $member ) )
{
return false;
+74 -9
View File
@@ -85,6 +85,26 @@ trait Helpful
'node_id' => $this->item()->container()->_id
) );
/* If we have a mapping, increment that column */
if( isset( static::$databaseColumnMap['num_helpful'] ) )
{
$helpfulCountColumn = static::$databaseColumnMap['num_helpful'];
$this->$helpfulCountColumn++;
$this->save();
}
/* And if this is a comment, check if item has a mapping */
if( $this instanceof Comment )
{
$item = $this->item();
if( isset( $item::$databaseColumnMap['num_helpful'] ) )
{
$helpfulCountColumn = $item::$databaseColumnMap['num_helpful'];
$item->$helpfulCountColumn++;
$item->save();
}
}
/* Trending */
if( Application::appIsEnabled( 'cloud' ) and Bridge::i()->featureIsEnabled( 'trending' ) )
{
@@ -141,6 +161,26 @@ trait Helpful
unset( $this->hasMarkedHelpful[ $member->member_id ] );
}
/* If we have a mapping, decrement that column */
if( isset( static::$databaseColumnMap['num_helpful'] ) )
{
$helpfulCountColumn = static::$databaseColumnMap['num_helpful'];
$this->$helpfulCountColumn--;
$this->save();
}
/* And if this is a comment, check if item has a mapping */
if( $this instanceof Comment )
{
$item = $this->item();
if( isset( $item::$databaseColumnMap['num_helpful'] ) )
{
$helpfulCountColumn = $item::$databaseColumnMap['num_helpful'];
$item->$helpfulCountColumn--;
$item->save();
}
}
if ( IPS::classUsesTrait( $this->item(), 'IPS\Content\Statistics' ) )
{
$this->item()->clearCachedStatistics();
@@ -170,22 +210,36 @@ trait Helpful
*/
public function helpfulsRepliesCount(): int
{
if( isset( static::$databaseColumnMap['num_helpful'] ) )
{
return $this->mapped( 'num_helpful' );
}
if ( $this->_helpfulRepliesCount === NULL )
{
$idColumn = static::$databaseColumnId;
try
{
$this->_helpfulRepliesCount = (int)Db::i()->select( 'COUNT( DISTINCT(comment_id) )', 'core_solved_index', [["app=? AND item_id=? AND type=? AND hidden=0", static::$application, $this->$idColumn, 'helpful']], null, null, 'item_id' )->first();
}
catch( Exception )
{
$this->_helpfulRepliesCount = 0;
}
$this->_helpfulRepliesCount = (int) Db::i()->select( 'COUNT( DISTINCT(comment_id) )', 'core_solved_index', ["app=? AND item_id=? AND type=? AND hidden=0", static::$application, $this->$idColumn, 'helpful'] )->first();
}
return $this->_helpfulRepliesCount;
}
/**
* Recount the total helpful replies
*
* @return void
*/
public function recountHelpfuls() : void
{
if( isset( static::$databaseColumnMap['num_helpful'] ) )
{
$idColumn = static::$databaseColumnId;
$helpfulColumn = static::$databaseColumnMap['num_helpful'];
$this->$helpfulColumn = (int) Db::i()->select( 'COUNT( DISTINCT(comment_id) )', 'core_solved_index', [ 'app=? and item_id=? and type=? and hidden=?', static::$application, $this->$idColumn, 'helpful', 0 ], flags: Db::SELECT_FROM_WRITE_SERVER )->first();
$this->save();
}
}
/**
* Helpful Count
*
@@ -280,6 +334,12 @@ trait Helpful
*/
public function canMarkHelpful( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'helpful', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
$member = $member ?: Member::loggedIn();
/* It's a no for guests */
@@ -320,7 +380,7 @@ trait Helpful
public function authorIsAnExpert() : bool
{
/* Do they have it disabled? */
if( !Bridge::i()->featureIsEnabled( 'experts' ) or $this->author()->members_bitoptions['expert_user_disabled'] or $this->author()->members_bitoptions['expert_user_blocked'] )
if( !Bridge::i()->featureIsEnabled( 'experts' ) or $this->author()->members_bitoptions['expert_user_disabled'] or !$this->author()->isExpert() )
{
return false;
}
@@ -350,6 +410,11 @@ trait Helpful
*/
public static function itemHasHelpful( Item $item ): bool
{
if( isset( $item::$databaseColumnMap['num_helpful'] ) )
{
return (bool) $item->mapped( 'num_helpful' );
}
try
{
return (bool) $item->helpfulPosts(1);
+54 -2
View File
@@ -317,6 +317,12 @@ trait Hideable
*/
public function canRestore( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'restore', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'restore', $member ) )
{
return false;
@@ -451,16 +457,27 @@ trait Hideable
if ( $this instanceof Item )
{
$item = $this;
if( isset( static::$commentClass ) )
{
$solvedWhere[] = array( 'comment_class=?', static::$commentClass );
}
$solvedWhere[] = array( 'item_id=?', $this->$idColumn );
}
else
{
$item = $this->item();
$solvedWhere[] = array( 'comment_class=?', get_called_class() );
$solvedWhere[] = array( 'comment_id=?', $this->$idColumn );
}
Db::i()->update( 'core_solved_index', [ 'hidden' => 1 ], $solvedWhere );
if( IPS::classUsesTrait( $item, Helpful::class ) )
{
$item->recountHelpfuls();
}
/* Update search index */
if( SearchContent::isSearchable( $this ) )
{
@@ -657,16 +674,27 @@ trait Hideable
if ( $this instanceof Item )
{
$item = $this;
if( isset( static::$commentClass ) )
{
$solvedWhere[] = array( 'comment_class=?', static::$commentClass );
}
$solvedWhere[] = array( 'item_id=?', $this->$idColumn );
}
else
{
$item = $this->item();
$solvedWhere[] = array( 'comment_class=?', get_called_class() );
$solvedWhere[] = array( 'comment_id=?', $this->$idColumn );
}
Db::i()->update( 'core_solved_index', [ 'hidden' => 0 ], $solvedWhere );
if( IPS::classUsesTrait( $item, Helpful::class ) )
{
$item->recountHelpfuls();
}
/* Clear the item stats */
if ( IPS::classUsesTrait( $item, 'IPS\Content\Statistics' ) )
{
@@ -1373,12 +1401,18 @@ trait Hideable
*/
public function canHide( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'hide', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'hide', $member ) )
{
return false;
}
if ( $this->hidden() <> 0 )
if ( $this->hidden() < 0 )
{
return FALSE;
}
@@ -1400,6 +1434,12 @@ trait Hideable
*/
public function canUnhide( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'unhide', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'unhide', $member ) )
{
return false;
@@ -1492,7 +1532,13 @@ trait Hideable
* @return bool
*/
public function canViewHiddenComments( ?Member $member=NULL ): bool
{
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'view_hidden', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
$commentClass = static::$commentClass;
return $commentClass::modPermission( 'view_hidden', $member, $this->containerWrapper() );
@@ -1506,6 +1552,12 @@ trait Hideable
*/
public function canViewHiddenReviews( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'view_hidden', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
$reviewClass = static::$reviewClass;
return $reviewClass::modPermission( 'view_hidden', $member, $this->containerWrapper() );
+243 -167
View File
@@ -20,6 +20,7 @@ use IPS\Api\Webhook;
use IPS\Application;
use IPS\Application\Module;
use IPS\Content;
use IPS\Content\Permissions as PermissionsExtension;
use IPS\Content\Search\Index;
use IPS\core\Achievements\Recognize;
use IPS\core\Approval;
@@ -84,6 +85,8 @@ use function is_null;
use function is_numeric;
use function is_object;
use function is_string;
use function mb_strrpos;
use function mb_strtolower;
use function substr;
use const IPS\CIC;
@@ -195,12 +198,14 @@ abstract class Item extends Content
* Build form to create
*
* @param Model|NULL $container Container (e.g. forum), if appropriate
* @param bool $showError Bypass canCreate check? This will show the form even when the member is not allowed to create the item.
* @return Form
*/
public static function create( Model|null $container=NULL ): Form
public static function create( Model|null $container=NULL, bool $showError = true ): Form
{
/* Perform permission checks */
static::canCreate( Member::loggedIn(), $container, TRUE );
/* Perform permission checks */
static::canCreate( Member::loggedIn(), $container, $showError );
/* Build the form */
$form = static::buildCreateForm( $container );
@@ -566,26 +571,7 @@ abstract class Item extends Content
/* Auto-follow */
if( isset( $values[ static::$formLangPrefix . 'auto_follow'] ) AND $values[ static::$formLangPrefix . 'auto_follow'] )
{
$followArea = mb_strtolower( mb_substr( get_called_class(), mb_strrpos( get_called_class(), '\\' ) + 1 ) );
/* Insert */
$idColumn = static::$databaseColumnId;
$save = array(
'follow_id' => md5( static::$application . ';' . $followArea . ';' . $obj->$idColumn . ';' . Member::loggedIn()->member_id ),
'follow_app' => static::$application,
'follow_area' => $followArea,
'follow_rel_id' => $obj->$idColumn,
'follow_member_id' => Member::loggedIn()->member_id,
'follow_is_anon' => isset( $values[ 'post_anonymously' ] ) ? (bool) $values[ 'post_anonymously' ] : 0,
'follow_added' => time() + 1, // Make sure streams show follows after content is created
'follow_notify_do' => 1,
'follow_notify_meta' => '',
'follow_notify_freq' => Member::loggedIn()->auto_follow['method'],
'follow_notify_sent' => 0,
'follow_visible' => 1
);
Db::i()->insert( 'core_follow', $save );
$obj->follow( Member::loggedIn()->auto_follow['method'], isset( $values['post_anonymously'] ) ? !$values['post_anonymously'] : true );
}
/* Auto-share */
@@ -609,7 +595,7 @@ abstract class Item extends Content
}
/* Send notifications */
if ( $sendNotification and ( IPS::classUsesTrait( $obj, 'IPS\Content\FuturePublishing' ) AND !$obj->isFutureDate() ) )
if ( $sendNotification and ( !IPS::classUsesTrait( $obj, 'IPS\Content\FuturePublishing' ) or !$obj->isFutureDate() ) )
{
if ( !$obj->hidden() )
{
@@ -844,7 +830,12 @@ abstract class Item extends Content
{
if ( isset( static::$databaseColumnMap['author_name'] ) )
{
$return['guest_name'] = new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name'), 'htmlAutocomplete' => "username" ), NULL, NULL, NULL, 'guest_name' );
$return['guest_name'] = new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name'), 'htmlAutocomplete' => "username" ), function( $val ){
if( !empty( $val ) and filter_var( $val, FILTER_VALIDATE_EMAIL ) !== false )
{
throw new InvalidArgumentException( 'form_no_email_allowed' );
}
}, NULL, NULL, 'guest_name' );
}
}
if ( Settings::i()->bot_antispam_type !== 'none' )
@@ -879,7 +870,7 @@ abstract class Item extends Content
'key' => IPS::mb_ucfirst( static::$module ),
'autoSaveKey' => ( $item === NULL ? ( 'newContentItem-' . static::$application . '/' . static::$module . '-' . ( $container ? $container->_id : 0 ) ) : ( 'contentEdit-' . static::$application . '/' . static::$module . '-' . $item->$idColumn ) ),
'attachIds' => ( $item === NULL ? NULL : array( $item->$idColumn, $commentObj->$commentIdColumn ) )
), '\IPS\Helpers\Form::floodCheck', NULL, NULL, static::$formLangPrefix . 'content_editor' );
), ( $item ? null : '\IPS\Helpers\Form::floodCheck' ), NULL, NULL, static::$formLangPrefix . 'content_editor' );
if ( $item AND IPS::classUsesTrait( $commentClass, 'IPS\Content\EditHistory' ) and Settings::i()->edit_log )
{
@@ -1049,13 +1040,9 @@ abstract class Item extends Content
}
/* Data Layer Event */
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() and static::dataLayerEventActive( 'content_create' ) )
{
/* make sure it isn't a PM Conversation */
if ( !isset( static::$title ) or static::$title !== Conversation::$title )
{
DataLayer::i()->addEvent( 'content_create', $this->getDataLayerProperties() );
}
DataLayer::i()->addEvent( 'content_create', $this->getDataLayerProperties( createOrEditValues: $values ) );
}
/* Send this URL to IndexNow if the guest can view it */
@@ -1158,9 +1145,11 @@ abstract class Item extends Content
/* Initial Comment */
if ( isset( static::$commentClass ) and static::$firstCommentRequired )
{
/* @var Comment $commentClass */
$commentClass = static::$commentClass;
$commentObj = $this->firstComment();
/* @var Comment $commentClass */
$commentClass = get_class( $commentObj );
/* @var $databaseColumnMap array */
$column = $commentClass::$databaseColumnMap['content'];
$idField = $commentClass::$databaseColumnId;
@@ -1251,6 +1240,11 @@ abstract class Item extends Content
$this->itemEdited();
}
if ( DataLayer::enabled( 'analytics_full' ) and static::dataLayerEventActive( 'content_edit' ) )
{
DataLayer::i()->addEvent( 'content_edit', $this->getDataLayerProperties() );
}
Event::fire( 'onCreateOrEdit', $this, array( $values ) );
$this->ui( 'formPostSave', array( $values ) );
@@ -1517,7 +1511,7 @@ abstract class Item extends Content
}
else
{
throw new BadMethodCallException;
return null;
}
}
else
@@ -2064,6 +2058,9 @@ abstract class Item extends Content
{
Index::i()->massUpdate( $commentClass, NULL, $item->$idColumn, $extension->searchIndexPermissions(), $this->hidden() ? 2 : NULL, $extension->searchIndexContainer(), NULL, $this->$idColumn, $this->author()->member_id );
}
/* Solved Index */
Db::i()->update( 'core_solved_index', [ 'item_id' => $this->$idColumn ], [ 'app=? and comment_class=? and item_id=?', static::$application, $commentClass, $item->$idColumn ] );
}
if ( isset( static::$reviewClass ) )
{
@@ -2194,6 +2191,12 @@ abstract class Item extends Content
$viewColumn = $item::$databaseColumnMap['views'];
$this->$viewColumn = $this->mapped('views') + $views;
}
/* Recount helpfuls */
if( IPS::classUsesTrait( $this, Helpful::class ) )
{
$this->recountHelpfuls();
}
$this->rebuildFirstAndLastCommentData();
@@ -2576,6 +2579,12 @@ abstract class Item extends Content
Db::i()->delete( 'core_ratings', ['class=? AND item_id=?', get_called_class(), $this->$idColumn] );
}
/* Pending view updates */
if( IPS::classUsesTrait( $this, ViewUpdates::class ) )
{
Db::i()->delete( 'core_view_updates', [ 'classname=? and id=?', get_called_class(), $this->$idColumn ] );
}
foreach ( $memberIds as $member )
{
Member::load( $member )->recountNotifications();
@@ -2615,6 +2624,7 @@ abstract class Item extends Content
Db::i()->delete( 'core_reputation_index', array('rep_class=? AND ' . Db::i()->in( 'type_id', $ids ), $class) );
Db::i()->delete( 'core_solved_index', array('comment_class=? AND ' . Db::i()->in( 'comment_id', $ids ), $class) );
Db::i()->delete( 'core_approval_queue', array( 'approval_content_class=? AND ' . Db::i()->in( 'approval_content_id', $ids ), $class ) );
}
/**
@@ -2691,7 +2701,7 @@ abstract class Item extends Content
try
{
$permissions = $this->container()->permissions();
return $permissions['perm_id'];
return is_array( $permissions ) ? $permissions['perm_id'] : null;
}
catch( BadMethodCallException )
{
@@ -2948,7 +2958,7 @@ abstract class Item extends Content
{
$authorCol = static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnMap['author'];
/* Only fetching a count, for a single container with a item cache count, no future publishing, and there is only one where clause (the container limitation) */
if( $countOnly === TRUE AND $skipPermission instanceof Model AND $skipPermission->_items !== NULL AND !in_array( 'IPS\Content\FuturePublishing', class_implements( get_called_class() ) ) AND count( $where ) === 1 )
if( $countOnly === TRUE AND $skipPermission instanceof Model AND $skipPermission->_items !== NULL AND !IPS::classUsesTrait( get_called_class(), FuturePublishing::class ) AND count( $where ) === 1 )
{
$countShortcut = TRUE;
$where[] = array( "({$col}=0 AND ( {$authorCol}={$member->member_id}{$extra} ) )" );
@@ -3062,47 +3072,13 @@ abstract class Item extends Content
{
$containerClass = static::$containerNodeClass;
$member = $member ?: Member::loggedIn();
$categories = array();
$lookupKey = md5( $containerClass::$permApp . $containerClass::$permType . $permissionKey . json_encode( $member->groups ) );
/* EME: Switch to use exclusion instead of inclusion */
/* @var $permissionMap array */
if( !isset( static::$permissionSelect[ $lookupKey ] ) )
{
static::$permissionSelect[ $lookupKey ] = array();
$permQueryJoinContainer = (bool) ( count( $containerWhere ) );
$clubWhere = "";
/* If we cannot access clubs, skip them */
if ( IPS::classUsesTrait( $containerClass, 'IPS\Content\ClubContainer' ) AND !$member->canAccessModule( Module::get( 'core', 'clubs', 'front' ) ) )
{
$permQueryWhere = array( "core_permission_index.app='" . $containerClass::$permApp . "' AND core_permission_index.perm_type='" . $containerClass::$permType . "' AND (" . $containerClass::$databaseTable . '.' . $containerClass::$databasePrefix . $containerClass::clubIdColumn() . " IS NOT NULL OR !(" . Db::i()->findInSet( 'perm_' . $containerClass::$permissionMap[ $permissionKey ], $member->permissionArray() ) . ' OR ' . 'perm_' . $containerClass::$permissionMap[ $permissionKey ] . "='*' ))" );
$permQueryJoinContainer = true;
}
else
{
$permQueryWhere = array( "core_permission_index.app='" . $containerClass::$permApp . "' AND core_permission_index.perm_type='" . $containerClass::$permType . "' AND !(" . Db::i()->findInSet( 'perm_' . $containerClass::$permissionMap[ $permissionKey ], $member->permissionArray() ) . ' OR ' . 'perm_' . $containerClass::$permissionMap[ $permissionKey ] . "='*' )" );
}
$permQuery = Db::i()->select( 'perm_type_id', 'core_permission_index', $permQueryWhere );
if ( $permQueryJoinContainer )
{
$permQuery->join( $containerClass::$databaseTable, array_merge( $containerWhere, array( 'core_permission_index.perm_type_id=' . $containerClass::$databaseTable . '.' . $containerClass::$databasePrefix . $containerClass::$databaseColumnId ) ), 'STRAIGHT_JOIN' );
}
foreach( $permQuery as $result )
{
static::$permissionSelect[ $lookupKey ][] = $result;
}
}
$categories = static::$permissionSelect[ $lookupKey ];
if( count( $categories ) )
{
$where[] = array( static::$databaseTable . "." . static::$databasePrefix . static::$databaseColumnMap['container'] . ' NOT IN (' . implode( ',', $categories ) . ')' );
}
$permQueryWhere = PermissionsExtension::nodePermissionClause( $permissionKey, $containerClass, $member );
$permQueryWhere[] = [ 'core_permission_index.app=?', $containerClass::$permApp ];
$permQueryWhere[] = [ 'core_permission_index.perm_type=?', $containerClass::$permType ];
$where[] = array(
'(' . static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnMap['container'] . ' IN( ' . Db::i()->select( 'perm_type_id', 'core_permission_index', $permQueryWhere )->returnFullQuery() . ') )',
);
}
$groupBy = ( $joinComments ? static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnId : NULL );
@@ -4639,21 +4615,23 @@ abstract class Item extends Content
}
/* Data Layer stuff for comments here, not in Comment class. We track user actions, and the user action is handled here */
$dataLayer = Settings::i()->core_datalayer_enabled ? $this->getDataLayerProperties( $comment ) : array();
Output::i()->json(
array(
'type' => 'add',
'id' => $comment->$commentIdColumn,
'page' => $newPageCount,
'total' => $this->mapped('num_comments'),
'content' => $output,
'message' => $message,
'postedByLoggedInMember' => true,
/* This is used on the front end */
"dataLayer" => $dataLayer,
)
$json = array(
'type' => 'add',
'id' => $comment->$commentIdColumn,
'page' => $newPageCount,
'total' => $this->mapped('num_comments'),
'content' => $output,
'message' => $message,
'postedByLoggedInMember' => true,
);
/* This is used on the front end */
if ( DataLayer::enabled() and $commentClass::dataLayerEventActive( 'comment_create' ) )
{
$json['dataLayer'] = $this->getDataLayerProperties( $comment );
}
Output::i()->json( $json );
}
}
else
@@ -4727,21 +4705,25 @@ abstract class Item extends Content
* Most, if not all of these are the same for different events, so we can just have one method
*
* @param Comment|null $comment A comment item, leave null for these keys to be omitted
* @param array $createOrEditValues=[] Values from the create or edit form, if applicable.
* @param bool $clearCache=false Whether to clear the cached properties and regenerate them from scratch
*
* @return array
*/
public function getDataLayerProperties( ?Comment $comment = null ): array
public function getDataLayerProperties( ?Comment $comment = null, array $createOrEditValues=[], bool $clearCache=false ): array
{
$commentIdColumn = $comment ? $comment::$databaseColumnId : null;
$index = $commentIdColumn ? ( $comment->$commentIdColumn ?: 0 ) : 0;
$index = "idx_" . ( $commentIdColumn ? ( $comment->$commentIdColumn ?: 0 ) : -1 );
if ( !isset( $this->_dataLayerProperties[$index] ) )
if ( $clearCache OR !isset( $this->_dataLayerProperties[$index] ) )
{
$app = static::$application ?? null;
$idColumn = static::$databaseColumnId;
$dataLayer = array(
'author_id' => DataLayer::i()->getSsoId( $this->author()->member_id ),
'author_name' => $this->author()->real_name ?: null,
$anonymous = IPS::classUsesTrait( $comment ?? $this,'IPS\\Content\\Anonymous' ) and ($comment ?? $this)->isAnonymous();
$author = $comment ? $comment->author() : $this->author();
$dataLayer = $comment ? $this->getDataLayerProperties() : array(
'author_id' => $anonymous ? 0 : DataLayer::i()->getSsoId( $author->member_id ),
'author_name' => $anonymous ? null : ( $author->real_name ?: null ),
'content_age' => $this->mapped( 'date' ) ? intval( floor( ( time() - $this->mapped( 'date' ) ) / 86400 ) ) : null,
'content_container_id' => null,
'content_container_name' => null,
@@ -4751,11 +4733,20 @@ abstract class Item extends Content
'content_title' => $this->mapped( 'title' ),
'content_type' => static::$contentType ?? null,
'content_url' => (string) $this->url(),
'content_anonymous' => $anonymous,
'content_is_followed' => ( isset( $createOrEditValues[ static::$formLangPrefix . 'auto_follow'] ) AND $createOrEditValues[ static::$formLangPrefix . 'auto_follow'] ) or (bool) (
Member::loggedIn()->member_id and
$this->$idColumn and
IPS::classUsesTrait( $this::class, 'IPS\Content\Followable' ) and
Member::loggedIn()->following( $app, mb_strtolower( mb_substr( $this::class, mb_strrpos( $this::class, '\\' ) + 1 ) ), $this->$idColumn )
),
);
if ( $comment AND ( $commentIdColumn = $comment::$databaseColumnId ?? null ) )
if ( $comment )
{
$dataLayer = array_replace( $dataLayer, array(
'author_id' => $anonymous ? 0 : DataLayer::i()->getSsoId( $comment->author()->member_id ),
'author_name' => $anonymous ? null : ( $comment->author()->real_name ?: null ),
'comment_id' => $comment->$commentIdColumn,
'comment_type' => $comment::$commentType ?? null,
'comment_url' => (string) $comment->url()
@@ -4763,12 +4754,18 @@ abstract class Item extends Content
}
/* For QA forums, the comment_type and content_type is an exception */
if ( Settings::i()->core_datalayer_distinguish_qa AND
$this instanceof Topic AND
$this->container()->_forum_type === 'qa' )
static $isQA = null;
if ( $isQA === null )
{
$isQA = ( Settings::i()->core_datalayer_distinguish_qa AND
$this instanceof Topic AND
$this->container()->_forum_type === 'qa');
}
if ( $isQA )
{
$dataLayer['content_type'] = 'question';
if ( isset( $comment ) )
if ( $comment and $comment instanceof Topic\Post )
{
$dataLayer['comment_type'] = 'answer';
}
@@ -4776,7 +4773,7 @@ abstract class Item extends Content
/* If we didn't find a Comment or Content type, try pulling that info from the static title fields */
elseif ( ( !isset( $dataLayer['content_type'] ) AND isset( static::$title ) ) OR ( !isset( $dataLayer['comment_type'] ) AND isset( $comment, $comment::$title ) ) )
{
$contentType = ( $app ? preg_replace( "/[{$app}_]?(.*)/", '$1', static::$title ?? "" ) : ( static::$title ?? null ) ) ?: null;
$contentType = ( $app ? preg_replace( "/^" . preg_quote( $app . "_" ) . "/", '', static::$title ?? "" ) : ( static::$title ?? null ) ) ?: null;
if ( $contentType AND !isset( $dataLayer['content_type'] ) )
{
$dataLayer['content_type'] = $contentType;
@@ -4784,7 +4781,7 @@ abstract class Item extends Content
if ( $comment AND isset( $comment::$title ) AND !isset( $dataLayer['comment_type'] ) )
{
$commentType = ($app or $contentType) ? preg_replace( "/" . ( $app ? "[{$app}_]?" : '' ) . ( $contentType ? "[{$contentType}_]?" : '' ) . "(.*)/", "$1", $comment::$title ) : $comment::$title;
$commentType = ($app or $contentType) ? preg_replace( "/^" . ( $app ? ( '(?:' . preg_quote( $app . '_' ) . ')?' ) : '' ) . ( $contentType ? ( '(?:' . preg_quote( $contentType . '_' ) . ')?' ) : '' ) . "/", "", $comment::$title ) : $comment::$title;
$dataLayer['comment_type'] = $commentType;
}
}
@@ -4795,19 +4792,25 @@ abstract class Item extends Content
$dataLayer['comment_type'] = $comment instanceof Review ? 'review' : 'comment';
}
try
/* Since the comment properties is based on the item's properties (the recursive call above), we only need this for non-comment properties */
if ( !$comment )
{
$container = $this->container();
$dataLayer = array_replace( $dataLayer, $container->getDataLayerProperties() );
}
catch ( OutOfRangeException | BadMethodCallException $e ) {}
if ( !isset( $dataLayer['content_area'] ) AND $app )
{
$lang = Lang::load( Lang::defaultLanguage() );
if ( $lang->checkKeyExists( '__app_' . static::$application ) )
try
{
$dataLayer['content_area'] = $lang->addToStack( '__app_' . static::$application );
$container = $this->container();
$dataLayer = array_replace( $dataLayer, $container->getDataLayerProperties() );
}
catch ( OutOfRangeException|BadMethodCallException $e )
{
}
if ( !isset( $dataLayer['content_area'] ) and $app )
{
$lang = Lang::load( Lang::defaultLanguage() );
if ( $lang->checkKeyExists( '__app_' . static::$application ) )
{
$dataLayer['content_area'] = $lang->addToStack( '__app_' . static::$application );
}
}
}
@@ -4897,7 +4900,12 @@ abstract class Item extends Content
{
if ( isset( $commentClass::$databaseColumnMap['author_name'] ) )
{
$return['guest_name'] = new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name') ) );
$return['guest_name'] = new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name') ), function( $val ){
if( !empty( $val ) and filter_var( $val, FILTER_VALIDATE_EMAIL ) !== false )
{
throw new InvalidArgumentException( 'form_no_email_allowed' );
}
} );
}
}
if ( Settings::i()->bot_antispam_type !== 'none' )
@@ -4957,7 +4965,7 @@ abstract class Item extends Content
$commentClass = static::$commentClass;
$idColumn = static::$databaseColumnId;
$commentIdColumn = $commentClass::$databaseColumnId;
$followArea = mb_strtolower( mb_substr( get_called_class(), mb_strrpos( get_called_class(), '\\' ) + 1 ) );
$followArea = mb_strtolower( mb_substr( $this::class, mb_strrpos( $this::class, '\\' ) + 1 ) );
/* Moderator wants to hide the comment */
if( isset( $values['hide'] ) AND $values['hide'] )
@@ -4969,41 +4977,24 @@ abstract class Item extends Content
$hidden = NULL;
}
$comment = $commentClass::create( $this, $values[ static::$formLangPrefix . 'comment' . '_' . $this->$idColumn ], FALSE, $values['guest_name'] ?? NULL, NULL, NULL, NULL, NULL, $hidden, ( isset( $values[ 'post_anonymously' ] ) ? (bool) $values[ 'post_anonymously' ] : NULL ) );
Event::fire( 'onCreateOrEdit', $comment, array( $values, TRUE ) );
/* Auto-follow - If posted anonymously we should set follow to anonymous as well */
if( isset( $values[ static::$formLangPrefix . 'auto_follow' ] ) )
{
if ( $values[ static::$formLangPrefix . 'auto_follow' ] and !Member::loggedIn()->following( static::$application, $followArea, $this->$idColumn ) )
{
/* Insert */
$save = array(
'follow_id' => md5( static::$application . ';' . $followArea . ';' . $this->$idColumn . ';' . Member::loggedIn()->member_id ),
'follow_app' => static::$application,
'follow_area' => $followArea,
'follow_rel_id' => $this->$idColumn,
'follow_member_id' => Member::loggedIn()->member_id,
'follow_is_anon' => isset( $values[ 'post_anonymously' ] ) ? (bool) $values[ 'post_anonymously' ] : 0,
'follow_added' => time() + 1, // Make sure streams show follows after content is created
'follow_notify_do' => 1,
'follow_notify_meta' => '',
'follow_notify_freq' => Member::loggedIn()->auto_follow['method'],
'follow_notify_sent' => 0,
'follow_visible' => 1
);
Db::i()->insert( 'core_follow', $save );
Db::i()->delete( 'core_follow_count_cache', array( 'class=? AND id=?', get_called_class(), (int) $this->$idColumn ) );
$this->follow( Member::loggedIn()->auto_follow['method'], isset( $values['post_anonymously'] ) ? !$values['post_anonymously'] : true );
}
else if ( $values[ static::$formLangPrefix . 'auto_follow' ] === false AND Member::loggedIn()->following( static::$application, $followArea, $this->$idColumn ) )
{
Db::i()->delete( 'core_follow', array( 'follow_id=?', md5( static::$application . ';' . $followArea . ';' . $this->$idColumn . ';' . Member::loggedIn()->member_id ) ) );
Db::i()->delete( 'core_follow_count_cache', array( 'id=? AND class=?', $this->$idColumn, get_called_class() ) );
$this->unfollow();
}
}
$this->_dataLayerProperties = [];
$comment = $commentClass::create( $this, $values[ static::$formLangPrefix . 'comment' . '_' . $this->$idColumn ], FALSE, $values['guest_name'] ?? NULL, NULL, NULL, NULL, NULL, $hidden, ( isset( $values[ 'post_anonymously' ] ) ? (bool) $values[ 'post_anonymously' ] : NULL ) );
Event::fire( 'onCreateOrEdit', $comment, array( $values, TRUE ) );
/* Update the search index (note: we already index the comment in Comment::create()) */
if( Content\Search\SearchContent::isSearchable( $this ) )
{
@@ -5059,7 +5050,12 @@ abstract class Item extends Content
{
if ( isset( $reviewClass::$databaseColumnMap['author_name'] ) )
{
$form->add( new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name') ) ) );
$form->add( new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name') ), function( $val ){
if( !empty( $val ) and filter_var( $val, FILTER_VALIDATE_EMAIL ) !== false )
{
throw new InvalidArgumentException( 'form_no_email_allowed' );
}
} ) );
}
}
if ( Settings::i()->bot_antispam_type !== 'none' )
@@ -5229,6 +5225,12 @@ abstract class Item extends Content
*/
public function canComment( ?Member $member=NULL, bool $considerPostBeforeRegistering = TRUE ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'reply', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( $this->containerWrapper() and !$this->container()->checkAction( 'comment' ) )
{
return FALSE;
@@ -5246,6 +5248,12 @@ abstract class Item extends Content
*/
public function canReview( ?Member $member=NULL, bool $considerPostBeforeRegistering = TRUE ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'review', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( $this->containerWrapper() and !$this->container()->checkAction( 'review' ) )
{
return FALSE;
@@ -6510,6 +6518,12 @@ abstract class Item extends Content
return FALSE;
}
/* Extensions go first */
if( $permCheck = Permissions::can( $permission, $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
/* If we can find the node... */
try
{
@@ -6553,6 +6567,12 @@ abstract class Item extends Content
*/
public function canView( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'view', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
$member = $member ?: Member::loggedIn();
/* Check it isn't hidden */
@@ -6586,6 +6606,11 @@ abstract class Item extends Content
}
}
/* Check if this is club content and if clubs are enabled */
if ( $this->containerWrapper() and IPS::classUsesTrait( $this->container(), 'IPS\Content\ClubContainer' ) and $this->container()->club() and !Settings::i()->clubs )
{
return false;
}
/* Check node */
return $this->containerWrapper() ? $this->container()->can( 'read', $member ) : true;
}
@@ -6598,6 +6623,12 @@ abstract class Item extends Content
*/
public function canChangeAuthor( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'edit', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
return static::modPermission( 'edit', $member, $this->container() );
}
@@ -6611,6 +6642,12 @@ abstract class Item extends Content
*/
public function canEdit( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'edit', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'edit', $member ) )
{
return false;
@@ -6656,6 +6693,12 @@ abstract class Item extends Content
{
$member = $member ?: Member::loggedIn();
/* Extensions go first */
if( $permCheck = Permissions::can( 'edit', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
/* Are we restricted from posting or have an unacknowledged warning? */
if ( $member->restrict_post or ( $member->members_bitoptions['unacknowledged_warnings'] and Settings::i()->warn_on and Settings::i()->warnings_acknowledge ) )
{
@@ -6704,6 +6747,12 @@ abstract class Item extends Content
*/
public function canMove( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'move', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'move', $member ) )
{
return false;
@@ -6729,6 +6778,12 @@ abstract class Item extends Content
*/
public function canMerge( Member|null $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'merge', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'merge', $member ) )
{
return false;
@@ -6750,6 +6805,12 @@ abstract class Item extends Content
*/
public function canDelete( Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'delete', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'delete', $member ) )
{
return false;
@@ -7150,29 +7211,29 @@ abstract class Item extends Content
if( $this->canEdit( $member ) )
{
$menu->add( new ContentMenuLink( $this->url()->setQueryString( 'do', 'edit' ), 'edit', identifier: 'edit' ) );
$menu->add( new ContentMenuLink( $this->url()->setQueryString( 'do', 'edit' ), 'edit', identifier: 'edit', icon: 'fa-solid fa-pen-to-square' ) );
}
if( $member->modPermission('can_manage_deleted_content') AND $this->hidden() == -2 )
{
if( IPS::classUsesTrait( $this, Hideable::class ) and $this->canRestore() )
{
$restore = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'restore' ) ), languageString: 'restore_as_visible', identifier: 'restore' );
$restore = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'restore' ) ), languageString: 'restore_as_visible', identifier: 'restore', icon: 'fa-solid fa-eye' );
$restore->requiresConfirm( 'restore_as_visible_desc' );
$menu->add( $restore );
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'restoreAsHidden' ) ), 'restore_as_hidden', identifier: 'restore_hidden' ) );
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'restoreAsHidden' ) ), 'restore_as_hidden', identifier: 'restore_hidden', icon: 'fa-solid fa-eye-slash' ) );
}
if( $this->canDelete() )
{
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'delete', 'immediate' => 1 ) ), 'delete_immediately', identifier: 'delete_immediately' ) );
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'delete', 'immediate' => 1 ) ), 'delete_immediately', identifier: 'delete_immediately', icon: 'fa-solid fa-trash-can' ) );
}
}
else
{
if( !$this::$firstCommentRequired and $this->canReportOrRevoke() === TRUE )
{
$report = new ContentMenuLink( url: $this->url('report'), languageString: 'report', identifier: 'report' );
$report = new ContentMenuLink( url: $this->url('report'), languageString: 'report', identifier: 'report', icon: 'fa-solid fa-flag' );
if( $member->member_id OR Captcha::supportsModal() )
{
$report->opensDialog(title: 'report', remoteSubmit: TRUE );
@@ -7182,26 +7243,26 @@ abstract class Item extends Content
if ( IPS::classUsesTrait( $this, 'IPS\Content\FuturePublishing' ) AND $this->isFutureDate() AND static::canFuturePublish($member, $this->container()))
{
$publish = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'publish' ) ), languageString: 'publish', dataAttributes: [ 'title=\'{lang="publish_desc"}\'' ], identifier: 'publish' );
$publish = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'publish' ) ), languageString: 'publish', dataAttributes: [ 'title=\'{lang="publish_desc"}\'' ], identifier: 'publish', icon: 'fa-solid fa-clock' );
$publish->requiresConfirm();
$menu->add( $publish );
}
if( IPS::classUsesTrait( $this, 'IPS\Content\Pinnable' ) AND $this->canPin( $member ) )
{
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'pin' ) ), 'pin', identifier: 'pin' ) );
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'pin' ) ), 'pin', identifier: 'pin', icon: 'fa-solid fa-thumbtack' ) );
}
if( IPS::classUsesTrait( $this, 'IPS\Content\Pinnable' ) AND $this->canUnpin( $member ) )
{
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unpin' ) ), 'unpin', identifier: 'unpin' ) );
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unpin' ) ), 'unpin', identifier: 'unpin', icon: 'fa-solid fa-thumbtack-slash' ) );
}
if( IPS::classUsesTrait( $this, 'IPS\Content\Hideable' ) )
{
if( $this->canHide($member))
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'hide' ) ), languageString: 'hide' , dataAttributes: [
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'hide' ) ), languageString: 'hide', icon: 'fa-solid fa-eye-slash', dataAttributes: [
'data-ipsDialog' => 'true',
'data-ipsDialog-size' => 'medium',
'data-ipsDialog-title' => Member::loggedIn()->language()->addToStack('hide'),
@@ -7211,7 +7272,7 @@ abstract class Item extends Content
if( $this->canUnhide($member) )
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unhide' ) ), languageString: $this->hidden() === 1 ? 'approve' : 'unhide', dataAttributes: [
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unhide' ) ), languageString: $this->hidden() === 1 ? 'approve' : 'unhide', icon: 'fa-solid fa-eye', dataAttributes: [
'data-ipsDialog-destructOnClose' => 'true'
], identifier: 'unhide' ) );
}
@@ -7221,7 +7282,7 @@ abstract class Item extends Content
{
if( $this->canLock( $member ) )
{
$lock = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'lock' ) ), languageString: 'lock', identifier: 'lock' );
$lock = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'lock' ) ), languageString: 'lock', identifier: 'lock', icon: 'fa-solid fa-lock' );
if( $member->modPermission('can_manage_alerts') AND $this->author()->member_id )
{
$lock->addAttribute( 'data-ipsDialog')
@@ -7234,32 +7295,32 @@ abstract class Item extends Content
if( $this->canUnlock( $member ) )
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unlock' ) ), languageString: 'unlock' ) );
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unlock' ) ), languageString: 'unlock', icon: 'fa-solid fa-unlock' ) );
}
}
if( $this->canMove( $member ) )
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'move' ) ), languageString: 'move' , opensDialog: true ) );
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'move' ) ), languageString: 'move' , opensDialog: true, icon: 'fa-solid fa-arrow-right' ) );
}
if( $this->canMerge( $member ) )
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'merge' ) ), languageString: 'merge', opensDialog: true ) );
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'merge' ) ), languageString: 'merge', opensDialog: true, icon: 'fa-solid fa-down-left-and-up-right-to-center' ) );
}
if( isset( static::$archiveClass ) )
{
if( $this->canUnarchive( $member ) )
{
$unarchive = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'unarchive' ) ), languageString: 'unarchive');
$unarchive = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'unarchive' ) ), languageString: 'unarchive', icon: 'fa-solid fa-box-open');
$unarchive->requiresConfirm( $this->unarchiveBlurb() );
$menu->add( $unarchive );
}
if( $this->canRemoveArchiveExcludeFlag( $member ) )
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'removeArchiveExcludeFlag' ) ), languageString: 'remove_archive_exclude_flag') );
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'removeArchiveExcludeFlag' ) ), languageString: 'remove_archive_exclude_flag', icon: 'fa-solid fa-box') );
}
}
@@ -7274,6 +7335,7 @@ abstract class Item extends Content
if ( !$this->isFeatured() )
{
$feature = new ContentMenuLink( url: $this->url()->setQueryString( array( 'do' => 'feature', 'fromItem' => 1 ) ), languageString: 'promote_social_button' );
$feature->icon ="fa-solid fa-star";
$feature->addAttribute( 'data-ipsDialog-flashMessage', Member::loggedIn()->language()->addToStack( 'promote_flash_msg' ) )
->addAttribute( 'data-ipsDialog-flashMessageTimeout', 5 )
->addAttribute( 'data-ipsDialog-flashMessageEscape', 'false' )
@@ -7284,6 +7346,7 @@ abstract class Item extends Content
else
{
$feature = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'unfeature', 'fromItem' => 1 ) ), languageString: 'demote_social_button' );
$feature->icon ="fa-regular fa-star";
$feature->addAttribute( 'data-ipsDialog-flashMessage', Member::loggedIn()->language()->addToStack( 'demote_flash_msg' ) )
->addAttribute( 'data-ipsDialog-flashMessageTimeout', 5 )
->addAttribute( 'data-ipsDialog-flashMessageEscape', 'false' )
@@ -7297,6 +7360,7 @@ abstract class Item extends Content
if( $this->canDelete( $member ) )
{
$delete = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' =>'delete' ) ), languageString: 'delete');
$delete->icon ="fa-solid fa-trash";
$delete->requiresConfirm();
$menu->add( $delete );
}
@@ -7308,23 +7372,24 @@ abstract class Item extends Content
if( IPS::classUsesTrait( $this, Statistics::class ) )
{
$analytics = new ContentMenuLink( url:$this->url()->setQueryString( array( 'do' => 'analytics' ) ), languageString: 'analytics_and_stats' );
$analytics->icon = "fa-solid fa-chart-simple";
$analytics->opensDialog( 'analytics_and_stats', 'large' );
$menu->add( $analytics );
}
$menu->add( new ContentMenuLink( url:$this->url()->setQueryString( array( 'do' => 'modlog' ) ), languageString: 'moderation_history' , opensDialog: true ) );
$menu->add( new ContentMenuLink( url:$this->url()->setQueryString( array( 'do' => 'modlog' ) ), languageString: 'moderation_history' , opensDialog: true, icon: 'fa-solid fa-clock-rotate-left' ) );
}
if( IPS::classUsesTrait( $this, MetaData::class ) )
{
if( $this->canOnMessage( 'add', $member ) )
{
$menu->add( new ContentMenuLink( url: $this->url()->setQueryString( array( 'do' => 'messageForm' ) ), languageString: 'add_message' , opensDialog: true ) );
$menu->add( new ContentMenuLink( url: $this->url()->setQueryString( array( 'do' => 'messageForm' ) ), languageString: 'add_message' , opensDialog: true, icon: 'fa-solid fa-message' ) );
}
if( $this->canToggleItemModeration( $member ) )
{
$toggle = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'toggleItemModeration' ) ), languageString: $this->itemModerationEnabled() ?'disable_topic_moderation' : 'enable_topic_moderation' );
$toggle = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'toggleItemModeration' ) ), languageString: $this->itemModerationEnabled() ?'disable_topic_moderation' : 'enable_topic_moderation', icon: 'fa-solid fa-traffic-light' );
$toggle->requiresConfirm( $this->itemModerationEnabled() ? 'disable_topic_moderation_confirm' : 'enable_topic_moderation_confirm' );
$menu->add( $toggle );
}
@@ -7335,7 +7400,7 @@ abstract class Item extends Content
$menu->addSeparator();
foreach ( $actions as $action )
{
$actionLink = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'savedAction', 'action' => $action->_id ) ), languageString: $action->_title );
$actionLink = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'savedAction', 'action' => $action->_id ) ), languageString: $action->_title, icon: 'fa-solid fa-wand-magic' );
$actionLink->requiresConfirm();
$menu->add( $actionLink );
}
@@ -7474,17 +7539,28 @@ abstract class Item extends Content
* @apiresponse datetime date Date
* @apiresponse string content Content
* @apiresponse string url URL
* @apiresponse \IPS\core\Assignments\Assignment|null assignment Assignment data
*/
public function apiOutput( Member $authorizedMember = NULL ): array
{
return array(
$data = [
'id' => $this->id,
'title' => $this->title ? $this->title : NULL,
'container' => $this->containerWrapper()?->apiOutput( $authorizedMember ),
'title' => $this->mapped( 'title' ),
'container' => $this->containerWrapper( true )?->apiOutput( $authorizedMember ),
'author' => $this->author()->apiOutput( $authorizedMember ),
'date' => ( isset( static::$databaseColumnMap['date'] ) ? DateTime::ts( $this->mapped( 'date' ) )->rfc3339() : null ),
'content' => $this->content(),
'url' => (string) $this->url()
);
'url' => (string) $this->url(),
];
if ( IPS::classUsesTrait( $this, Assignable::class ) )
{
$data['assignment'] = $this->assignment ? $this->assignment->apiOutput($authorizedMember, false ) : NULL;
}
else
{
$data['assignment'] = NULL;
}
return $data;
}
}
+26
View File
@@ -103,10 +103,29 @@ trait ItemTopic
try
{
$topic = Topic::load( $this->$column );
if ( $topic->isArchived() )
{
/* Do not update archived topics */
return;
}
/* If this is a future publish date, make sure we update that as well */
if( IPS::classUsesTrait( $this, FuturePublishing::class ) and $topic->isFutureDate() )
{
$topic->start_date = $this->mapped( 'date' );
$topic->is_future_entry = $this->isFutureDate();
$topic->publish_date = $this->mapped( 'future_date' );
}
$title = $this->getTopicTitle();
Member::loggedIn()->language()->parseOutputForDisplay( $title );
$topic->title = $title;
$topic->save();
/* Synch the last comment data in case timestamps have changed */
$topic->resyncLastComment();
$firstPost = $topic->comments( 1 );
/* If the first post of the topic is missing, NULL will be returned */
@@ -118,6 +137,7 @@ trait ItemTopic
$content = $this->getTopicContent();
Member::loggedIn()->language()->parseOutputForDisplay( $content );
$firstPost->post = $content;
$firstPost->post_date = $topic->start_date;
$firstPost->save();
Index::i()->index( $firstPost );
}
@@ -181,6 +201,12 @@ trait ItemTopic
$topic->sendNotifications();
}
/* Check our auto-follow settings */
if( $topic->author()->auto_follow['content'] )
{
$topic->follow( $topic->author()->auto_follow['method'], !$this->isAnonymous(), $topic->author() );
}
/* Update file */
$this->$column = $topic->tid;
$this->save();
+19
View File
@@ -13,6 +13,7 @@ namespace IPS\Content;
use BadMethodCallException;
use IPS\Member;
use IPS\Request;
use function defined;
use function get_class;
use function header;
@@ -61,6 +62,18 @@ trait Lockable
*/
public function canLock( ?Member $member=NULL ): bool
{
/* Is this the task? Let it through */
if( Request::i()->isCliEnvironment() )
{
return true;
}
/* Extensions go first */
if( $permCheck = Permissions::can( 'lock', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'lock', $member ) )
{
return false;
@@ -94,6 +107,12 @@ trait Lockable
*/
public function canUnlock( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'unlock', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'unlock', $member ) )
{
return false;
+24
View File
@@ -216,6 +216,12 @@ trait MetaData
*/
public function canOnMessage( string $action, ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'onMessage', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'onMessage', $member ) )
{
return false;
@@ -300,6 +306,12 @@ trait MetaData
*/
public function canFeatureComment( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'featureComment', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'featureComment', $member ) )
{
return false;
@@ -317,6 +329,12 @@ trait MetaData
*/
public function canUnfeatureComment( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'unfeatureComment', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'unfeatureComment', $member ) )
{
return false;
@@ -398,6 +416,12 @@ trait MetaData
*/
public function canToggleItemModeration( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'toggleItemModeration', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'toggleItemModeration', $member ) )
{
return false;
+196
View File
@@ -0,0 +1,196 @@
<?php
/**
* @brief Permissions
* @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
* @subpackage
* @since 4/16/2025
*/
namespace IPS\Content;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Application;
use IPS\Application\Module;
use IPS\Db;
use IPS\Dispatcher;
use IPS\Extensions\PermissionsAbstract;
use IPS\IPS;
use IPS\Member;
use IPS\Node\Model;
use IPS\Settings;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Permissions
{
/**
* @brief Used for Permissions extensions; override default permissions
*/
const PERM_ALLOW = 1;
/**
* @brief Used for Permissions extensions; override default permissions
*/
const PERM_DENY = 2;
/**
* @brief Used for Permissions extensions; revert to default permission checks
*/
const PERM_DEFAULT = 0;
/**
* @var array
*/
protected static array $nodeIncludesMap = [];
/**
* @var array
*/
protected static array $nodeExcludeMap = [];
/**
* Load all the inclusion/exclusion mappings from extensions
*
* @param string $permission
* @param string $nodeClass
* @param Member $member
* @return void
*/
protected static function loadMappings( string $permission, string $nodeClass, Member $member ) : void
{
if( !isset( static::$nodeIncludesMap[ $nodeClass ][ $permission ] ) )
{
$include = [];
$exclude = [];
foreach( Application::allExtensions( 'core', 'Permissions' ) as $ext )
{
/* @var PermissionsAbstract $ext */
$include = array_merge( $include, $ext->nodeIdsToAllow( $permission, $nodeClass, $member ) );
$exclude = array_merge( $exclude, $ext->nodeIdsToDeny( $permission, $nodeClass, $member ) );
}
static::$nodeIncludesMap[ $nodeClass ][ $permission ] = $include;
static::$nodeExcludeMap[ $nodeClass ][ $permission ] = $exclude;
}
}
/**
* Easily load included IDs, for my sanity
*
* @param string $permission
* @param string $nodeClass
* @param Member|null $member
* @param int $permType
* @return array|null
*/
protected static function getMapping( string $permission, string $nodeClass, ?Member $member, int $permType ) : ?array
{
/* Only allow these extensions to run on the front-end or from the API */
if( !Dispatcher::hasInstance() or ( Dispatcher::i()->controllerLocation != 'front' and Dispatcher::i()->controllerLocation != 'api' ) )
{
return null;
}
$member = $member ?: Member::loggedIn();
static::loadMappings( $permission, $nodeClass, $member );
switch( $permType )
{
case static::PERM_ALLOW:
return count( static::$nodeIncludesMap[ $nodeClass ][ $permission ] ) ? static::$nodeIncludesMap[ $nodeClass ][ $permission ] : null;
case static::PERM_DENY:
return count( static::$nodeExcludeMap[ $nodeClass ][ $permission ] ) ? static::$nodeExcludeMap[ $nodeClass ][ $permission ] : null;
}
return null;
}
/**
* Wrapper method to call extension
*
* @param string $permission
* @param object $object
* @param Member|null $member
* @return int
*/
public static function can( string $permission, object $object, ?Member $member=null ) : int
{
/* Only allow these extensions to run on the front-end or from the API */
if( !Dispatcher::hasInstance() or ( Dispatcher::i()->controllerLocation != 'front' and Dispatcher::i()->controllerLocation != 'api' ) )
{
return static::PERM_DEFAULT;
}
foreach( Application::allExtensions( 'core', 'Permissions' ) as $ext )
{
/* @var PermissionsAbstract $ext */
$result = $ext->checkPermission( $permission, $object, $member );
if( $result !== static::PERM_DEFAULT )
{
return $result;
}
}
return static::PERM_DEFAULT;
}
/**
* Build a query clause to pull node IDs from the permissions table.
*
* @param string $permission
* @param string $nodeClass
* @param Member|null $member
* @param bool $includeClubs
* @return array
*/
public static function nodePermissionClause( string $permission, string $nodeClass, ?Member $member=null, bool $includeClubs=true ) : array
{
$member = $member ?: Member::loggedIn();
/* If clubs are disabled, or we don't have permission, force it to exclude them */
if( !Settings::i()->clubs or !$member->canAccessModule( Module::get( 'core', 'clubs', 'front' ) ) or !IPS::classUsesTrait( $nodeClass, ClubContainer::class ) )
{
$includeClubs = false;
}
/* @var Model $nodeClass */
/* @var array $permissionMap */
$clause = [
Db::i()->findInSet( 'core_permission_index.perm_' . $nodeClass::$permissionMap[ $permission ], ( $includeClubs ? $member->permissionArray() : $member->groups ) ),
'core_permission_index.perm_' . $nodeClass::$permissionMap[ $permission ] . '=?'
];
$binds = [
'*'
];
/* If we have any specific inclusions, put them in */
if( $includes = static::getMapping( $permission, $nodeClass, $member, static::PERM_ALLOW ) )
{
$clause[] = 'core_permission_index.perm_type_id in (' . implode( ",", $includes ) . ')';
}
$return = [
array_merge( array( '(' . implode( ' OR ', $clause ) . ')' ), $binds )
];
/* If we have any specific exclusions, we need it separate so that we make it an AND */
if( $excludes = static::getMapping( $permission, $nodeClass, $member, static::PERM_DENY ) )
{
$return[] = [ Db::i()->in( 'core_permission_index.perm_type_id', $excludes, true ) ];
}
return $return;
}
}
+12
View File
@@ -45,6 +45,12 @@ trait Pinnable
*/
public function canPin( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'pin', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'pin', $member ) )
{
return false;
@@ -67,6 +73,12 @@ trait Pinnable
*/
public function canUnpin( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'unpin', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'unpin', $member ) )
{
return false;
+3 -1
View File
@@ -11,6 +11,7 @@
namespace IPS\Content;
use BadMethodCallException;
use IPS\IPS;
use IPS\Member;
use IPS\Poll;
use IPS\Node\Model;
@@ -60,7 +61,8 @@ trait Polls
if( $this->mapped('poll') )
{
/* If the poll is in a club, return the special extended class */
if( $this->containerWrapper() AND $club = $this->containerWrapper()->club() )
$container = $this->containerWrapper( true );
if( $container and IPS::classUsesTrait( $container, ClubContainer::class ) and $club = $container->club() )
{
$poll = ClubPoll::load( $this->mapped('poll') );
$poll->club = $club;
+6
View File
@@ -48,6 +48,12 @@ trait Ratings
*/
public function canRate( Member|null $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'rate', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
$member = $member ?: Member::loggedIn();
if( !$member->member_id )
{
+10
View File
@@ -16,6 +16,7 @@ use BadMethodCallException;
use DateInterval;
use DomainException;
use Exception;
use IPS\Api\Webhook;
use IPS\Application;
use IPS\Content;
use IPS\DateTime;
@@ -164,6 +165,8 @@ trait Reactable
/* Fire event */
Event::fire( 'onReact', $member, [ $this, $reaction ] );
Webhook::fire( 'content_reaction_added', [ 'member' => $member, 'content' => $this, 'reaction' => $reaction ] );
/* Have we hit highlighted content? */
if( $this->isHighlighted() )
{
@@ -262,6 +265,7 @@ trait Reactable
/* Fire event */
Event::fire( 'onUnreact', $member, [ $this ] );
Webhook::fire( 'content_reaction_removed', [ 'member' => $member, 'content' => $this, 'reaction' => $reaction ] );
/* Remove Notifications */
if( $removeNotifications === TRUE )
@@ -296,6 +300,12 @@ trait Reactable
*/
public function canReact( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'react', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'react', $member ) )
{
return false;
+31 -1
View File
@@ -13,6 +13,7 @@ namespace IPS\Content;
/* To prevent PHP errors (extending class does not exist) revealing path */
use Exception;
use IPS\IPS;
use IPS\Node\CustomBadge;
use IPS\Platform\Bridge;
use IPS\Data\Store;
@@ -257,8 +258,16 @@ class Reaction extends Model
{
return $badge->file()?->url;
}
if( $this->_data['icon'] )
{
try
{
return File::get( 'core_Reaction', $this->_data['icon'] );
}
catch( Exception $e ){}
return File::get( 'core_Reaction', $this->_data['icon'] );
}
return NULL;
}
/**
@@ -489,4 +498,25 @@ class Reaction extends Model
parent::delete();
}
/**
* Get output for API
*
* @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials
* @return array
* @apiresponse int id ID number
* @apiresponse string icon URL to the icon,
* @apiresponse string value Value of the reaction
*/
public function apiOutput( Member $authorizedMember = NULL ): array
{
$return = array(
'id' => $this->id,
'title' => $this->title,
'icon' => (string) $this->icon,
'value' => $this->value
);
return $return;
}
}
+12
View File
@@ -40,6 +40,12 @@ trait Recognizable
*/
public function canRemoveRecognize( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'unrecognize', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'unrecognize', $member ) )
{
return false;
@@ -82,6 +88,12 @@ trait Recognizable
*/
public function canRecognize( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'recognize', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'recognize', $member ) )
{
return false;
+6
View File
@@ -320,6 +320,12 @@ abstract class Review extends Comment
*/
public function canView( Member|null $member= null ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'view', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( $member === NULL )
{
$member = Member::loggedIn();
+3
View File
@@ -103,6 +103,7 @@ class Index extends SearchIndex
'settings' => $settings,
'mappings' => array(
'properties' => array(
'index_id' => array( 'type' => 'keyword' ),
'index_class' => array( 'type' => 'keyword' ),
'index_object_id' => array( 'type' => 'long' ),
'index_item_id' => array( 'type' => 'long' ),
@@ -205,6 +206,8 @@ class Index extends SearchIndex
{
if ( $indexData = $this->indexData( $object ) )
{
$indexData['index_id'] = $this->getIndexId( $object );
/* If nobody has permission to access it, just remove it */
if ( !$indexData['index_permissions'] )
{
@@ -44,6 +44,8 @@ class MassIndexer extends Index
{
if ( $indexData = $this->indexData( $object ) and $indexData['index_permissions'] )
{
$indexData['index_id'] = $this->getIndexId( $object );
if ( $object instanceof Item )
{
$parent = $object;
+45 -5
View File
@@ -25,8 +25,10 @@ use IPS\IPS;
use IPS\Log;
use IPS\Member;
use IPS\Member\Club;
use IPS\Node\Grouping;
use IPS\Settings;
use StdClass;
use function array_pop;
use function count;
use function defined;
use function in_array;
@@ -79,6 +81,11 @@ class Query extends SearchQuery
*/
protected array|null $itemClasses = NULL;
/**
* @brief If true, just fetch the title record, or the first comment if a post, etc
*/
protected bool $getTitleRecordOnly = false;
/**
* Constructor
*
@@ -133,6 +140,19 @@ class Query extends SearchQuery
{
if ( $filter->containerIdFilter )
{
if ( $filter->containerIds )
{
if ( $filter->itemClass )
{
$itemClass = $filter->itemClass;
if ( isset( $itemClass::$containerNodeClass ) )
{
$containerClass = $itemClass::$containerNodeClass;
$filter->containerIds = $containerClass::normalizeIds( $filter->containerIds );
}
}
}
$conditions[] = array(
'terms' => array(
'index_container_id' => $filter->containerIds
@@ -217,9 +237,7 @@ class Query extends SearchQuery
/* Only first comment? */
if ( $filter->onlyFirstComment )
{
$conditions[] = array(
'exists' => array( 'field' => 'index_title' )
);
$this->getTitleRecordOnly = true;
}
/* Only last comment? */
@@ -290,7 +308,7 @@ class Query extends SearchQuery
/**
* Filter by club
*
* @param Club|int|array|null $club The club, or array of club IDs
* @param Club|int|array|null $club The club, or array of club IDs or NULL to exlude content from clubs
* @return Query (for daisy chaining)
*/
public function filterByClub( Club|int|array|null $club ): static
@@ -1103,6 +1121,9 @@ class Query extends SearchQuery
if ( $method & static::TERM_TITLES_ONLY )
{
$searchConditions[] = array( 'match_phrase' => array( 'index_title' => array( 'query' => $term ) ) );
/* Just get the title record only, or the first post if it's a topic as we don't want to show each comment as each comment will have the topic title in the index */
$this->getTitleRecordOnly = true;
}
else
{
@@ -1115,6 +1136,9 @@ class Query extends SearchQuery
if ( $method & static::TERM_TITLES_ONLY )
{
$searchConditions[] = array( 'wildcard' => array( 'index_title' => array( 'value' => $term ) ) );
/* Just get the title record only, or the first post if it's a topic as we don't want to show each comment as each comment will have the topic title in the index */
$this->getTitleRecordOnly = true;
}
else
{
@@ -1138,6 +1162,9 @@ class Query extends SearchQuery
if ( $method & static::TERM_TITLES_ONLY )
{
$searchConditions[] = array( 'match' => array( 'index_title' => array( 'query' => $term, 'operator' => $operator ) ) );
/* Just get the title record only, or the first post if it's a topic as we don't want to show each comment as each comment will have the topic title in the index */
$this->getTitleRecordOnly = true;
}
else
{
@@ -1164,7 +1191,7 @@ class Query extends SearchQuery
/* If we're not searching with a term, then just show the title record, not comments */
if ( $term === NULL and ! $this->lastCommentMustBeTrue )
{
$must[] = ['exists' => ['field' => 'index_title']];
$this->getTitleRecordOnly = true;
}
}
@@ -1221,6 +1248,19 @@ class Query extends SearchQuery
/* Peform the search */
try
{
/* We only want the main record (of first comment) not the comments themselves, so we check to make sure _id (aka index_id) is the same as index_item_index_id */
if ( $this->getTitleRecordOnly )
{
$filters[] = [
'script' => [
'script' => [
'lang' => 'painless',
'source' => "doc['index_item_index_id'] == doc['index_id']"
]
]
];
}
/* Initial query */
$query = array(
'bool' => array(
+16 -1
View File
@@ -22,6 +22,7 @@ use IPS\Db\Exception;
use IPS\Db\Select;
use IPS\Member;
use IPS\Member\Club;
use IPS\Node\Grouping;
use IPS\Settings;
use IPS\IPS;
use Throwable;
@@ -33,6 +34,7 @@ use function in_array;
use function intval;
use function is_array;
use function is_null;
use function is_string;
use const IPS\CIC;
use const IPS\USE_MYSQL_SEARCH_OPTIMIZED_MODE;
@@ -145,10 +147,23 @@ class Query extends \IPS\Content\Search\Query
$clause[] = 'index_class=?';
$params[] = array_pop( $filter->classes );
}
/* Set the containers */
if ( $filter->containerIdFilter !== NULL )
{
if ( $filter->containerIds )
{
if ( $filter->itemClass )
{
$itemClass = $filter->itemClass;
if ( isset( $itemClass::$containerNodeClass ) )
{
$containerClass = $itemClass::$containerNodeClass;
$filter->containerIds = $containerClass::normalizeIds( $filter->containerIds );
}
}
}
$clause[] = Db::i()->in( 'index_container_id', $filter->containerIds, $filter->containerIdFilter === FALSE );
}
+11 -3
View File
@@ -66,12 +66,19 @@ abstract class Query
{
if ( Settings::i()->search_method == 'elastic' )
{
return new Elastic\Query( $member ?: Member::loggedIn(), Url::external( rtrim( Settings::i()->search_elastic_server, '/' ) . '/' . Settings::i()->search_elastic_index ) );
$search = new Elastic\Query( $member ?: Member::loggedIn(), Url::external( rtrim( Settings::i()->search_elastic_server, '/' ) . '/' . Settings::i()->search_elastic_index ) );
}
else
{
return new Mysql\Query( $member ?: Member::loggedIn() );
$search = new Mysql\Query( $member ?: Member::loggedIn() );
}
/* Don't include any club content if we have disabled clubs */
if( !Settings::i()->clubs )
{
$search->filterByClub( NULL );
}
return $search;
}
/**
@@ -99,9 +106,10 @@ abstract class Query
{
$this->setHiddenFilter( static::HIDDEN_VISIBLE );
}
$filters = [];
/* Exclude disabled applications */
$filters = array();
foreach( SearchContent::searchableClasses() as $class )
{
/* We only want items here */
+6 -3
View File
@@ -161,11 +161,14 @@ class Content extends Result
$data = array_pop( $images );
$key = key( $data );
try
if( !empty( $data[ $key ] ) )
{
$enclosure = File::get( $key, $data[ $key ] );
try
{
$enclosure = File::get( $key, $data[ $key ] );
}
catch( Exception ) { }
}
catch( Exception ) { }
}
$document->addItem( $object instanceof Comment ? $object->item()->mapped( 'title' ) : $object->mapped( 'title' ), $object->url(), Result::preDisplay( $this->indexData['index_content'] ), DateTime::ts( $this->indexData['index_date_created'] ), NULL, $enclosure );
@@ -169,7 +169,22 @@ abstract class SearchContentAbstract
}
/* Articles language */
$articles = static::articlesFromIndexData( $objectClass, $containerData );
if( is_subclass_of( $objectClass, Comment::class ) and
(
( $itemClass::$firstCommentRequired and $indexData['index_item_index_id'] == $indexData['index_id'] ) or
( isset( $itemData['author'] ) )
)
)
{
/* If the first comment is required, and this is the first comment, treat it as an item, not a comment.
OR.... if we are commenting on someone else's content. */
$articles = static::articlesFromIndexData( $itemClass, $containerData );
}
else
{
$articles = static::articlesFromIndexData( $objectClass, $containerData );
}
$summaryLanguage = static::searchResultSummaryLanguage( $authorData, $articles, $indexData, $itemData );
/* Container details */
@@ -292,7 +307,7 @@ abstract class SearchContentAbstract
{
if( $itemClass::$firstCommentRequired )
{
if( $indexData['index_title'] )
if( $indexData['index_item_index_id'] == $indexData['index_id'] )
{
return Member::loggedIn()->language()->addToStack( "user_own_activity_item", FALSE, array( 'sprintf' => array( $articles['indefinite'] ), 'htmlsprintf' => array( $authorTemplate ) ) );
}
+6
View File
@@ -44,6 +44,12 @@ trait Shareable
*/
public function canShare(): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'share', $this ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'share' ) )
{
return false;
+12
View File
@@ -226,6 +226,12 @@ trait Solvable
*/
public function canSolve( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'solve', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'solve', $member ) )
{
return false;
@@ -238,6 +244,12 @@ trait Solvable
return FALSE;
}
/* Guests cannot do this */
if( !$member->member_id )
{
return false;
}
/* If we have no replies, it's not solvable yet */
if( $this->commentCount() <= 0 OR ( static::$firstCommentRequired AND $this->commentCount() == 1 ) )
{
+147 -10
View File
@@ -18,6 +18,7 @@ use BadMethodCallException;
use Exception;
use InvalidArgumentException;
use IPS\Application;
use IPS\cms\Records;
use IPS\Content;
use IPS\Data\Store;
use IPS\DateTime;
@@ -27,6 +28,7 @@ use IPS\Helpers\Form;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\Translatable;
use IPS\Helpers\Form\Upload;
use IPS\Helpers\Form\YesNo;
use IPS\Http\Url;
use IPS\Http\Url\Friendly;
use IPS\IPS;
@@ -127,6 +129,11 @@ class Tag extends Model
*/
public static string $application = 'core';
/**
* @brief [Node] Sortable?
*/
public static bool $nodeSortable = false;
/**
* [Node] Return the custom badge for each row
*
@@ -222,6 +229,21 @@ class Tag extends Model
$this->_data['totals'] = ( is_array( $val ) and count( $val ) ) ? json_encode( $val ) : null;
}
/**
* [Node] Get content table meta description
*
* @return string|null
*/
public function metaDescription(): ?string
{
if( !Member::loggedIn()->language()->checkKeyExists( static::$titleLangPrefix . $this->id . static::$descriptionLangSuffix ) )
{
return null;
}
return parent::metaDescription();
}
/**
* Is this node currently queued for deleting or moving content OR is it the target of content queued to be moved from another node?
*
@@ -237,6 +259,16 @@ class Tag extends Model
return parent::deleteOrMoveQueued();
}
/**
* [Node] Get the title to store in the log
*
* @return string|null
*/
public function titleForLog(): ?string
{
return $this->text;
}
/**
* Determines if the tag has been flagged for merging.
* We need to flag it because this can take a while if there
@@ -257,7 +289,9 @@ class Tag extends Model
*/
public function form( Form &$form ): void
{
$form->add( new Text( 'tag_text', $this->text, true, [], function( $val ){
$form->add( new Text( 'tag_text', $this->text, true, [
'regex' => '/^[^,]+$/i',
], function( $val ){
try
{
$test = Db::i()->select( '*', static::$databaseTable, [ 'tag_text=? and tag_id <> ?', $val, (int) $this->id ] )->first();
@@ -299,6 +333,8 @@ class Tag extends Model
'multiple' => false,
'allowStockPhotos' => true
] ) );
$form->add( new YesNo( 'tag_recommended', $this->recommended, false ) );
}
/**
@@ -334,6 +370,56 @@ class Tag extends Model
return $values;
}
/**
* [Node] Get buttons to display in tree
* Example code explains return value
*
* @code
* array(
* array(
* 'icon' => 'plus-circle', // Name of FontAwesome icon to use
* 'title' => 'foo', // Language key to use for button's title parameter
* 'link' => \IPS\Http\Url::internal( 'app=foo...' ) // URI to link to
* 'class' => 'modalLink' // CSS Class to use on link (Optional)
* ),
* ... // Additional buttons
* );
* @endcode
* @param Url $url Base URL
* @param bool $subnode Is this a subnode?
* @return array
*/
public function getButtons( Url $url, bool $subnode=FALSE ): array
{
$buttons = [];
if( $this->recommended )
{
$buttons['recommend'] = [
'icon' => 'star',
'title' => 'tag_mark_norecommend',
'link' => $url->setQueryString( [ 'do' => 'recommend', 'id' => $this->id ] )->csrf()
];
}
else
{
$buttons['recommend'] = [
'icon' => 'regular fa-star',
'title' => 'tag_mark_recommend',
'link' => $url->setQueryString( [ 'do' => 'recommend', 'id' => $this->id ] )->csrf()
];
}
$buttons['view'] = [
'icon' => 'search',
'title' => 'tag_view_page',
'link' => $this->url(),
'target' => '_blank'
];
return array_merge( $buttons, parent::getButtons( $url, $subnode ) );
}
/**
* Recount the totals for this content type all affected tags
*
@@ -446,7 +532,7 @@ class Tag extends Model
public static function allTags() : array
{
return iterator_to_array(
Db::i()->select( 'tag_text', static::$databaseTable, null, 'tag_text' )
Db::i()->select( 'tag_text', static::$databaseTable, null, 'tag_recommended desc, tag_text' )
);
}
@@ -462,7 +548,10 @@ class Tag extends Model
{
if( IPS::classUsesTrait( $class, Taggable::class ) )
{
$return[ $class::$title ] = $class;
$bits = explode( "\\", $class );
$tab = $bits[1] . '__' . array_pop( $bits );
$return[ $tab ] = $class;
Member::loggedIn()->language()->words[ $tab . '_pl' ] = Member::loggedIn()->language()->get( $class::$title . '_pl' );
}
}
return $return;
@@ -501,9 +590,11 @@ class Tag extends Model
try
{
$item = $itemClass::load( $row['pinned_item_id'] );
$item->pinnedData = $row;
$items[] = $item;
if( $item->canView() )
{
$item->pinnedData = $row;
$items[] = $item;
}
}
catch( OutOfRangeException ){}
}
@@ -579,14 +670,14 @@ class Tag extends Model
if( !isset( Store::i()->tagSlugs ) )
{
Store::i()->tagSlugs = iterator_to_array(
Db::i()->select( 'tag_text,tag_text_seo', static::$databaseTable, [ 'tag_enabled=?', 1 ] )
Db::i()->select( 'LOWER(tag_text) as tag_text,tag_text_seo', static::$databaseTable, [ 'tag_enabled=?', 1 ] )
->setKeyField( 'tag_text' )
->setValueField( 'tag_text_seo' )
);
}
$cache = Store::i()->tagSlugs;
$slug = $cache[ $tagText ] ?? Friendly::seoTitle( $tagText );
$slug = $cache[ mb_strtolower( $tagText ) ] ?? Friendly::seoTitle( $tagText );
return Url::internal( "app=core&module=discover&controller=tag&tag=" . $slug, "front", "tags" );
}
@@ -652,11 +743,21 @@ class Tag extends Model
if ( !isset( Store::i()->tags ) )
{
/* Cache only the enabled tags */
Store::i()->tags = iterator_to_array(
Db::i()->select( 'tag_id,tag_text', static::$databaseTable, [ 'tag_enabled=?', 1 ], 'tag_text' )
$recommended = iterator_to_array(
Db::i()->select( 'tag_id,tag_text', static::$databaseTable, [ 'tag_enabled=? and tag_recommended=?', 1, 1 ], 'tag_text' )
->setKeyField( 'tag_id' )
->setValueField( 'tag_text' )
);
natcasesort( $recommended );
$other = iterator_to_array(
Db::i()->select( 'tag_id,tag_text', static::$databaseTable, [ 'tag_enabled=? and tag_recommended=?', 1, 0 ], 'tag_text' )
->setKeyField( 'tag_id' )
->setValueField( 'tag_text' )
);
natcasesort( $other );
Store::i()->tags = $recommended + $other;
}
return Store::i()->tags;
@@ -740,4 +841,40 @@ class Tag extends Model
/* Queue the background task to remove the tag from all currently tagged items */
Task::queue( 'core', 'UpdateTaggedItems', [ 'tag' => $this->text, 'delete' => true ], 5, [ 'tag' ] );
}
/**
* Get output for API
*
* @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials
* @return array
* @apiresponse int id ID number
* @apiresponse string tag Name
* @apiresponse string text_seo URL Slug
* @apiresponse string headline Headline
* @apiresponse string description description
* @apiresponse string cover_photo Cover Photo Path
* @apiresponse int recommended Recommended
*/
public function apiOutput( Member $authorizedMember = NULL ): array
{
$return = [
'id' => $this->id,
'tag' => $this->_title,
'text-seo' => $this->text_seo,
'headline' => $this->headline,
'url' => (string) $this->url(),
'description' => $this->description,
];
if( $file = $this->coverPhotoFile() )
{
$return[ 'cover_photo' ] = (string) $file;
}else
{
$return[ 'cover_photo' ] = null;
}
$return['recommended'] = $this->recommended;
return $return;
}
}
+7 -31
View File
@@ -11,13 +11,11 @@
namespace IPS\Content;
use DomainException;
use IPS\Content\Search\SearchContent;
use IPS\Member;
use IPS\Node\Model;
use IPS\Request;
use IPS\Settings;
use IPS\Db;
use IPS\Content\Search\Index;
use IPS\Helpers\Form\Text;
use IPS\IPS;
@@ -105,9 +103,7 @@ trait Taggable
*/
public static function definedTags(): array
{
$tags = array_values( Tag::getStore() );
natcasesort( $tags );
return $tags;
return array_values( Tag::getStore() );
}
/**
@@ -144,21 +140,17 @@ trait Taggable
{
$idColumn = static::$databaseColumnId;
$this->tags = array( 'tags' => array(), 'prefix' => NULL );
$tagList = Tag::getStore();
array_walk( $tagList, function( &$value, $key ) use ( &$tagList ) {
$tagList[ $key ] = strtolower( $value );
} );
foreach ( Db::i()->select( '*', 'core_tags', array( 'tag_meta_app=? AND tag_meta_area=? AND tag_meta_id=?', static::$application, static::$module, $this->$idColumn ), 'tag_text' ) as $tag )
{
$tagText = Settings::i()->tags_force_lower ? mb_strtolower( $tag['tag_text'] ) : $tag['tag_text'];
if ( $tag['tag_prefix'] )
{
$this->tags['prefix'] = $tag['tag_text'];
$this->tags['prefix'] = $tagText;
}
/* Sanity check in case the tag is pending deletion */
elseif( in_array( strtolower( $tag['tag_text'] ), $tagList ) )
else
{
$this->tags['tags'][] = $tag['tag_text'];
$this->tags['tags'][] = $tagText;
}
}
}
@@ -304,31 +296,15 @@ trait Taggable
*/
public static function tagsFormField( ?Item $item, Model|string|null $container = null, bool $minimized = FALSE ): Text|NULL
{
/* Include existing tags in case we're editing */
if ( $item )
{
$source = $item->prefix() ? array_merge( array( 'prefix' => $item->prefix() ), $item->tags() ) : $item->tags();
}
else
{
$source = array();
}
/* And get the defined tags */
if( static::definedTags() )
{
$source = array_unique( array_merge( $source, static::definedTags() ) );
}
$options = array(
'autocomplete' => array(
'unique' => TRUE,
'source' => $source,
'source' => static::definedTags(),
'resultItemTemplate' => 'core.autocomplete.tagsResultItem',
'freeChoice' => false,
'minimized' => $minimized,
'addTokenText' => Member::loggedIn()->language()->get( 'tags_optional' ),
'alphabetical' => true
'alphabetical' => false
) );
if ( Settings::i()->tags_force_lower )
+2 -1
View File
@@ -14,6 +14,7 @@ namespace IPS\Data\Store;
use DirectoryIterator;
use DomainException;
use ErrorException;
use IPS\Data\Store;
use IPS\Helpers\Form\Text;
use IPS\Request;
@@ -119,7 +120,7 @@ class FileSystem extends Store
{
static::$cache[ $key ] = require( $this->_path . '/' . $key . '.' . SUITE_UNIQUE_KEY . '.php' );
}
catch ( ParseError $e )
catch ( ParseError|ErrorException $e )
{
throw new UnderflowException;
}
+22 -20
View File
@@ -1007,7 +1007,7 @@ class Db extends mysqli
/* Run */
$return = $this->returnQuery;
$stmt = $this->preparedQuery($query[0], $query[1]);
$stmt = $this->preparedQuery($query[0], $query[1] );
if( $return === TRUE )
{
@@ -1733,7 +1733,7 @@ class Db extends mysqli
/* Make sure we're connected */
$this->checkConnection(TRUE);
return $this->query($this->_createTableQuery( $data ));
return $this->query($this->_createTableQuery( $data ), read: false );
}
/**
@@ -1749,7 +1749,7 @@ class Db extends mysqli
/* Make sure we're connected */
$this->checkConnection(TRUE);
return $this->query("CREATE TABLE `{$this->prefix}{$newTableName}` LIKE `{$this->prefix}{$table}`");
return $this->query("CREATE TABLE `{$this->prefix}{$newTableName}` LIKE `{$this->prefix}{$table}`", read: false );
}
/**
@@ -1835,7 +1835,7 @@ class Db extends mysqli
$cleanUp = TRUE;
}
$result = $this->query("RENAME TABLE " . $query);
$result = $this->query("RENAME TABLE " . $query, read: false );
if( $cleanUp )
{
@@ -1869,7 +1869,7 @@ class Db extends mysqli
/* Force InnoDB as the storage engine */
$query .= "ENGINE=InnoDB";
$this->query($query);
$this->query($query, read: false );
}
/**
@@ -1917,7 +1917,8 @@ class Db extends mysqli
return '`' . $prefix . $val . '`';
},
( is_array( $table ) ? $table : array( $table ) )
) )
) ),
read: false
);
}
@@ -2143,7 +2144,7 @@ class Db extends mysqli
$query .= implode( ", ", $indexClauses );
$query .= " );";
return $this->query( $query );
return $this->query( $query, read: false );
}
/**
@@ -2166,7 +2167,7 @@ class Db extends mysqli
$query .= implode( ", ", $columnClauses );
$query .= ";";
return $this->query( $query );
return $this->query( $query, read: false );
}
/**
@@ -2179,7 +2180,7 @@ class Db extends mysqli
*/
public function addColumn(string $table, array $definition ): mysqli_result|bool
{
return $this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` ADD COLUMN {$this->compileColumnDefinition( $definition )}");
return $this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` ADD COLUMN {$this->compileColumnDefinition( $definition )}", MYSQLI_STORE_RESULT, false );
}
/**
@@ -2193,7 +2194,7 @@ class Db extends mysqli
*/
public function changeColumn(string $table, string $column, array $definition ): mysqli_result|bool
{
return $this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` CHANGE COLUMN `{$this->escape_string( $column )}` {$this->compileColumnDefinition( $definition )}");
return $this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` CHANGE COLUMN `{$this->escape_string( $column )}` {$this->compileColumnDefinition( $definition )}", MYSQLI_STORE_RESULT, false );
}
/**
@@ -2221,7 +2222,7 @@ class Db extends mysqli
$statement = "DROP COLUMN `{$this->escape_string( $column )}`";
}
return $this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` {$statement};");
return $this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` {$statement};", MYSQLI_STORE_RESULT, false );
}
/**
@@ -2247,7 +2248,7 @@ class Db extends mysqli
/* Otherwise just do it normally */
else
{
$this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` {$this->buildIndex( $table, $definition )}");
$this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` {$this->buildIndex( $table, $definition )}", MYSQLI_STORE_RESULT, false );
}
}
@@ -2267,11 +2268,11 @@ class Db extends mysqli
if( $this->checkForIndex( $table, $index ) )
{
$query = $this->dropIndex( $table, $index );
$this->dropIndex( $table, $index );
if( $returnQuery === TRUE )
{
$return = $query;
$return = $this->lastCompiledQuery;
}
}
@@ -2281,12 +2282,13 @@ class Db extends mysqli
}
$this->addIndex( $table, $definition );
if( $returnQuery === TRUE )
{
$this->returnQuery = FALSE;
$return .= ( $query ?? '' );
$this->lastCompiledQuery = $return . $this->lastCompiledQuery; // prepend the drop index first
$return .= $this->lastCompiledQuery;
return $return;
}
@@ -2364,7 +2366,7 @@ class Db extends mysqli
if ( $_index )
{
$return = $this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` {$_index};");
$return = $this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` {$_index};", MYSQLI_STORE_RESULT, false );
}
else
{
@@ -2892,7 +2894,7 @@ class Db extends mysqli
*/
public function createDatabase( string $name ): mysqli_result|bool
{
return ( $this->query("CREATE DATABASE ". $this->escape_string( "{$name}" )) );
return ( $this->query("CREATE DATABASE ". $this->escape_string( "{$name}" ), MYSQLI_STORE_RESULT, false ) );
}
/**
+19
View File
@@ -423,4 +423,23 @@ class Developer
}
return $handler;
}
/**
* Retrieves the file path for an application's language file.
* Can optionally add the IDE Handler link so that the link can be opened in an IDE
*
* @param Application $application The application instance.
* @param bool $ideHandlerSupport Indicates whether the IDE handler support is enabled.
* @return string|bool The language file path as a string if it exists, or FALSE if the file does not exist.
*/
public static function getApplicationsLanguageFilePath( Application $application, bool $ideHandlerSupport = TRUE ): string|bool
{
$path = \IPS\ROOT_PATH.'/applications/'. $application->directory . '/dev/lang.php';
if ( file_exists( $path ) and $ideHandlerSupport and $path = static::getIdeHref( $path ) )
{
return $path;
}
return FALSE;
}
}
+65 -44
View File
@@ -278,6 +278,11 @@ class Admin extends Standard
{
foreach ( Db::i()->select( '*', 'core_acp_search_index' ) as $word )
{
if( !empty( $word['callback'] ) and !eval( $word['callback'] ) )
{
continue;
}
$this->searchKeywords[ $word['url'] ]['lang_key'] = $word['lang_key'];
$this->searchKeywords[ $word['url'] ]['restriction'] = $word['restriction'];
$this->searchKeywords[ $word['url'] ]['keywords'][] = $word['keyword'];
@@ -422,10 +427,11 @@ class Admin extends Standard
public function buildMenu(bool $rebuild=FALSE ): ?array
{
$acpTabOrder = $this->_getAcpTabOrder();
$alwaysFirst = [];
if ( $this->menu === NULL or $rebuild === TRUE )
if ( $this->menu === null or $rebuild === true )
{
$this->menu = array( 'tabs' => array(), 'defaults' => array(), 'badges' );
$this->menu = ['tabs' => [], 'defaults' => [], 'badges'];
foreach ( Application::applications() as $app )
{
@@ -433,11 +439,15 @@ class Admin extends Standard
{
$appMenu = $app->acpMenu();
if ( $acpTabOrder !== NULL and isset( $acpTabOrder[ $app->directory ] ) and $app->directory == 'nexus' )
if ( $appItemsFirst = $app->acpMenuItemsAlwayFirst() and count( $appItemsFirst ) )
{
uksort( $appMenu, function( $a, $b ) use ( $acpTabOrder, $app )
{
return array_search( "{$app->directory}_{$a}", $acpTabOrder[ $app->directory ] ) - array_search( "{$app->directory}_{$b}", $acpTabOrder[ $app->directory ] );
$alwaysFirst = array_merge( $alwaysFirst, $appItemsFirst );
}
if ( $acpTabOrder !== null and isset( $acpTabOrder[$app->directory] ) and $app->directory == 'nexus' )
{
uksort( $appMenu, function ( $a, $b ) use ( $acpTabOrder, $app ) {
return array_search( "{$app->directory}_{$a}", $acpTabOrder[$app->directory] ) - array_search( "{$app->directory}_{$b}", $acpTabOrder[$app->directory] );
} );
}
@@ -455,34 +465,34 @@ class Admin extends Standard
if ( Member::loggedIn()->hasAcpRestriction( $app, $moduleToCheck ) )
{
if( !$item['restriction'] )
if ( !$item['restriction'] )
{
$canAccess = TRUE;
$canAccess = true;
}
else
{
if( mb_strpos( $item['restriction'], ',' ) )
if ( mb_strpos( $item['restriction'], ',' ) )
{
$restrictions = explode( ',', $item['restriction'] );
}
else
{
$restrictions = array( $item['restriction'] );
$restrictions = [$item['restriction']];
}
$canAccess = FALSE;
$canAccess = false;
foreach( $restrictions as $restrictionKey )
foreach ( $restrictions as $restrictionKey )
{
if( Member::loggedIn()->hasAcpRestriction( $app, $moduleToCheck, $restrictionKey ) )
if ( Member::loggedIn()->hasAcpRestriction( $app, $moduleToCheck, $restrictionKey ) )
{
$canAccess = TRUE;
$canAccess = true;
break;
}
}
}
if( isset( $item['activemenuitem'] ) and $item['activemenuitem'] )
if ( isset( $item['activemenuitem'] ) and $item['activemenuitem'] )
{
$groupKey = substr( $item['activemenuitem'], 0, strrpos( $item['activemenuitem'], '_' ) );
$itemKey = substr( $item['activemenuitem'], strrpos( $item['activemenuitem'], '_' ) + 1 );
@@ -494,13 +504,13 @@ class Admin extends Standard
if ( $canAccess )
{
$this->menu['tabs'][ $item['tab'] ][ "{$groupKey}" ][ $itemKey ] = "app={$app->directory}&module={$moduleUrl}&controller={$item['controller']}" . ( $item['do'] ? "&do={$item['do']}" : '' );
$this->menu['tabs'][$item['tab']]["{$groupKey}"][$itemKey] = "app={$app->directory}&module={$moduleUrl}&controller={$item['controller']}" . ( $item['do'] ? "&do={$item['do']}" : '' );
}
/* Custom badge */
if( isset( $item['badge'] ) and $item['badge'] )
if ( isset( $item['badge'] ) and $item['badge'] )
{
$this->menu['badges'][ "{$groupKey}_{$itemKey}" ] = $item['badge'];
$this->menu['badges']["{$groupKey}_{$itemKey}"] = $item['badge'];
}
}
}
@@ -510,53 +520,51 @@ class Admin extends Standard
}
/* Developer Center */
if( \IPS\IN_DEV )
if ( \IPS\IN_DEV )
{
$currentApp = Request::i()->appKey ?? 'core';
foreach( $this->menu['tabs']['developer'] as $module => $controllers )
foreach ( $this->menu['tabs']['developer'] as $module => $controllers )
{
foreach( $controllers as $key => $url )
foreach ( $controllers as $key => $url )
{
parse_str( $url, $params );
$params['module'] = 'developer';
$params['appKey'] = $currentApp;
$this->menu['tabs']['developer'][ $module ][ $key ] = http_build_query( $params );
$this->menu['tabs']['developer'][$module][$key] = http_build_query( $params );
}
}
}
if ( $acpTabOrder !== NULL )
if ( $acpTabOrder !== null )
{
$_apps = array_keys( $acpTabOrder );
uksort( $this->menu['tabs'], function($a, $b) use ( $_apps )
{
if( !in_array( $a, $_apps ) )
$_apps = array_keys( $acpTabOrder );
uksort( $this->menu['tabs'], function ( $a, $b ) use ( $_apps ) {
if ( !in_array( $a, $_apps ) )
{
return 1;
}
if( !in_array( $b, $_apps ) )
if ( !in_array( $b, $_apps ) )
{
return -1;
}
return array_search( $a, $_apps ) - array_search( $b, $_apps );
} );
foreach( $acpTabOrder as $app => $submenu )
foreach ( $acpTabOrder as $app => $submenu )
{
if ( !empty( $submenu ) )
{
if( isset( $this->menu['tabs'][ $app ] ) )
if ( isset( $this->menu['tabs'][$app] ) )
{
uksort( $this->menu['tabs'][ $app ], function($a, $b) use ( $submenu )
{
if( !in_array( $a, $submenu ) )
uksort( $this->menu['tabs'][$app], function ( $a, $b ) use ( $submenu ) {
if ( !in_array( $a, $submenu ) )
{
return 1;
}
if( !in_array( $b, $submenu ) )
if ( !in_array( $b, $submenu ) )
{
return -1;
}
@@ -564,11 +572,10 @@ class Admin extends Standard
return array_search( $a, $submenu ) - array_search( $b, $submenu );
} );
}
if ( isset( $this->menu['defaults'] ) )
{
uksort( $this->menu['defaults'], function($a, $b) use ( $acpTabOrder )
{
uksort( $this->menu['defaults'], function ( $a, $b ) use ( $acpTabOrder ) {
return array_search( $a, $acpTabOrder );
} );
}
@@ -577,15 +584,29 @@ class Admin extends Standard
}
/* Now set the tab defaults */
foreach( $this->menu['tabs'] as $tab => $menu )
foreach ( $this->menu['tabs'] as $tab => $menu )
{
if ( !isset( $this->menu['defaults'][ $tab ] ) )
if ( !isset( $this->menu['defaults'][$tab] ) )
{
foreach( $menu as $group => $submenu )
foreach ( $menu as $group => $submenu )
{
$this->menu['defaults'][ $tab ] = array_values($submenu)[0];
$this->menu['defaults'][$tab] = array_values( $submenu )[0];
break;
}
}
}
}
/* Set any items to be first */
foreach ( $alwaysFirst as $entry )
{
foreach ( $entry as $key => $menuItem )
{
if ( isset( $this->menu['tabs'][$key] ) and isset( $this->menu['tabs'][$key][$menuItem] ) )
{
$first = $this->menu['tabs'][$key][$menuItem];
unset( $this->menu['tabs'][$key][$menuItem] );
$this->menu['tabs'][$key] = array_merge( [$menuItem => $first], $this->menu['tabs'][$key] );
}
}
}
+1 -1
View File
@@ -112,7 +112,7 @@ abstract class Controller
*/
public function execute() : void
{
if ( !empty( static::$dataLayerContext ) AND Settings::i()->core_datalayer_enabled AND !Request::i()->isAjax() )
if ( !empty( static::$dataLayerContext ) AND DataLayer::enabled() AND !Request::i()->isAjax() )
{
foreach ( static::$dataLayerContext as $property => $data )
{
+13 -32
View File
@@ -19,6 +19,7 @@ use InvalidArgumentException;
use IPS\Application;
use IPS\Content\Search\SearchContent;
use IPS\core\Alerts\Alert;
use IPS\core\DataLayer;
use IPS\core\IndexNow;
use IPS\core\Rss;
use IPS\Data\Cache;
@@ -27,6 +28,7 @@ use IPS\Data\Store;
use IPS\DateTime;
use IPS\Db;
use IPS\Developer;
use IPS\Dispatcher;
use IPS\Http\Url;
use IPS\Http\Url\Friendly;
use IPS\Http\Url\Internal;
@@ -513,7 +515,7 @@ class Front extends Standard
{
foreach( Application::applications() as $directory => $application )
{
if ( $application->_enabled )
if ( $application->enabled )
{
if( method_exists( $application, 'convertLegacyParameters' ) )
{
@@ -561,32 +563,6 @@ class Front extends Standard
}
}
catch ( UnderflowException $e ) {}
if ( Output::i()->allowDefaultWidgets )
{
$defaultAreas = [];
foreach ( Widget::appDefaults( $this->application ) as $widget )
{
$areaKey = $widget['default_area'];
/* If another app has already defined this area or the area has been filled by the page builder, don't overwrite it */
if ( isset( $widgets[ $areaKey ] ) and !isset( $defaultAreas[ $areaKey ] ) )
{
continue;
}
$widget['unique'] = $widget['key'];
if ( !isset( $widgets[ $areaKey ] ) )
{
$defaultAreas[ $areaKey ] = 1;
$widgets[ $areaKey ] = Area::create( $widget['default_area'], [ $widget ] );
}
else
{
$widgets[ $areaKey ]->addWidget( $widget );
}
}
}
if ( count( $widgets ) )
{
@@ -682,11 +658,14 @@ class Front extends Standard
/* Meta tags */
Output::i()->buildMetaTags();
/* Data Attributes */
Output::i()->setBodyAttributes();
/* Check MFA */
$this->checkMfa();
/* Check Alerts */
$this->checkAlerts();
static::checkAlerts( $this );
/* Loader Extension */
foreach( Application::allExtensions( 'core', 'Loader' ) as $loader )
@@ -888,7 +867,7 @@ FILE;
if ( !Request::i()->isAjax() )
{
Output::i()->globalControllers[] = 'core.front.core.app';
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() )
{
Output::i()->globalControllers[] = 'core.front.core.dataLayer';
}
@@ -965,11 +944,13 @@ FILE;
}
/**
* Check and process alerts for the logged-in member.
*
*
* @param Dispatcher $dispatcher The dispatcher instance controlling the current request.
*
* @return void
*/
public function checkAlerts() : void
public static function checkAlerts( Dispatcher $dispatcher ) : void
{
/* Don't get in the way of validating members */
if ( Member::loggedIn()->members_bitoptions['validating'] )
@@ -985,7 +966,7 @@ FILE;
/* Don't get in the way of the ModCP, registering, logging in, etc */
$ignoreControllers = [ 'modcp', 'register', 'login', 'redirect', 'cookies', 'lostpass' ];
if( !Request::i()->isAjax() and !in_array( $this->controller, $ignoreControllers ) AND $alert = Alert::getNextAlertForMember( Member::loggedIn() ) )
if( !Request::i()->isAjax() and !in_array( $dispatcher->controller, $ignoreControllers ) AND $alert = Alert::getNextAlertForMember( Member::loggedIn() ) )
{
$alert->viewed( Member::loggedIn() );
+4 -4
View File
@@ -183,7 +183,7 @@ abstract class Standard extends Dispatcher
catch ( OutOfRangeException $e )
{
$applications = Application::applications();
foreach( $applications as $application )
{
if( $application->default )
@@ -191,7 +191,7 @@ abstract class Standard extends Dispatcher
$this->application = $application;
}
}
if( !isset( $this->application ) )
{
$this->application = array_shift( $applications );
@@ -203,7 +203,7 @@ abstract class Standard extends Dispatcher
else
{
$applications = Application::applications();
foreach( $applications as $application )
{
if( $application->default )
@@ -211,7 +211,7 @@ abstract class Standard extends Dispatcher
$this->application = $application;
}
}
if( !isset( $this->application ) )
{
$this->application = array_shift( $applications );
+9 -3
View File
@@ -404,7 +404,7 @@ abstract class Email
*/
public function getAdvertisement( string $type='html' ): string
{
if( $advertisement = Advertisement::loadForEmail( $this->advertisementParams ) )
if( $this->type !== static::TYPE_TRANSACTIONAL and $advertisement = Advertisement::loadForEmail( $this->advertisementParams ) )
{
return $advertisement->toString( $type, $this );
}
@@ -1369,13 +1369,19 @@ abstract class Email
/**
* Makes HTML acceptable for use in emails
*
* @param string $text The text
* @param string|null $text The text
* @param Lang|null $language Language. If not provided, will use whatever is set in $this->language - provided for backwards compatibility with templates not sending one
* @param int|null $truncate NULL to not truncate, (int) to truncate to (int) chars
* @return string
*/
public function parseTextForEmail( string $text, Lang $language = NULL, int $truncate=NULL ): string
public function parseTextForEmail( ?string $text, Lang $language = NULL, int $truncate=NULL ): string
{
/* Text might be null because Item::content() can return null */
if( $text === null )
{
return '';
}
if ( $language === NULL )
{
$language = $this->language;
+12 -12
View File
@@ -16,7 +16,8 @@ use IPS\core\Advertisement;
use IPS\core\extensions\core\CommunityEnhancements\SendGrid as SendGridIntegration;
use IPS\Db;
use IPS\Email;
use IPS\Http\Request\Exception;
use IPS\Http\Request\Exception as RequestException;
use IPS\Email\Outgoing\Exception as EmailException;
use IPS\Http\Url;
use IPS\Lang;
use IPS\Member;
@@ -71,7 +72,7 @@ class SendGrid extends Email
* @param mixed $fromName The name the email should appear from. If NULL, default setting is used
* @param array $additionalHeaders The name the email should appear from. If NULL, default setting is used
* @return void
* @throws Exception
* @throws EmailException
*/
public function _send( mixed $to, mixed $cc=array(), mixed $bcc=array(), mixed $fromEmail = NULL, mixed $fromName = NULL, array $additionalHeaders = array() ) : void
{
@@ -129,7 +130,7 @@ class SendGrid extends Email
$response = $this->_api( 'mail/send', $request );
if ( isset( $response['errors'] ) )
{
throw new Exception( $response['errors'][0]['message'] );
throw new EmailException( $response['errors'][0]['message'] );
}
}
@@ -216,7 +217,7 @@ class SendGrid extends Email
{
$response = $this->_api( 'mail/send', $request );
}
catch(Exception $e )
catch(RequestException $e )
{
Db::i()->insert( 'core_mail_error_logs', array(
'mlog_date' => time(),
@@ -395,14 +396,13 @@ class SendGrid extends Email
return $this->_api( 'scopes' );
}
/**
* Parse/Save email form settings
*
* @param array $values settings array
* @return array
*/
public function saveSettings( array $values ): array
/**
* Parse/Save email form settings
*
* @param array $values settings array
* @return array
*/
public static function processSettings( array $values ): array
{
if ( isset( $values['mail_method'] ) and $values['mail_method'] != 'sendgrid' and Settings::i()->sendgrid_use_for == 2 )
{
+1 -1
View File
@@ -211,7 +211,7 @@ class Smtp extends Email
}
$recipientsForSmtp = array_unique( array_map( 'trim', $recipientsForSmtp ) );
if( empty( $additonalHeaders['Message-ID'] ) )
if( empty( $additionalHeaders['Message-ID'] ) )
{
$additionalHeaders['Message-ID'] = $this->generateMessageId();
}
@@ -17,6 +17,7 @@ namespace IPS\Events\ListenerType;
use IPS\Content as ContentClass;
use IPS\Content\Comment as CommentClass;
use IPS\Content\Item as ItemClass;
use IPS\core\Reports\Report;
use IPS\Events\ListenerType;
use IPS\Node\Model;
@@ -34,6 +35,8 @@ if (!defined('\IPS\SUITE_UNIQUE_KEY')) {
* @method onItemMove( ItemClass $item, Model $oldContainer, bool $keepLink = FALSE ) : void
* @method onCommentMove( CommentClass $comment, ItemClass $oldItem, bool $skip = FALSE ) : void
* @method onItemView( ItemClass $item ) : void
* @method onItemSplit( ItemClass $item, ItemClass $oldItem ) : void
* @method onReport ( ContentClass $object, Report $report ) : void
*/
class ContentListenerType extends ListenerType
{
@@ -41,6 +41,7 @@ if( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
* @method onRenew( InvoiceItem $item, PurchaseClass $purchase, int $cycles ) : void
* @method onTransfer( InvoiceItem $item, PurchaseClass $purchase, MemberClass $newCustomer ) : void
* @method onUnpaid( InvoiceItem $item, InvoiceClass $invoice, string $status ) : void
* @method onAddToInvoice( InvoiceItem $item, InvoiceClass $invoice ) : void
*/
class PackageListenerType extends ListenerType
{
@@ -36,4 +36,17 @@ abstract class AnnouncementsAbstract
* @return FormAbstract Form element
*/
abstract public function getSettingField( ?Announcement $announcement ): FormAbstract;
/**
* Parse the stored IDs into something that makes sense for the Announcement
* Default is a comma-separated string, but some locations may override
*
* @see Announcement::loadAllByLocation()
* @param string $ids
* @return array
*/
public function getAnnouncementIds( string $ids ) : array
{
return explode( ",", $ids );
}
}
+51 -2
View File
@@ -14,8 +14,10 @@ namespace IPS\Extensions;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Helpers\Badge;
use IPS\Helpers\Menu\Buttons;
use IPS\Helpers\Menu\MenuItem;
use IPS\Member;
use IPS\Member\Club;
use IPS\Member\Club\Page as ClubPage;
use IPS\Node\Model;
@@ -32,10 +34,10 @@ abstract class ClubAbstract
* Tabs
*
* @param Club $club The club
* @param Model|null $container Container
* @param Model|ClubPage|null $container Container
* @return array
*/
public function tabs( Club $club, ?Model $container = NULL ): array
public function tabs( Club $club, Model|ClubPage|null $container = NULL ): array
{
return [];
}
@@ -65,4 +67,51 @@ abstract class ClubAbstract
{
return [];
}
/**
* Return an array of badges to show on the club card and header
*
* @param Club $club
* @return array<Badge>
*/
public function badges( Club $club ) : array
{
return [];
}
/***
* Return an array of form fields that will be added to the club creation form
*
* @param Club|null $club
* @return array
*/
public function formElements( ?Club $club ) : array
{
return [];
}
/**
* Handle any custom fields that were added in the formElements method
* This is executed after the club has been saved
*
* @param Club $club
* @param array $values
* @param bool $new
* @return void
*/
public function saveForm( Club $club, array $values, bool $new=false ) : void
{
}
/**
* Custom filters for retrieving a list of clubs that are visible to a member
*
* @param Member|null $member
* @return array
*/
public function clubsWhere( ?Member $member ) : array
{
return [];
}
}
@@ -0,0 +1,86 @@
<?php
/**
* @brief MemberManagementAbstract
* @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
* @subpackage
* @since 10/10/2025
*/
namespace IPS\Extensions;
/* To prevent PHP errors (extending class does not exist) revealing path */
use Callback;
use IPS\Helpers\Table\Db as TableDb;
use IPS\Helpers\Table\Table;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
abstract class MemberACPManagementAbstract
{
/**
* Additional columns allowed in the member export.
* Return an array where the key is the column name
* and the value is a language string
*
* @return array<string,string>
*/
public function exportColumns() : array
{
return [];
}
/**
* Custom formatting for any columns defined in exportColumns
*
* @param string $column
* @param array $member
* @return string
*/
public function exportColumnValue( string $column, array $member ) : string
{
return $member[ $column ] ?? '';
}
/**
* Additional columns to show in the ACP member list
* Return an array where the key is the column name
* and the value is an optional callback method for @see Table::$parsers
*
* @return array<string,?Callback>
*/
public function acpColumns() : array
{
/* Example:
return array(
'column1' => null,
'column2' => function( $val, $row )
{
return '';
}
);*/
return [];
}
/**
* Additional tables to join on the ACP member list
* Format should match @see TableDb::$joins
* @note If you are including a column in acpColumns(), make sure
* you add it to the 'select' element of your join clause
*
* @return array
*/
public function acpJoins() : array
{
return [];
}
}
@@ -82,4 +82,17 @@ abstract class OverviewStatisticsAbstract
return null;
}
/**
* Note: this method must be overridden in extension instances. Returning an empty array will exclude this block from the CSV download.
*
* @param null|array|string $dateRange=null
* @param null|string $subBlock=null
*
* @return array|null
*/
public function getBlockNumbers( array|string $dateRange = null, string $subBlock = null ) : array|null
{
return null;
}
}
+61 -1
View File
@@ -13,6 +13,12 @@
namespace IPS\Extensions;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Content\Item;
use IPS\Content\Permissions;
use IPS\Member;
use IPS\Node\Model;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
@@ -26,5 +32,59 @@ abstract class PermissionsAbstract
*
* @return array
*/
abstract public function getNodeClasses(): array;
public function getNodeClasses() : array
{
return [];
}
/**
* Override individual permissions for specific items
* Return one of @see Permissions::PERM_ALLOW, Permissions::PERM_DENY, Permissions::PERM_DEFAULT
* PERM_DEFAULT will essentially do nothing and default to the standard permission checks.
* PERM_ALLOW and PERM_DENY will force an override.
*
* @note Check the object class before executing your custom logic.
* You MUST return PERM_DEFAULT for any object that you are not checking.
*
* @param string $permission
* @param object $object
* @param Member|null $member
* @return int
*/
public function checkPermission( string $permission, object $object, ?Member $member=null ) : int
{
return Permissions::PERM_DEFAULT;
}
/**
* Returns an array of node IDs with the permission equivalent of @see Permissions::PERM_ALLOW
* Used in queries that retrieve nodes and/or items.
*
* @see Model::nodesWithPermission()
* @see Item::getItemsWithPermission()
* @param string $permission
* @param string $class
* @param Member|null $member
* @return array
*/
public function nodeIdsToAllow( string $permission, string $class, ?Member $member=null ) : array
{
return [];
}
/**
* Returns an array of node IDs with the permission equivalent of @see Permissions::PERM_DENY
* Used in queries that retrieve nodes and/or items.
*
* @see Model::nodesWithPermission()
* @see Item::getItemsWithPermission()
* @param string $permission
* @param string $class
* @param Member|null $member
* @return array
*/
public function nodeIdsToDeny( string $permission, string $class, ?Member $member=null ) : array
{
return [];
}
}
+44
View File
@@ -14,8 +14,10 @@ namespace IPS\Extensions;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Content;
use IPS\core\Rss\Import;
use IPS\Helpers\Form;
use IPS\Node\Model;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
@@ -30,6 +32,39 @@ abstract class RssImportAbstract
*/
public array $classes = array();
/**
* @brief FileStorage extension to use
*/
public string $fileStorage = '';
/**
* Show in the Admin CP?
*
* @return boolean
*/
public function showInAdminCp(): bool
{
return true;
}
/**
* Return available options for a Form\Select
*
* @return array
*/
public function availableOptions(): array
{
return array();
}
/**
* Node selector options
*
* @param Import|null $rss Existing RSS object if editing|null if not
* @return array
*/
abstract public function nodeSelectorOptions( ?Import $rss ): array;
/**
* Display Form
*
@@ -47,4 +82,13 @@ abstract class RssImportAbstract
* @return array
*/
abstract public function saveForm( array &$values, Import $rss ) : array;
/**
* @param Import $rss RSS object
* @param array $article RSS feed article importing
* @param Model $container Container object
* @param string $content Post content with read more link if set
* @return Content|null
*/
abstract public function create( Import $rss, array $article, Model $container, string $content ): ?Content;
}
+7 -3
View File
@@ -77,9 +77,13 @@ abstract class File
{
$return['Database'] = 'IPS\\File\\Database';
}
$return['Amazon'] = 'IPS\\File\\Amazon';
$return['Backblaze'] = 'IPS\\File\\Backblaze';
if ( $current and $current['method'] === 'Backblaze' )
{
$return['Backblaze'] = 'IPS\\File\\Backblaze';
}
if ( $current and $current['method'] === 'Ftp' )
{
@@ -1278,7 +1282,7 @@ abstract class File
{
$copyFiles = static::$copyFiles;
static::$copyFiles = TRUE;
$return = static::create( $this->storageExtension, $this->originalFilename, $this->contents(), $this->container, $this instanceof FileSystem ? ( $this->configuration['dir'] . '/' . ( $this->container ? "{$this->container}/" : '' ) . $this->filename ) : true );
$return = static::create( $this->storageExtension, $this->originalFilename, $this->contents(), $this->container, false, $this instanceof FileSystem ? ( $this->configuration['dir'] . '/' . ( $this->container ? "{$this->container}/" : '' ) . $this->filename ) : null );
static::$copyFiles = $copyFiles;
return $return;
}
+83 -115
View File
@@ -52,62 +52,50 @@ class Mapbox extends GeoCoder
$obj = new GeoLocation;
$_address = '';
/* Make sure the response from Mapbox is valid */
if( isset( $data['results'] ) AND is_array( $data['results'] ) AND count( $data['results'] ) )
if( !empty( $data['features'] ) )
{
if( isset( $data['results'][0]['geometry'] ) AND isset( $data['results'][0]['geometry']['coordinates'] ) )
if( !empty( $data['features'][0]['geometry'] ) )
{
$obj->long = $data['results'][0]['geometry']['coordinates'][0];
$obj->lat = $data['results'][0]['geometry']['coordinates'][1];
$obj->long = $data['features'][0]['geometry']['coordinates'][0];
$obj->lat = $data['features'][0]['geometry']['coordinates'][1];
}
$obj->placeName = $data['results'][0]['place_name'];
$obj->placeName = $data['features'][0]['place_name'];
foreach( $data['results'][0]['address_components'] as $component )
/* If we are at address level, the address will not be in a property */
if( $data['features'][0]['place_type'][0] == 'address' )
{
if( $component['types'][0] == 'street_number' )
{
$_address = $component['long_name'];
}
elseif( $component['types'][0] == 'route' )
{
$_address .= " " . $component['long_name'];
}
$obj->addressLines = [ $data['features'][0]['address'] . ' ' . $data['features'][0]['text'] ];
}
if( $component['types'][0] == 'postal_code' )
foreach( $data['features'][0]['context'] as $property )
{
$propertyType = mb_substr( $property['id'], 0, mb_strpos( $property['id'], '.' ) );
switch( $propertyType )
{
$obj->postalCode = $component['long_name'];
}
case 'locality':
$obj->city = $property['text'];
break;
if( $component['types'][0] == 'country' )
{
$obj->country = $component['short_name'];
}
case 'postcode':
$obj->postalCode = $property['text'];
break;
if( $component['types'][0] == 'administrative_area_level_1' )
{
$obj->region = $component['long_name'];
}
case 'country':
$obj->country = $property['text'];
break;
if( $component['types'][0] == 'administrative_area_level_2' )
{
$obj->county = $component['long_name'];
}
case 'region':
$obj->region = $property['text'];
break;
if( $component['types'][0] == 'locality' )
{
$obj->city = $component['long_name'];
case 'district':
$obj->county = $property['text'];
break;
}
}
}
if( $_address )
{
$obj->addressLines = array( $_address );
}
return $obj;
}
else
@@ -138,54 +126,44 @@ class Mapbox extends GeoCoder
$obj->lat = $lat;
$obj->long = $long;
$_address = '';
/* Make sure the response from Mapbox is valid */
if( isset( $data['results'] ) AND is_array( $data['results'] ) AND count( $data['results'] ) )
if( !empty( $data['features'] ) )
{
foreach( $data['results'][0]['address_components'] as $component )
$obj->placeName = $data['features'][0]['place_name'];
/* If we are at address level, the address will not be in a property */
if( $data['features'][0]['place_type'][0] == 'address' )
{
if( $component['types'][0] == 'street_number' )
{
$_address = $component['long_name'];
}
elseif( $component['types'][0] == 'route' )
{
$_address .= " " . $component['long_name'];
}
$obj->addressLines = [ $data['features'][0]['address'] . ' ' . $data['features'][0]['text'] ];
}
if( $component['types'][0] == 'postal_code' )
foreach( $data['features'][0]['context'] as $property )
{
$propertyType = mb_substr( $property['id'], 0, mb_strpos( $property['id'], '.' ) );
switch( $propertyType )
{
$obj->postalCode = $component['long_name'];
}
case 'locality':
$obj->city = $property['text'];
break;
if( $component['types'][0] == 'country' )
{
$obj->country = $component['short_name'];
}
case 'postcode':
$obj->postalCode = $property['text'];
break;
if( $component['types'][0] == 'administrative_area_level_1' )
{
$obj->region = $component['long_name'];
}
case 'country':
$obj->country = $property['text'];
break;
if( $component['types'][0] == 'administrative_area_level_2' )
{
$obj->county = $component['long_name'];
}
case 'region':
$obj->region = $property['text'];
break;
if( $component['types'][0] == 'locality' )
{
$obj->city = $component['long_name'];
case 'district':
$obj->county = $property['text'];
break;
}
}
}
if( $_address )
{
$obj->addressLines = array( $_address );
}
return $obj;
}
else
@@ -229,49 +207,39 @@ class Mapbox extends GeoCoder
if( $setAddress === TRUE )
{
if( isset( $data['results'] ) AND is_array( $data['results'] ) AND count( $data['results'] ) )
$geoLocation->placeName = $data['features'][0]['place_name'];
/* If we are at address level, the address will not be in a property */
if( $data['features'][0]['place_type'][0] == 'address' )
{
foreach( $data['results'][0]['address_components'] as $component )
{
if( $component['types'][0] == 'street_number' )
{
$_address = $component['long_name'];
}
elseif( $component['types'][0] == 'route' )
{
$_address .= " " . $component['long_name'];
}
if( $component['types'][0] == 'postal_code' )
{
$geoLocation->postalCode = $component['long_name'];
}
if( $component['types'][0] == 'country' )
{
$geoLocation->country = $component['short_name'];
}
if( $component['types'][0] == 'administrative_area_level_1' )
{
$geoLocation->region = $component['long_name'];
}
if( $component['types'][0] == 'administrative_area_level_2' )
{
$geoLocation->county = $component['long_name'];
}
if( $component['types'][0] == 'locality' )
{
$geoLocation->city = $component['long_name'];
}
}
$geoLocation->addressLines = [ $data['features'][0]['address'] . ' ' . $data['features'][0]['text'] ];
}
if( $_address )
foreach( $data['features'][0]['context'] as $property )
{
$geoLocation->addressLines = array( $_address );
$propertyType = mb_substr( $property['id'], 0, mb_strpos( $property['id'], '.' ) );
switch( $propertyType )
{
case 'locality':
$geoLocation->city = $property['text'];
break;
case 'postcode':
$geoLocation->postalCode = $property['text'];
break;
case 'country':
$geoLocation->country = $property['text'];
break;
case 'region':
$geoLocation->region = $property['text'];
break;
case 'district':
$geoLocation->county = $property['text'];
break;
}
}
}
}
+3
View File
@@ -92,6 +92,9 @@ class Mapbox
$location = str_replace( ',', '.', $this->geolocation->long ) . ',' . str_replace( ',', '.', $this->geolocation->lat );
/* Leaving zoom null breaks the map */
$zoom = $zoom ?? 13;
return Url::external( "https://api.mapbox.com/styles/v1/mapbox/streets-v10/static/pin-l-marker+f00({$location})/{$location},{$zoom},0,60/{$width}x{$height}@2x" )->setQueryString( array(
'access_token' => Settings::i()->mapbox_api_key
) );
+31 -2
View File
@@ -12,6 +12,7 @@ namespace IPS\Helpers\Chart;
/* To prevent PHP errors (extending class does not exist) revealing path */
use DateInterval;
use DateTimeZone;
use Exception;
use IPS\DateTime;
@@ -153,8 +154,36 @@ class Callback extends Dynamic
{
$result[ $column ] = $row[ $column ];
}
$data[ $row['time'] ] = $result;
$time = $row['time'];
// UNIX timestamps are sometimes passed in. In this case, we try to make sure the timestamp format matches the defaults added via $this->initData()
if ( is_int( $time ) )
{
$date = DateTime::ts( $time );
switch ( $this->timescale )
{
case 'hourly':
$time = $date->format( 'Y-n-j-h-i-s' );
break;
case 'daily':
$time = $date->format( 'Y-n-j' );
break;
case 'weekly':
/* o is the ISO year number, which we need when years roll over.
@see http://php.net/manual/en/function.date.php#106974 */
$time = $date->format( 'o-W' );
break;
case 'monthly':
$time = $date->format( 'Y-n' );
break;
}
}
$data[ $time ] = $result;
}
ksort( $data, SORT_NATURAL );
+5 -2
View File
@@ -230,7 +230,10 @@ class Database extends Dynamic
$this->groupBy = $this->customFiltersForm['groupBy'];
$whereFunctionResult = $whereFunction( $this->savedCustomFilters );
$this->where[] = is_array( $whereFunctionResult ) ? $whereFunctionResult : array( $whereFunctionResult );
if( !empty( $whereFunctionResult ) )
{
$this->where[] = is_array( $whereFunctionResult ) ? $whereFunctionResult : array( $whereFunctionResult );
}
foreach( $seriesFunction( $this->savedCustomFilters ) as $series )
{
@@ -379,7 +382,7 @@ class Database extends Dynamic
foreach ( $stmt as $k => $v )
{
if( count( $this->availableFilters ) and !in_array( $k, $this->currentFilters ) )
if( !in_array( $k, $this->currentFilters ) )
{
continue;
}
+160 -17
View File
@@ -21,7 +21,9 @@ use IPS\Db;
use IPS\Helpers\Chart;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Date;
use IPS\Helpers\Form\Select;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\YesNo;
use IPS\Http\Url;
use IPS\IPS;
use IPS\Member;
@@ -30,12 +32,19 @@ use IPS\Request;
use IPS\Session;
use IPS\Theme;
use Throwable;
use UnderflowException;
use function array_merge;
use function defined;
use function explode;
use function file_get_contents;
use function in_array;
use function is_array;
use function is_numeric;
use function is_string;
use function iterator_to_array;
use function json_encode;
use function mb_substr;
use function var_export;
use const IPS\TEMP_DIRECTORY;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
@@ -183,6 +192,13 @@ abstract class Dynamic extends Chart
* @brief Prefix passed to tables
*/
public string $tableLangPrefix = '';
/**
* When downloading chart collections from the saved reports page, this indicates the type that the chart should load as by default
*
* @var string|null
*/
public static null|string $mergedChartType = null;
/**
* Constructor
@@ -200,12 +216,38 @@ abstract class Dynamic extends Chart
*/
public function __construct( Url $url, string $title='', array $options=array(), string $defaultType='AreaChart', string $defaultTimescale='monthly', array $defaultTimes=array( 'start' => 0, 'end' => 0 ), string $identifier='', DateTime $minimumDate=null )
{
if ( isset( static::$mergedChartType ) )
{
$defaultType = static::$mergedChartType;
}
/* If we are deleting a chart, just do that now and redirect */
if( isset( Request::i()->deleteChart ) )
{
Session::i()->csrfCheck();
Db::i()->delete( 'core_saved_charts', array( 'chart_id=? and chart_member=?', Request::i()->deleteChart, Member::loggedIn()->member_id ) );
Output::i()->redirect( Request::i()->url()->stripQueryString( array( 'deleteChart', 'chartId', 'csrfKey' ) ), 'chart_deleted' );
$where = array( 'chart_id=? and (chart_member IS NULL OR chart_member=0 OR chart_member=?)', Request::i()->deleteChart, Member::loggedIn()->member_id );
try
{
$reportID = Db::i()->select( "chart_report_id", "core_saved_charts", $where )->first();
}
catch ( UnderflowException )
{
$reportID = null;
}
Db::i()->delete( 'core_saved_charts', $where );
$url = Request::i()->url()->stripQueryString( array( 'deleteChart', 'chartId', 'csrfKey', 'wasConfirmed' ) );
if ( isset( $reportID ) )
{
$url = $url->setQueryString( "report_id", $reportID );
}
if ( Request::i()->do === 'getChart' )
{
$url = $url->stripQueryString([ 'do' ]);
}
Output::i()->redirect( $url, 'chart_deleted' );
}
if ( !isset( $options['chartArea'] ) )
@@ -351,7 +393,7 @@ abstract class Dynamic extends Chart
}
/* If we have requested a saved chart, load its filters */
if( isset( Request::i()->chartId ) AND Request::i()->chartId != '_default' AND !isset( Request::i()->filters ) )
if( isset( Request::i()->chartId ) AND is_numeric( Request::i()->chartId ) AND !isset( Request::i()->filters ) )
{
foreach( $this->loadAvailableChartTabs() as $chart )
{
@@ -414,7 +456,7 @@ abstract class Dynamic extends Chart
try
{
/* Generate a form so we can save our filters as a new saved chart */
$this->form = new Form;
$this->form = new Form( 'form_' . $this->identifier );
$this->form->class = 'ipsForm--vertical ipsForm--chart-custom-filters';
if ( $this->customFiltersForm )
@@ -429,18 +471,18 @@ abstract class Dynamic extends Chart
}
/* If we have a sub selector from a node form, add on the identifier and pass off to the form to show the ajax */
if ( Request::i()->_nodeSelectName )
if ( isset( Request::i()->_nodeSelectName ) and Request::i()->_nodeSelectName )
{
Request::i()->_nodeSelectName = $this->identifier . Request::i()->_nodeSelectName;
return (string) $this->getCustomFiltersForm();
}
/* We have an existing chart ID */
if( isset( Request::i()->chartId ) AND Request::i()->chartId != '_default' )
if( isset( Request::i()->chartId ) AND is_numeric( Request::i()->chartId ) )
{
$title = '';
foreach( $this->loadAvailableChartTabs() as $chart )
foreach( $this->loadSavedCharts() as $chart )
{
if( $chart['chart_id'] == Request::i()->chartId )
{
@@ -451,7 +493,7 @@ abstract class Dynamic extends Chart
$custom = array();
$chartFilters = ( isset( Request::i()->chartFilters ) and Request::i()->chartFilters ) ? Request::i()->chartFilters : array();
$this->timescale = ( isset( Request::i()->timescale ) and Request::i()->timescale[ $this->identifier ] ) ? Request::i()->timescale[ $this->identifier ] : $chart['chart_timescale'] ?? 'monthly';
$this->timescale = ( isset( Request::i()->timescale ) and in_array( Request::i()->timescale[ $this->identifier ], ['hourly', 'daily', 'monthly', 'weekly'] ) ) ? Request::i()->timescale[ $this->identifier ] : $chart['chart_timescale'] ?? 'monthly';
foreach( Request::i() as $key => $value )
@@ -483,7 +525,45 @@ abstract class Dynamic extends Chart
/* We are not viewing a saved chart */
else
{
$this->form->add( new Text( 'custom_chart_title', NULL, TRUE ) );
Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'admin_stats.js', 'core' ) );
Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'system/statistics.css', 'core', 'admin' ) );
Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'system/reports.css', 'core', 'admin' ) );
$reports = iterator_to_array( Db::i()->select( "*", "core_reports", order: "report_title ASC" ) );
$reportOptions = [];
$defaultOption = false;
foreach( $reports as $row )
{
$defaultOption = $defaultOption ?: "report_" . $row["id"];
$reportOptions["report_" . $row['id']] = $row['report_title'];
}
$noTabs = (bool) $this->extension?->noTabs;
$this->form->add( new YesNo( "statsreports_show_all_members", $noTabs, false, ['disabled' => $noTabs] ) );
$globalTogglesOn = ['statsreports_report_select'];
if ( empty( $reports ) )
{
$globalTogglesOn[] = "statsreports_report_new_title";
}
$this->form->add( new YesNo( "statsreports_include_in_report", false, false, [ "togglesOn" => $globalTogglesOn ] ) );
// if there are saved reports, allow user to select one of them
if ( !empty( $reportOptions ) )
{
// todo an autocomplete field is probably a better UX when there could be dozens of reports saved
$this->form->add( new Select( "statsreports_report", $defaultOption, false, [
"unlimited" => false,
"unlimitedLang" => "statsreports_report_new",
"unlimitedToggles" => [
'statsreports_report_new_title'
],
"options" => $reportOptions,
], id: 'statsreports_report_select' ) );
}
$this->form->add( new Text( "statsreports_report_new_title", $this->title, null, id: "statsreports_report_new_title" ) );
$this->form->add( new Text( 'custom_chart_title', $this->title, true ) );
if( $values = $this->form->values() )
{
@@ -498,14 +578,37 @@ abstract class Dynamic extends Chart
$chartFilters = ( isset( Request::i()->chartFilters ) and Request::i()->chartFilters ) ? Request::i()->chartFilters : array();
/* Store the new chart */
$id = Db::i()->insert( 'core_saved_charts', array(
'chart_member' => Member::loggedIn()->member_id,
'chart_controller' => Request::i()->app . '_' . Request::i()->module . '_' . Request::i()->controller . ( Request::i()->tab ? '_' . Request::i()->tab : '' ),
// is it saved to a report?
$reportID = null;
if ( @$values['statsreports_include_in_report'] )
{
if ( isset( $values['statsreports_report'] ) and $values['statsreports_report'] )
{
$reportID = (int) explode( '_', $values['statsreports_report'], 2 )[1];
}
else
{
$title = $values['statsreports_report_new_title'];
$reportID = Db::i()->insert( "core_reports", [ "report_title" => $title] );
}
}
if ( $noTabs )
{
$values['statsreports_show_all_members'] = true;
}
$data = array(
'chart_member' => @$values['statsreports_show_all_members'] ? 0 : Member::loggedIn()->member_id, // we make the member "0" when the show to all members option is enabled
'chart_controller' => $this->getController(),
'chart_configuration' => json_encode( array_merge( $chartFilters, $custom ) ),
'chart_timescale' => Request::i()->timescale ?? $this->timescale ?? 'monthly',
'chart_title' => $values['custom_chart_title'],
) );
'chart_report_id' => $reportID
);
/* Store the new chart */
$id = Db::i()->insert( 'core_saved_charts', $data );
/* Set some input parameters */
$this->url = $this->url->setQueryString( 'chartId', $id );
@@ -526,6 +629,7 @@ abstract class Dynamic extends Chart
/* And then return the output we need */
Output::i()->json( array(
'reportURL' => ($data['chart_member'] and !$data['chart_report_id']) ? null : Url::internal( "app=core&module=overview&controller=mycharts&highlight={$id}" . ( $data['chart_report_id'] ? "&report_id={$data['chart_report_id']}" : "" ) ),
'tabHref' => $this->url->stripQueryString( 'filters' ),
'chartId' => $id,
'tabId' => md5( $this->url->acpQueryString() ),
@@ -576,6 +680,21 @@ abstract class Dynamic extends Chart
}
}
/**
* Get the "controller" to use when saving this chart
*
* @return string
*/
public function getController() : string
{
if ( is_string( $this->extension?->controller ) )
{
return $this->extension->controller;
}
return Request::i()->app . '_' . Request::i()->module . '_' . Request::i()->controller . ( Request::i()->tab ? '_' . Request::i()->tab : '' );
}
/**
* @brief Show filter tabs
*/
@@ -586,6 +705,8 @@ abstract class Dynamic extends Chart
*/
protected ?array $availableChartTabs = NULL;
protected ?array $savedCharts = null;
/**
* @Brief Extension
*/
@@ -617,19 +738,35 @@ abstract class Dynamic extends Chart
{
if ( $this->availableChartTabs === null )
{
if ( $this->extension !== null )
if ( $this->extension?->noTabs )
{
$this->availableChartTabs = iterator_to_array( Db::i()->select( '*', 'core_saved_charts', ['chart_member=? AND chart_controller=?', Member::loggedIn()->member_id, $this->extension->controller] ) );
$this->availableChartTabs = [];
}
else
{
$this->availableChartTabs = iterator_to_array( Db::i()->select( '*', 'core_saved_charts', ['chart_member=? AND chart_controller=?', Member::loggedIn()->member_id, Request::i()->app . '_' . Request::i()->module . '_' . Request::i()->controller . ( Request::i()->tab ? '_' . Request::i()->tab : '' )] ) );
$this->availableChartTabs = iterator_to_array( Db::i()->select( '*', 'core_saved_charts', ['chart_member=? AND chart_controller=? AND chart_report_id IS NULL', Member::loggedIn()->member_id, $this->getController()] ) );
}
}
return $this->availableChartTabs;
}
/**
* Load and return available chart tabs
*
* @return array|null
*/
protected function loadSavedCharts() : ?array
{
if ( $this->savedCharts === null )
{
$this->savedCharts = iterator_to_array( Db::i()->select( '*', 'core_saved_charts', ['(chart_member=? OR chart_member=0 OR chart_member IS NULL) AND chart_controller=?', Member::loggedIn()->member_id, $this->getController()] ) );
}
return $this->savedCharts;
}
/**
* Set extension
*
@@ -672,6 +809,12 @@ abstract class Dynamic extends Chart
*/
protected function initData(): array
{
/* The JS can set this to "undefined" so let's make sure it's a valid value */
if ( !in_array( $this->timescale, ["none", "hourly", "daily", "monthly", "weekly"] ) )
{
$this->timescale = "monthly";
}
/* Init data */
$data = array();
if ( $this->start AND $this->timescale !== 'none' )
@@ -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' )
{
+1 -1
View File
@@ -129,7 +129,7 @@ class Link extends MenuItem
* @param string|NULL $id
* @param string $identifier
*/
public function __construct( Url|string $url, string $languageString, string $css ='ipsMenu_item', array $dataAttributes = [], bool $opensDialog = FALSE, ?string $icon = NULL, string $id = NULL, string $identifier = '' )
public function __construct( Url|string $url, string $languageString, string $css ='iDropdown__li', array $dataAttributes = [], bool $opensDialog = FALSE, ?string $icon = NULL, string $id = NULL, string $identifier = '' )
{
$this->url = $url;
$this->title = $languageString;
+9 -6
View File
@@ -34,7 +34,7 @@ class Menu
/**
* Menu Elements(items)
*
* @var array
* @var MenuItem[]
*/
public array $elements = [];
@@ -86,7 +86,8 @@ class Menu
public ?string $tooltip = NULL;
/**
* @breif If this is true, menus with only one element will show as a button
* @brief If this is true, menus with only one element will show as a button
*
* @var bool
*/
public bool $shrinkToButton = true;
@@ -125,7 +126,6 @@ class Menu
$elements = [];
foreach( $this->elements as $key => $element )
{
/* @var MenuItem $element */
$elements[ $key ] = $element;
if( $element->menuItem == $after )
{
@@ -190,6 +190,7 @@ class Menu
/**
* Adds HTML code to the stack
*
* @param string $html
* @return $this
*/
@@ -202,7 +203,7 @@ class Menu
/**
* Returns all menu elements
*
* @return array
* @return array<MenuItem>
*/
public function getLinks(): array
{
@@ -211,6 +212,7 @@ class Menu
/**
* Returns a row wrapper which can contain custom HTML code
*
* @param string $content
* @return string
*/
@@ -251,6 +253,7 @@ class Menu
/**
* Returns the parsed menu
*
* @return string
*/
public function __toString(): string
@@ -275,10 +278,10 @@ class Menu
/**
* Move an item to the top of the menu
*
* @param $item
* @param MenuItem $item
* @return void
*/
public function moveToStart( $item ): void
public function moveToStart( MenuItem $item ): void
{
$currentElements = $this->elements;
$newElements = [];
+21 -3
View File
@@ -214,11 +214,10 @@ class Content extends Table
$this->where[] = array($class::$databaseTable . '.' . $class::$databasePrefix . $class::$databaseColumnMap['container'] . '=?', $container->_id);
}
$this->sortDirection = ( $this->sortDirection !== NULL ) ? $this->sortDirection : $container->_sortOrder;
if ( !$this->sortBy and ! empty( $container->_sortBy ) )
{
$this->sortBy = $class::$databaseTable . '.' . $class::$databasePrefix . $container->_sortBy;
$this->sortDirection = $container->_sortOrder;
}
if ( !$this->filter )
{
@@ -264,7 +263,7 @@ class Content extends Table
}
/* Set available sort options */
foreach ( array( 'updated', 'last_comment', 'title', 'rating', 'date', 'num_comments', 'num_reviews', 'views' ) as $k )
foreach ( array( 'updated', 'last_comment', 'title', 'rating', 'date', 'num_comments', 'num_reviews', 'views', 'num_helpful' ) as $k )
{
if ( isset( $class::$databaseColumnMap[ $k ] ) and !isset( $this->sortOptions[ $k ] ) )
{
@@ -395,6 +394,25 @@ class Content extends Table
/* @var Item $class */
/* @var array $databaseColumnMap */
$sortBy = $this->sortBy . ' ' . ( mb_strtolower( $this->sortDirection ) == 'asc' ? 'asc' : 'desc' );
/* If we are sorting by rating, we want to make sure we take into account the number of ratings */
if( array_key_exists( 'rating', $this->sortOptions ) and $this->sortBy == $class::$databaseTable . '.' . $class::$databasePrefix . $class::$databaseColumnMap['rating'] )
{
if( isset( $class::$databaseColumnMap['rating_average'] ) )
{
$sortBy = $class::$databaseTable . '.' . $class::$databasePrefix . $class::$databaseColumnMap['rating_average'] . ' ' . ( mb_strtolower( $this->sortDirection ) == 'asc' ? 'asc' : 'desc' );
}
if( isset( $class::$databaseColumnMap['rating_hits'] ) )
{
$sortBy .= ',' . $class::$databaseTable . '.' . $class::$databasePrefix . $class::$databaseColumnMap['rating_hits'] . ' ' . ( mb_strtolower( $this->sortDirection ) == 'asc' ? 'asc' : 'desc' );
}
elseif( isset( $class::$databaseColumnMap['num_reviews'] ) )
{
$sortBy .= ',' . $class::$databaseTable . '.' . $class::$databasePrefix . $class::$databaseColumnMap['num_reviews'] . ' ' . ( mb_strtolower( $this->sortDirection ) == 'asc' ? 'asc' : 'desc' );
}
}
if ( IPS::classUsesTrait( $class, 'IPS\Content\Pinnable' ) and $this->honorPinned )
{
$column = $class::$databaseTable . '.' . $class::$databasePrefix . $class::$databaseColumnMap['pinned'];
+34 -27
View File
@@ -208,7 +208,7 @@ class Db extends Table
{
unset( $advancedSearchValues[ $k ] );
}
break;
break;
case SEARCH_DATE_RANGE:
$timezone = ( Member::loggedIn()->timezone ? new DateTimeZone( Member::loggedIn()->timezone ) : NULL );
@@ -261,37 +261,44 @@ class Db extends Table
break;
case SEARCH_NODE:
$nodeClass = $options['class'];
$prop = $options['searchProp'] ?? '_id';
if ( !is_array( $v ) )
if( $v )
{
$v = array( $v );
}
$values = array();
foreach ( $v as $_v )
{
if ( !is_object( $_v ) )
$nodeClass = $options[ 'class' ];
$prop = $options[ 'searchProp' ]??'_id';
if( !is_array( $v ) )
{
if ( mb_substr( $_v, 0, 2 ) === 's.' )
{
$nodeClass = $nodeClass::$subnodeClass;
$_v = mb_substr( $_v, 2 );
}
try
{
$_v = $nodeClass::load( $_v );
}
catch ( OutOfRangeException $e )
{
continue;
}
$v = [$v];
}
$values[] = $_v->$prop;
$values = [];
foreach( $v as $_v )
{
if( !is_object( $_v ) )
{
if( mb_substr( $_v, 0, 2 ) === 's.' )
{
$nodeClass = $nodeClass::$subnodeClass;
$_v = mb_substr( $_v, 2 );
}
try
{
$_v = $nodeClass::load( $_v );
}
catch( OutOfRangeException $e )
{
continue;
}
}
$values[] = $_v->$prop;
}
$where[] = [$this->db->in( $k, $values )];
}
$where[] = array( $this->db->in( $k, $values ) );
else
{
unset( $advancedSearchValues[ $k ] );
}
break;
case SEARCH_NUMERIC:
case SEARCH_NUMERIC_TEXT:
switch ( $v[0] )
+1 -1
View File
@@ -201,7 +201,7 @@ class Internal extends Url
/* Couldn't find one? Don't accept responsibility */
catch (OutOfRangeException $e ){}
}
else if( $return !== NULL )
else if( $return !== NULL and !( $return instanceof Friendly ) )
{
$return = TRUE;
}
+2
View File
@@ -244,6 +244,8 @@ class Gd extends Image
}
break;
case 'avif':
case 'webp':
case 'png':
case 'jpg':
/* We need to fill the background as transparent. If we copy a watermark image here (resizing it down for instance) and it has
+66 -37
View File
@@ -47,6 +47,13 @@ use function get_declared_classes;
use function in_array;
use function intval;
use function is_array;
use function is_bool;
use function is_callable;
use function is_double;
use function is_float;
use function is_int;
use function is_null;
use function is_object;
use function is_string;
use function preg_replace_callback;
use function strlen;
@@ -334,26 +341,29 @@ class Lang extends Model
$values[ $lang->id ] = $default;
}
}
else
{
if ( count( $values ) == 0 )
{
return;
}
foreach ( static::languages() as $lang )
{
if ( !isset( $values[ $lang->id ] ) )
{
$values[ $lang->id ] = '';
}
else if ( $lang->default AND $default === NULL )
{
$default = $values[ $lang->id ];
}
}
}
elseif( $default === null )
{
$default = $values[ static::defaultLanguage() ] ?? null;
}
if ( count( $values ) == 0 )
{
return;
}
$currentValues = iterator_to_array( Db::i()->select( '*', 'core_sys_lang_words', array( 'word_key=?', $key ) )->setKeyField('lang_id') );
/* Go through all the languages. If something is not set, it's disabled.
In that case we only want to update it if the row does not exist; so we use the default value.
If the row already existed, leave it alone. */
foreach( static::languages() as $lang )
{
if( !isset( $values[ $lang->id ] ) and !isset( $currentValues[ $lang->id ] ) )
{
$values[ $lang->id ] = $default;
}
}
foreach ( $values as $langId => $value )
{
if ( isset( $currentValues[ $langId ] ) )
@@ -800,7 +810,7 @@ class Lang extends Model
}
else
{
$keysToLoad[] = "'" . Db::i()->real_escape_string( $k ) . "'";
$keysToLoad[] = $k;
}
}
@@ -816,15 +826,23 @@ class Lang extends Model
return $this->words[ $key ];
}
$keysToLoad = array( "'" . Db::i()->real_escape_string( $key ) . "'" );
$keysToLoad[] = $key;
}
foreach(Db::i()->select( 'word_key, word_default, word_custom', 'core_sys_lang_words', array( "lang_id=? AND word_key IN(" . implode( ",", $keysToLoad ) . ")", ( $this->id ?? static::defaultLanguage() ) ) ) as $lang )
foreach( Db::i()->select( 'word_key, word_default, word_custom,lang_id', 'core_sys_lang_words',
[
[ Db::i()->in( 'lang_id', array_unique( array_filter( [ $this->id, static::defaultLanguage() ] ) ) ) ],
[ Db::i()->in( 'word_key', $keysToLoad ) ],
],
) as $lang )
{
$value = $lang['word_custom'] ?: $lang['word_default'];
if( $lang['lang_id'] == $this->id or !isset( $this->words[ $lang['word_key'] ] ) )
{
$value = $lang['word_custom'] ?: $lang['word_default'];
$this->words[ $lang['word_key'] ] = $value;
$return[ $lang['word_key' ] ] = $value;
$this->words[ $lang['word_key'] ] = $value;
$return[ $lang['word_key' ] ] = $value;
}
}
/* If we're using an array, fill any missings strings with NULL to prevent duplicate queries */
@@ -861,7 +879,10 @@ class Lang extends Model
/* Setup? */
if( $this->wordsLoaded === TRUE )
{
return ( isset( $this->words[ $key ] ) ) ? $this->words[ $key ] : $key;
if( isset( $this->words[ $key ] ) )
{
return $this->words[ $key ];
}
}
/* Get it */
@@ -1771,21 +1792,29 @@ class Lang extends Model
}
/* Parse out lang */
$keys = array();
$keys = [];
foreach( $this->outputStack as $word => $values )
{
if( !isset( $this->words[ $values['key'] ] ) )
{
$keys[] = "'" . Db::i()->real_escape_string( $values['key'] ) . "'";
$keys[] = $values['key'];
}
}
if( !$this->wordsLoaded === TRUE AND count( $keys ) and !IN_DEV)
if( !$this->wordsLoaded === TRUE AND count( $keys ) and !IN_DEV )
{
foreach(Db::i()->select( 'word_key, word_default, word_custom', 'core_sys_lang_words', array( "lang_id=? AND word_key IN(" . implode( ",", $keys ) . ") and word_js=0", $this->id ) ) as $row )
foreach( Db::i()->select( 'word_key, word_default, word_custom,lang_id', 'core_sys_lang_words',
[
[ Db::i()->in( 'lang_id', array_unique( array_filter( [ $this->id, static::defaultLanguage() ] ) ) ) ],
[ Db::i()->in( 'word_key', $keys ) ],
[ 'word_js=0' ]
],
) as $row )
{
$this->words[ $row['word_key'] ] = $row['word_custom'] ?: $row['word_default'];
if( $row['lang_id'] == $this->id or !isset( $this->words[ $row['word_key'] ] ) )
{
$this->words[ $row['word_key'] ] = $row['word_custom'] ?: $row['word_default'];
}
}
foreach( $this->outputStack as $word => $values )
@@ -1848,7 +1877,7 @@ class Lang extends Model
/**
* Strip VLE tags, useful for AJAX responses where you can't edit the string anyways
*
* @param array|string|null $output The output string
* @param array|string|null $output The output string. If an array contains non-string values, they will be cast to a string if they don't correlate to JSON primitives and aren't arrays
* @return string|array|null
*/
public function stripVLETags( array|string|null $output ): array|string|null
@@ -1857,13 +1886,13 @@ class Lang extends Model
{
if( ! is_array( $output ) )
{
return (string) $output;
return $output;
}
$replacement = array();
foreach ( $output as $key => $value )
{
$replacement[ $key ] = $this->stripVLETags( $value );
$replacement[ $key ] = ( is_int( $value ) or is_double( $value ) or is_float( $value ) or is_bool( $value ) or is_null( $value ) ) ? $value : $this->stripVLETags( is_array( $value ) ? $value : (string) $value );
}
return $replacement;
@@ -1899,7 +1928,7 @@ class Lang extends Model
foreach ( $output as $key => $value )
{
$replacement[ $key ] = $this->stripVLETags( $value );
$replacement[ $key ] = ( is_int( $value ) or is_double( $value ) or is_float( $value ) or is_bool( $value ) or is_null( $value ) ) ? $value : $this->stripVLETags( is_array( $value ) ? $value : (string) $value );
}
return $replacement;
@@ -1912,9 +1941,9 @@ class Lang extends Model
* @param array|string $replace The string to replace with
* @param array|string|null $haystack The subject
*
* @return string|array|null|object
* @return mixed
*/
public static function replace( array|string $find, array|string $replace, mixed $haystack ): array|string|null|object
public static function replace( array|string $find, array|string $replace, mixed $haystack ): mixed
{
/* Reduce the number of str_replace with arrays */
static $replaceTable = array();
+95 -4
View File
@@ -25,6 +25,7 @@ use IPS\Helpers\Form\Radio;
use IPS\Helpers\Form\Translatable;
use IPS\Helpers\Form\YesNo;
use IPS\Http\Url;
use IPS\IPS;
use IPS\Lang;
use IPS\Login;
use IPS\Login\Exception as LoginException;
@@ -33,6 +34,7 @@ use IPS\Login\Handler\Standard;
use IPS\Member;
use IPS\Member\Device;
use IPS\Node\Model;
use IPS\Output;
use IPS\Request;
use IPS\Settings;
use LogicException;
@@ -60,6 +62,22 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
*/
abstract class Handler extends Model
{
/**
* @brief [ActiveRecord] Caches
* @note Defined cache keys will be cleared automatically as needed
*/
protected array $caches = [ 'loginMethods' ];
/**
* Is this login handler supported? - Implemented like this for BC
*
* @return bool
*/
public static function isSupported(): bool
{
return TRUE;
}
/**
* Get all handler classes
*
@@ -84,7 +102,10 @@ abstract class Handler extends Model
foreach ( Application::allExtensions( 'core', 'LoginHandler', FALSE, 'core' ) as $key => $extension )
{
$return[] = $extension::class;
if( $extension::isSupported() )
{
$return[] = $extension::class;
}
}
return $return;
@@ -175,6 +196,21 @@ abstract class Handler extends Model
* @return int
*/
abstract public function type(): int;
/**
* Determine if a root can be added
*
* @return bool
*/
public static function canAddRoot(): bool
{
if ( IPS::canManageResources() )
{
return TRUE;
}
return parent::canAddRoot();
}
/**
* Can this handler process a login for a member?
@@ -196,6 +232,16 @@ abstract class Handler extends Model
{
return FALSE;
}
/**
* Can this handler sync profile photos?
*
* @return bool
*/
public function canSyncProfilePhoto() : bool
{
return false;
}
/**
* @brief Cached links
@@ -441,6 +487,19 @@ abstract class Handler extends Model
'linked' => TRUE,
) );
}
/**
* [ActiveRecord] Delete Record
*
* @return void
*/
public function delete(): void
{
parent::delete();
/* Delete login links for this handler */
Db::i()->delete( 'core_login_links', [ 'token_login_method=?', $this->_id ] );
}
/**
* Unlink Account
@@ -543,6 +602,11 @@ abstract class Handler extends Model
{
$return[] = 'email';
}
if( isset( $this->settings['update_photo_changes'] ) and $this->settings['update_photo_changes'] === 'force' )
{
$return[] = 'photo';
}
return $return;
}
@@ -775,6 +839,17 @@ abstract class Handler extends Model
/* Return */
return $obj;
}
/**
* [Node] Get whether or not this node is locked to current enabled/disabled status
*
* @note Return value NULL indicates the node cannot be enabled/disabled
* @return bool|null
*/
protected function get__locked(): ?bool
{
return !IPS::canManageResources();
}
/**
* Get settings
@@ -804,7 +879,7 @@ abstract class Handler extends Model
*/
public function canCopy(): bool
{
if ( !static::$allowMultiple )
if ( !static::$allowMultiple OR !IPS::canManageResources() )
{
return FALSE;
}
@@ -818,6 +893,10 @@ abstract class Handler extends Model
*/
public function canDelete(): bool
{
if( !IPS::canManageResources() )
{
return FALSE;
}
if ( parent::canDelete() )
{
return count( static::roots() ) > 1;
@@ -840,6 +919,14 @@ abstract class Handler extends Model
*/
public function form( Form &$form ) : void
{
/* No Editing for Managed */
if ( !IPS::canManageResources() )
{
$form->attributes = ['inert' => 'true'];
$form->actionButtons = [];
$form->addMessage( 'login_method_cannot_edit_managed', 'error' );
}
$form->addHeader('login_method_basic_settings');
$form->add( new Translatable( 'login_method_name', NULL, TRUE, array( 'app' => 'core', 'key' => ( $this->id ? 'login_method_' . $this->id : NULL ) ) ) );
if ( !( $this instanceof Standard ) ) {
@@ -870,8 +957,6 @@ abstract class Handler extends Model
) ) );
}
$form->add( new YesNo( 'login_front', $this->id ? $this->front : static::$enableAcpLoginByDefault, FALSE, array() ) );
foreach ( $this->acpForm() as $key => $field )
{
if ( is_string( $field ) )
@@ -909,6 +994,12 @@ abstract class Handler extends Model
*/
public function saveForm( array $values ): mixed
{
/* No Editing for Managed */
if( !IPS::canManageResources() )
{
Output::i()->error( 'login_method_cannot_edit_managed', '3S440/1', 403, '' );
}
if ( isset( static::$shareService ) and isset( $values[ 'share_autoshare_' . static::$shareService ] ) )
{
try
+18 -1
View File
@@ -94,6 +94,23 @@ abstract class OAuth1 extends Handler
'optional' => 'login_update_changes_optional',
'disabled' => 'login_update_changes_no',
), 'disabled' => $emailChangesDisabled ), NULL, NULL, NULL, 'login_update_email_changes_inc_optional' );
/* If this handler supports profile photos, add options for that */
if( $this->canSyncProfilePhoto() )
{
$photoChangesDisabled = array();
if( $forcePhotoHandler = static::handlerHasForceSync( 'photo', $this ) )
{
$photoChangesDisabled[] = 'force';
Member::loggedIn()->language()->words['login_update_changes_yes_photo_desc'] = Member::loggedIn()->language()->addToStack( 'login_update_changes_yes_disabled', FALSE, array( 'sprintf' => $forcePhotoHandler->_title ) );
}
$return['update_photo_changes'] = new Radio( 'login_update_photo_changes', $this->settings['update_photo_changes'] ?? 'optional', FALSE, array( 'options' => array(
'force' => 'login_update_changes_yes_photo',
'optional' => 'login_update_changes_optional',
'disabled' => 'login_update_changes_no',
), 'disabled' => $photoChangesDisabled ), null, null, null, 'login_update_photo_changes_inc_optional' );
}
return $return;
}
@@ -468,7 +485,7 @@ abstract class OAuth1 extends Handler
*/
public function userProfileName( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) )
if ( !( $link = $this->_link( $member ) ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( "", Exception::INTERNAL_ERROR );
}
+37 -10
View File
@@ -47,6 +47,16 @@ class Twitter extends OAuth1
{
return 'login_handler_Twitter';
}
/**
* Can this handler sync profile photos?
*
* @return bool
*/
public function canSyncProfilePhoto() : bool
{
return true;
}
/**
* ACP Settings Form
@@ -73,6 +83,15 @@ class Twitter extends OAuth1
'screen' => array( 'login_update_name_changes_inc_optional' ),
)
), NULL, NULL, NULL, 'login_real_name' ),
'real_photo' => new Radio( 'login_real_photo', $this->settings['real_photo'] ?? 1, false, array(
'options' => array(
1 => 'login_real_photo_twitter',
0 => 'login_real_photo_disabled'
),
'toggles' => array(
1 => array( 'login_update_photo_changes_inc_optional' )
)
) )
),
parent::acpForm(),
array()
@@ -240,7 +259,7 @@ class Twitter extends OAuth1
*/
public function userProfileName( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) )
if ( !( $link = $this->_link( $member ) ) OR empty( $link['token_access_token'] ) )
{
throw new LoginException( "", LoginException::INTERNAL_ERROR );
}
@@ -260,16 +279,20 @@ class Twitter extends OAuth1
*/
public function userProfilePhoto( Member $member ): ?Url
{
if ( !( $link = $this->_link( $member ) ) )
if ( !( $link = $this->_link( $member ) ) OR empty( $link['token_access_token'] ) )
{
throw new LoginException( "", LoginException::INTERNAL_ERROR );
}
$userData = $this->_userData( $link['token_access_token'], $link['token_secret'] );
if ( !$userData['default_profile_image'] )
{
return Url::external( str_replace( '_normal', '', $userData['profile_image_url_https'] ?: $userData['profile_image_url'] ) );
}
if ( isset( $this->settings['real_photo'] ) and $this->settings['real_photo'] )
{
$userData = $this->_userData( $link['token_access_token'], $link['token_secret'] );
if ( !$userData['default_profile_image'] )
{
return Url::external( str_replace( '_normal', '', $userData['profile_image_url_https'] ?: $userData['profile_image_url'] ) );
}
}
return NULL;
}
@@ -335,8 +358,12 @@ class Twitter extends OAuth1
{
$return[] = 'name';
}
$return[] = 'photo';
if( isset( $this->settings['update_photo_changes'] ) and $this->settings['update_photo_changes'] == 'optional' and isset( $this->settings['real_photo'] ) and $this->settings['real_photo'] )
{
$return[] = 'photo';
}
$return[] = 'cover';
return $return;
+3 -2
View File
@@ -174,6 +174,7 @@ class Custom extends OAuth2
$return['authorization_endpoint'] = (string) $return['authorization_endpoint'];
$return['token_endpoint'] = (string) $return['token_endpoint'];
$return['user_endpoint'] = (string) $return['user_endpoint'];
$return['oauth_authorization_endpoint'] = (string) $return['oauth_authorization_endpoint'];
return $return;
}
@@ -369,7 +370,7 @@ class Custom extends OAuth2
{
if ( isset( $this->settings['photo_field'] ) and $this->settings['photo_field'] )
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( "", Exception::INTERNAL_ERROR );
}
@@ -395,7 +396,7 @@ class Custom extends OAuth2
*/
public function userProfileName( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( "", Exception::INTERNAL_ERROR );
}
+37 -11
View File
@@ -49,6 +49,16 @@ class Facebook extends OAuth2
{
return 'login_handler_Facebook';
}
/**
* Can this handler sync profile photos?
*
* @return bool
*/
public function canSyncProfilePhoto() : bool
{
return true;
}
/**
* ACP Settings Form
@@ -75,6 +85,15 @@ class Facebook extends OAuth2
1 => array( 'login_update_name_changes_inc_optional' ),
)
), NULL, NULL, NULL, 'login_real_name' ),
'real_photo' => new Radio( 'login_real_photo', $this->settings['real_photo'] ?? 1, false, array(
'options' => array(
1 => 'login_real_photo_facebook',
0 => 'login_real_photo_disabled'
),
'toggles' => array(
1 => array( 'login_update_photo_changes_inc_optional' )
)
) )
),
parent::acpForm(),
array()
@@ -310,17 +329,21 @@ class Facebook extends OAuth2
*/
public function userProfilePhoto( Member $member ): ?Url
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new LoginException( "", LoginException::INTERNAL_ERROR );
}
$photoVars = explode( ':', $member->group['g_photo_max_vars'] );
$response = $this->_authorizedRequest( "{$link['token_identifier']}/picture?width={$photoVars[1]}&redirect=false", $link['token_access_token'], NULL, 'get' );
if ( !$response['data']['is_silhouette'] and isset( $response['data']['url'] ) and $response['data']['url'] )
{
return Url::external( $response['data']['url'] );
}
if ( isset( $this->settings['real_photo'] ) and $this->settings['real_photo'] )
{
$photoVars = explode( ':', $member->group['g_photo_max_vars'] );
$response = $this->_authorizedRequest( "{$link['token_identifier']}/picture?width={$photoVars[1]}&redirect=false", $link['token_access_token'], NULL, 'get' );
if ( !$response['data']['is_silhouette'] and isset( $response['data']['url'] ) and $response['data']['url'] )
{
return Url::external( $response['data']['url'] );
}
}
return NULL;
}
@@ -336,7 +359,7 @@ class Facebook extends OAuth2
*/
public function userProfileName( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new LoginException( "", LoginException::INTERNAL_ERROR );
}
@@ -365,8 +388,11 @@ class Facebook extends OAuth2
{
$return[] = 'name';
}
$return[] = 'photo';
if( isset( $this->settings['update_photo_changes'] ) and $this->settings['update_photo_changes'] == 'optional' and isset( $this->settings['real_photo'] ) and $this->settings['real_photo'] )
{
$return[] = 'photo';
}
return $return;
}
+37 -11
View File
@@ -49,6 +49,16 @@ class Google extends OAuth2
}
protected static bool $enableAcpLoginByDefault = FALSE;
/**
* Can this handler sync profile photos?
*
* @return bool
*/
public function canSyncProfilePhoto() : bool
{
return true;
}
/**
* ACP Settings Form
@@ -73,7 +83,16 @@ class Google extends OAuth2
'toggles' => array(
1 => array( 'login_update_name_changes_inc_optional' ),
)
), NULL, NULL, NULL, 'login_real_name' )
), NULL, NULL, NULL, 'login_real_name' ),
'real_photo' => new Radio( 'login_real_photo', $this->settings['real_photo'] ?? 1, false, array(
'options' => array(
1 => 'login_real_photo_google',
0 => 'login_real_photo_disabled'
),
'toggles' => array(
1 => array( 'login_update_photo_changes_inc_optional' )
)
) )
),
parent::acpForm()
);
@@ -280,16 +299,20 @@ class Google extends OAuth2
*/
public function userProfilePhoto( Member $member ): ?Url
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( "", Exception::INTERNAL_ERROR );
}
$userData = $this->_userData( $link['token_access_token'] );
if ( isset( $userData['picture'] ) and $userData['picture'] )
{
return Url::external( $userData['picture'] )->setQueryString( 'sz', NULL );
}
if ( isset( $this->settings['real_photo'] ) and $this->settings['real_photo'] )
{
$userData = $this->_userData( $link['token_access_token'] );
if ( isset( $userData['picture'] ) and $userData['picture'] )
{
return Url::external( $userData['picture'] )->setQueryString( 'sz', NULL );
}
}
return NULL;
}
@@ -305,7 +328,7 @@ class Google extends OAuth2
*/
public function userProfileName( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( "", Exception::INTERNAL_ERROR );
}
@@ -356,8 +379,11 @@ class Google extends OAuth2
{
$return[] = 'name';
}
$return[] = 'photo';
if( isset( $this->settings['update_photo_changes'] ) and $this->settings['update_photo_changes'] == 'optional' and isset( $this->settings['real_photo'] ) and $this->settings['real_photo'] )
{
$return[] = 'photo';
}
return $return;
}
+2 -2
View File
@@ -340,7 +340,7 @@ class Invision extends OAuth2
*/
public function userProfilePhoto( Member $member ): ?Url
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new LoginException( "", LoginException::INTERNAL_ERROR );
}
@@ -365,7 +365,7 @@ class Invision extends OAuth2
*/
public function userProfileName( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new LoginException( "", LoginException::INTERNAL_ERROR );
}
+45 -20
View File
@@ -49,6 +49,16 @@ class LinkedIn extends OAuth2
{
return 'login_handler_Linkedin';
}
/**
* Can this handler sync profile photos?
*
* @return bool
*/
public function canSyncProfilePhoto() : bool
{
return true;
}
/**
* @brief Enable AdminCP logins by default
@@ -77,8 +87,17 @@ class LinkedIn extends OAuth2
'toggles' => array(
1 => array( 'login_update_name_changes_inc_optional' ),
)
), NULL, NULL, NULL, 'login_real_name'
) ), parent::acpForm() );
), NULL, NULL, NULL, 'login_real_name' ),
'real_photo' => new Radio( 'login_real_photo', $this->settings['real_photo'] ?? 1, false, array(
'options' => array(
1 => 'login_real_photo_linkedin',
0 => 'login_real_photo_disabled'
),
'toggles' => array(
1 => array( 'login_update_photo_changes_inc_optional' )
)
) )
), parent::acpForm() );
}
/**
@@ -272,7 +291,7 @@ class LinkedIn extends OAuth2
*/
public function userProfilePhoto( Member $member ): ?Url
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( "", Exception::INTERNAL_ERROR );
}
@@ -282,21 +301,25 @@ class LinkedIn extends OAuth2
return NULL;
}
$userData = $this->_userData( $link['token_access_token'] )['profilePicture']['displayImage~']['elements'];
if ( isset( $this->settings['real_photo'] ) and $this->settings['real_photo'] )
{
$userData = $this->_userData( $link['token_access_token'] )['profilePicture']['displayImage~']['elements'];
foreach( array_reverse( $userData ) as $_userData )
{
if ( isset( $_userData['identifiers'] ) )
{
foreach( $_userData['identifiers'] as $identifier )
{
if( isset( $identifier['identifier'] ) AND $identifier['identifier'] )
{
return Url::external( $identifier['identifier'] );
}
}
}
}
}
foreach( array_reverse( $userData ) as $_userData )
{
if ( isset( $_userData['identifiers'] ) )
{
foreach( $_userData['identifiers'] as $identifier )
{
if( isset( $identifier['identifier'] ) AND $identifier['identifier'] )
{
return Url::external( $identifier['identifier'] );
}
}
}
}
return NULL;
}
@@ -312,7 +335,7 @@ class LinkedIn extends OAuth2
*/
public function userProfileName( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( "", Exception::INTERNAL_ERROR );
}
@@ -370,9 +393,11 @@ class LinkedIn extends OAuth2
{
$return[] = 'name';
}
$return[] = 'photo';
if( isset( $this->settings['update_photo_changes'] ) and $this->settings['update_photo_changes'] == 'optional' and isset( $this->settings['real_photo'] ) and $this->settings['real_photo'] )
{
$return[] = 'photo';
}
return $return;
}
+1 -1
View File
@@ -236,7 +236,7 @@ class Microsoft extends OAuth2
*/
public function userProfileName( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( "", Exception::INTERNAL_ERROR );
}
+20 -3
View File
@@ -122,6 +122,23 @@ abstract class OAuth2 extends Handler
'optional' => 'login_update_changes_optional',
'disabled' => 'login_update_changes_no',
), 'disabled' => $emailChangesDisabled ), NULL, NULL, NULL, 'login_update_email_changes_inc_optional' );
/* If this handler supports profile photos, add options for that */
if( $this->canSyncProfilePhoto() )
{
$photoChangesDisabled = array();
if( $forcePhotoHandler = static::handlerHasForceSync( 'photo', $this ) )
{
$photoChangesDisabled[] = 'force';
Member::loggedIn()->language()->words['login_update_changes_yes_photo_desc'] = Member::loggedIn()->language()->addToStack( 'login_update_changes_yes_disabled', FALSE, array( 'sprintf' => $forcePhotoHandler->_title ) );
}
$return['update_photo_changes'] = new Radio( 'login_update_photo_changes', $this->settings['update_photo_changes'] ?? 'optional', FALSE, array( 'options' => array(
'force' => 'login_update_changes_yes_photo',
'optional' => 'login_update_changes_optional',
'disabled' => 'login_update_changes_no',
), 'disabled' => $photoChangesDisabled ), null, null, null, 'login_update_photo_changes_inc_optional' );
}
return array_merge( $return, parent::acpForm() );
}
@@ -538,7 +555,7 @@ abstract class OAuth2 extends Handler
{
try
{
$identifier = Db::i()->select( 'token_identifier', 'core_login_links', array( 'token_login_method=? AND token_member=?', $this->id, $exception->member->member_id ) )->first();
$identifier = Db::i()->select( 'token_identifier', 'core_login_links', [ 'token_login_method=? AND token_member=?', $this->id, $exception->member->member_id ], flags: Db::SELECT_FROM_WRITE_SERVER )->first();
if( $identifier != $userId )
{
@@ -786,7 +803,7 @@ abstract class OAuth2 extends Handler
*/
public function userId( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( 'generic_error', Exception::INTERNAL_ERROR );
}
@@ -806,7 +823,7 @@ abstract class OAuth2 extends Handler
*/
public function userProfileName( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( 'generic_error', Exception::INTERNAL_ERROR );
}
+2 -2
View File
@@ -312,7 +312,7 @@ class Wordpress extends OAuth2
*/
public function userProfilePhoto( Member $member ): ?Url
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( "", Exception::INTERNAL_ERROR );
}
@@ -342,7 +342,7 @@ class Wordpress extends OAuth2
*/
public function userProfileName( Member $member ): ?string
{
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) )
if ( !( $link = $this->_link( $member ) ) or ( $link['token_expires'] and $link['token_expires'] < time() ) OR empty( $link['token_access_token'] ) )
{
throw new Exception( "", Exception::INTERNAL_ERROR );
}
+6
View File
@@ -141,6 +141,12 @@ class Login
try
{
$return[ $row['login_id'] ] = Handler::constructFromData( $row );
/* Check login method is supported */
if( !$return[ $row['login_id'] ]->isSupported() )
{
unset( $return[ $row['login_id'] ] );
}
}
catch ( OutOfRangeException $e ) { }
}
+1 -1
View File
@@ -128,7 +128,7 @@ class Success
Request::i()->setCookie( 'noCache', 0, DateTime::ts( time() - 86400 ) );
/* Member sync */
Event::fire( 'onLogin', $this->member );
$this->member->profileSync();
Event::fire( 'onLogin', $this->member );
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ abstract class MFAHandler
/**
* Get handlers
*
* @return array
* @return array<MFAHandler>
*/
public static function handlers(): array
{
+49
View File
@@ -540,6 +540,55 @@ class Number implements JsonSerializable
return $result;
}
/**
* Get the moving average series from a series of numbers
*
* @param number[] $numbers
* @param int $degree How many elements of the source $numbers to average over
* @param float $weightIncrement How much to scale the weight of each value in the moving average as it approaches the current date. The weight starts at 1 and will be multiplied by this number
* @return (number|null)[] If the input array contains non-numeric values, these will be omitted from the moving average. If there are no numbers in the average period, null is added to the output array instead of a number
*/
public static function movingAverage( array $numbers, int $degree=5, float $weightIncrement=1 ) : array
{
if ( count( $numbers ) <= 1 OR $degree <= 1 )
{
return [...$numbers];
}
$final = [];
$values = array_values( $numbers );
$keys = array_keys( $numbers );
for ( $i = 1; $i < count( $numbers ); $i++ )
{
$start = max(0, $i - $degree);
$slice = array_slice( $values, $start, $i + 1 - $start );
$sum = 0;
$count = 0;
$weight = 1;
foreach ( $slice as $number )
{
if ( !is_numeric( $number ) )
{
continue;
}
$number = ( is_int( $number ) or is_float( $number ) ) ? $number : (float) $number;
$sum += $number * $weight;
$count += $weight;
$weight *= $weightIncrement;
}
if ( $count === 0 )
{
$final[$keys[$i]] = null;
continue;
}
$final[$keys[$i]] = $sum / $count;
}
return $final;
}
/**
* Get product of numbers
*
+226 -15
View File
@@ -33,8 +33,11 @@ use IPS\Db\Exception;
use IPS\Db\Select;
use IPS\Dispatcher;
use IPS\Events\Event;
use IPS\Extensions\ClubAbstract;
use IPS\File;
use IPS\GeoLocation;
use IPS\Helpers\Badge;
use IPS\Helpers\Badge\Icon;
use IPS\Helpers\CoverPhoto;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Address;
@@ -56,6 +59,7 @@ use IPS\Math\Number;
use IPS\Member;
use IPS\Member\Club\CustomField;
use IPS\Member\Club\Page as ClubPage;
use IPS\Member\Club\Template;
use IPS\nexus\Customer;
use IPS\nexus\Invoice;
use IPS\nexus\Money;
@@ -69,6 +73,7 @@ use IPS\Patterns\ActiveRecord;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Request;
use IPS\Settings;
use IPS\Task;
use IPS\Theme;
use OutOfRangeException;
use OverflowException;
@@ -80,6 +85,7 @@ use function defined;
use function get_class;
use function in_array;
use function is_array;
use function is_object;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
@@ -92,7 +98,11 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
*/
class Club extends ActiveRecord implements Embeddable
{
use Followable;
use Followable
{
Followable::unfollow as public _unfollow;
Followable::follow as public _follow;
}
/**
* @brief Club: public
@@ -178,6 +188,13 @@ class Club extends ActiveRecord implements Embeddable
* @brief Not actually a status, but used for member history
*/
const STATUS_LEFT = 'left';
/**
* @breif Constants for node permissions values
*/
const NODE_PRIVATE = 0;
const NODE_PUBLIC = 1;
const NODE_MODERATORS = 2;
/**
* @brief [ActiveRecord] Multiton Store
@@ -227,9 +244,10 @@ class Club extends ActiveRecord implements Embeddable
* @param array $filters Custom field filters
* @param mixed|null $extraWhere Additional WHERE clause
* @param bool $countOnly Only return a count, instead of an iterator
* @param array|null $location Array with lat and lon
* @return ActiveRecordIterator|array|int
*/
public static function clubs( ?Member $member, int|array|null $limit, string $sortOption, bool|array|Member $mineOnly=FALSE, array $filters=array(), mixed $extraWhere=NULL, bool $countOnly=FALSE ): ActiveRecordIterator|int|array
public static function clubs( ?Member $member, int|array|null $limit, string $sortOption, bool|array|Member $mineOnly=FALSE, array $filters=array(), mixed $extraWhere=NULL, bool $countOnly=FALSE, ?array $location=null ): ActiveRecordIterator|int|array
{
$where = array();
$joins = array();
@@ -300,6 +318,19 @@ class Club extends ActiveRecord implements Embeddable
}
}
}
$selectClause = '*';
$having = NULL;
/* Location? */
if ( isset( $location['lat'] ) and isset( $location['lon'] ) )
{
/* Make sure co-ordinates are in a valid format regardless of locale */
$location['lat'] = number_format( $location['lat'], 6, '.', '' );
$location['lon'] = number_format( $location['lon'], 6, '.', '' );
$selectClause .= ', ( 3959 * acos( cos( radians(' . $location['lat'] . ') ) * cos( radians( location_lat ) ) * cos( radians( location_long ) - radians( ' . $location['lon'] . ' ) ) + sin( radians( ' . $location['lat'] . ' ) ) * sin( radians( location_lat ) ) ) ) AS distance';
}
/* Additional where clause */
if ( $extraWhere )
@@ -313,15 +344,27 @@ class Club extends ActiveRecord implements Embeddable
$where[] = array( $extraWhere );
}
}
/* Extensions */
foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
{
/* @var ClubAbstract $ext */
foreach( $ext->clubsWhere( $member ) as $clause )
{
$where[] = $clause;
}
}
/* Query */
if( $countOnly )
{
$select = Db::i()->select( 'COUNT(*)', 'core_clubs', $where );
$select = Db::i()->select( 'COUNT(*)', 'core_clubs', $where, null, null, null, $having );
}
else
{
$select = Db::i()->select( '*', 'core_clubs', $where, ( $sortOption === 'name' ? "{$sortOption} ASC" : "{$sortOption} DESC" ), $limit );
/* If we are sorting by distance, we want null values last */
$sortOption = ( $sortOption === 'distance' ) ? '-distance' : $sortOption;
$select = Db::i()->select( $selectClause, 'core_clubs', $where, "{$sortOption} " . ( $sortOption === 'name' ? 'ASC' : 'DESC' ), $limit, null, $having );
}
foreach ( $joins as $join )
@@ -619,6 +662,28 @@ class Club extends ActiveRecord implements Embeddable
$form->add( new YesNo( 'club_rules_reacknowledge', FALSE, FALSE, [], NULL, NULL, NULL, 'club_rules_reacknowledge' ) );
}
$form->add( new YesNo( 'club_auto_follow', $this->auto_follow, false ) );
if( !$new )
{
Member::loggedIn()->language()->words['club_auto_follow_desc'] = Member::loggedIn()->language()->get( 'club_auto_follow_desc_edit' );
}
foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
{
/* @var ClubAbstract $ext */
foreach( $ext->formElements( $this ) as $element )
{
if( is_object( $element ) )
{
$form->add( $element );
}
else
{
$form->addHtml( $element );
}
}
}
return $form;
}
@@ -737,6 +802,9 @@ class Club extends ActiveRecord implements Embeddable
$this->show_membertab = "nonmember";
}
$this->auto_follow = $values['club_auto_follow'];
$autoFollow = ( !$new and isset( $this->changed['auto_follow'] ) and $this->changed['auto_follow'] );
$this->save();
if ( $values['show_rules'] )
@@ -766,6 +834,12 @@ class Club extends ActiveRecord implements Embeddable
}
}
/* If we are auto-following and this is not a new club, trigger a background task */
if( $autoFollow )
{
Task::queue( 'core', 'AutoFollowClubs', [ 'club' => $this->id, 'node' => null ], 3, [ 'club', 'node' ] );
}
if ( $new )
{
$this->addMember( Member::loggedIn(), Club::STATUS_LEADER );
@@ -815,6 +889,23 @@ class Club extends ActiveRecord implements Embeddable
}
}
/* Extensions */
foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
{
/* @var ClubAbstract $ext */
$ext->saveForm( $this, $values, $new );
}
/* Default nodes */
if( $new )
{
foreach( Template::roots() as $template )
{
$nodeClass = new ( $template->node_class );
$nodeClass->saveClubForm( $this, $template->node_data, $template );
}
}
if( $new and Settings::i()->clubs_require_approval and !$this->approved )
{
/* Add it to the approval queue */
@@ -1293,6 +1384,9 @@ class Club extends ActiveRecord implements Embeddable
*/
public function addMember( Member $member, string $status = 'member', bool $update=FALSE, Member $addedBy = NULL, Member $invitedBy = NULL, bool $updateJoinedDate=FALSE ) : void
{
/* Get the current status so that we can figure out if this is a new membership */
$currentStatus = $this->memberStatus( $member );
try
{
Db::i()->insert( 'core_clubs_memberships', array(
@@ -1332,6 +1426,7 @@ class Club extends ActiveRecord implements Embeddable
Db::i()->update( 'core_clubs_memberships', $save, array( 'club_id=? AND member_id=?', $this->id, $member->member_id ) );
$member->rebuildPermissionArray();
$this->memberStatuses[ $member->member_id ] = $status;
}
else
{
@@ -1344,6 +1439,12 @@ class Club extends ActiveRecord implements Embeddable
}
}
/* Is this club an auto-follow? */
if( $this->auto_follow and in_array( $status, [ static::STATUS_MEMBER, static::STATUS_LEADER, static::STATUS_MODERATOR ] ) and !in_array( $currentStatus, [ static::STATUS_MEMBER, static::STATUS_LEADER, static::STATUS_MODERATOR ] ) )
{
$this->follow( 'immediate', true, $member );
}
/* Log to Member History */
$memberStatus = $this->memberStatus( $member, 2 );
if( in_array( $memberStatus['status'], [ Club::STATUS_INVITED, Club::STATUS_INVITED_BYPASSING_PAYMENT, Club::STATUS_BANNED, Club::STATUS_MEMBER, Club::STATUS_DECLINED, Club::STATUS_REQUESTED ] ) )
@@ -1404,16 +1505,22 @@ class Club extends ActiveRecord implements Embeddable
*/
public function removeMember( Member $member ) : void
{
/* Get the member's current status */
$memberStatus = $this->memberStatus( $member );
Db::i()->delete( 'core_clubs_memberships', array( 'club_id=? AND member_id=?', $this->id, $member->member_id ) );
$member->rebuildPermissionArray();
/* Clear out the member from any local caches */
if( isset( $this->memberStatuses[ $member->member_id ] ) )
{
unset( $this->memberStatuses[ $member->member_id ] );
}
/* Unfollow the club */
$this->unfollow( $member );
Webhook::fire( 'club_member_removed', ['club' => $this, 'member' => $member] );
Event::fire( 'onLeaveClub', $member, array( $this ) );
$member->rebuildPermissionArray();
}
/**
@@ -2021,10 +2128,10 @@ class Club extends ActiveRecord implements Embeddable
/**
* Get the club navbar tabs
*
* @param Model|NULL $container Container
* @param Model|ClubPage|null $container Container
* @return array
*/
public function tabs( Model $container = NULL ) : array
public function tabs( Model|ClubPage|null $container = NULL ) : array
{
if ( !static::$tabs )
{
@@ -2178,7 +2285,7 @@ class Club extends ActiveRecord implements Embeddable
$menu->add( new Link( $this->url()->setQueryString( 'do', 'invite' ), 'club_invite_members', dataAttributes: [ 'data-ipsDialog-size' => 'narrow' ], opensDialog: true ) );
}
if( $this->canManageNavigation() and $container instanceof Model and count( $this->tabs( $container ) ) > 1 )
if( $this->canManageNavigation() and count( $this->tabs( $container ) ) > 1 )
{
$menu->add( new Link( '#', 'reorder_club_menu', dataAttributes: [ 'data-action' => 'reorderClubmenu' ] ) );
}
@@ -2202,7 +2309,7 @@ class Club extends ActiveRecord implements Embeddable
}
}
if( $container instanceof Model and $this->isLeader() )
if( $container instanceof Model and $this->isLeader() and $container->canLeaderManage() )
{
$menu->addSeparator();
$menu->add( new Link( $this->url()->setQueryString( [ 'do' => 'nodeForm', 'type' => get_class( $container ), 'node' => $container->_id ] ), Member::loggedIn()->language()->addToStack( 'clubs_edit_this_container', true, [ 'sprintf' => Member::loggedIn()->language()->addToStack( $container::$nodeTitle . '_sg' ) ] ), dataAttributes: [ 'data-ipsDialog-title' => $container->_title ], opensDialog: true ) );
@@ -2311,6 +2418,58 @@ class Club extends ActiveRecord implements Embeddable
return $buttons;
}
/**
* Return badges that should be displayed
*
* @return array
*/
public function badges() : array
{
$badges = [];
if( Settings::i()->clubs_require_approval and !$this->approved )
{
$badges[] = new Icon( Badge::BADGE_WARNING, 'fa-eye-slash', Member::loggedIn()->language()->addToStack( 'club_unapproved' ) );
}
else
{
if( $this->featured )
{
$badges[] = new Icon( Badge::BADGE_POSITIVE, 'fa-star', Member::loggedIn()->language()->addToStack( 'featured' ) );
}
$memberStatus = $this->memberStatus( Member::loggedIn() );
if( in_array( $memberStatus, [ static::STATUS_MEMBER, static::STATUS_MODERATOR, static::STATUS_LEADER ] ) )
{
$badges[] = new Icon( Badge::BADGE_POSITIVE, 'fa-check', Member::loggedIn()->language()->addToStack( 'club_member' ) );
}
elseif( in_array( $memberStatus, [ static::STATUS_EXPIRED, static::STATUS_EXPIRED_MODERATOR ] ) )
{
$badges[] = new Icon( Badge::BADGE_INTERMEDIARY, 'fa-triangle-exclamation', Member::loggedIn()->language()->addToStack( 'club_expired' ) );
}
elseif( in_array( $memberStatus, [ static::STATUS_INVITED, static::STATUS_INVITED_BYPASSING_PAYMENT ] ) )
{
$badges[] = new Icon( Badge::BADGE_NEUTRAL, 'fa-envelope', Member::loggedIn()->language()->addToStack( 'club_invited' ) );
}
elseif( $memberStatus == static::STATUS_WAITING_PAYMENT )
{
$badges[] = new Icon( Badge::BADGE_NEUTRAL, 'fa-check', Member::loggedIn()->language()->addToStack( 'club_awaiting_payment_title' ) );
}
elseif( $memberStatus == static::STATUS_REQUESTED )
{
$badges[] = new Icon( Badge::BADGE_INTERMEDIARY, 'fa-clock', Member::loggedIn()->language()->addToStack( 'club_requested_desc_short' ) );
}
}
foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
{
/* @var ClubAbstract $ext */
$badges = array_merge( $badges, $ext->badges( $this ) );
}
return $badges;
}
/**
* Can a member view the members page
*
@@ -2362,10 +2521,10 @@ class Club extends ActiveRecord implements Embeddable
* Can be used by 3rd parties to add own club navigation tabs before they get sorted
*
* @param array $tabs Tabs
* @param Model|null $container Container
* @param Model|ClubPage|null $container Container
* @return array
*/
protected function _tabs( array $tabs, Model $container = NULL ) : array
protected function _tabs( array $tabs, Model|ClubPage|null $container = NULL ) : array
{
foreach( Application::allExtensions( 'core', 'Club' ) AS $ext )
{
@@ -2635,4 +2794,56 @@ class Club extends ActiveRecord implements Embeddable
$this->rebuilt = time();
$this->save();
}
/**
* Follow this object
*
* @param string $frequency ( 'none', 'immediate', 'daily', 'weekly' )
* @param bool $public
* @param Member|null $member
* @return void
*/
public function follow( string $frequency, bool $public=true, ?Member $member=null ) : void
{
$member = $member ?: Member::loggedIn();
$this->_follow( $frequency, $public, $member );
/* Follow all nodes */
foreach( $this->nodes() as $node )
{
try
{
/* @var Model $class */
$class = $node['node_class'];
$class::load( $node['node_id'] )->follow( $frequency, $public, $member );
}
catch( OutOfRangeException ){}
}
}
/**
* Unfollow this object
*
* @param Member|null $member
* @param string|null $followId
* @return void
*/
public function unfollow( ?Member $member=null, ?string $followId=null ) : void
{
/* First run the main method so that we unfollow the club itself */
$member = $member ?: Member::loggedIn();
$this->_unfollow( $member, $followId );
/* Now unfollow any nodes in the club */
foreach( $this->nodes() as $node )
{
try
{
/* @var Model $class */
$class = $node['node_class'];
$class::load( $node['node_id'] )->unfollow( $member );
}
catch( OutOfRangeException ){}
}
}
}
+1 -1
View File
@@ -163,7 +163,7 @@ class CustomField extends SystemCustomField implements Permissions
/**
* Get fields
*
* @return array|ActiveRecordIterator
* @return array<CustomField>|ActiveRecordIterator
*/
public static function fields(): ActiveRecordIterator|array
{
+217
View File
@@ -0,0 +1,217 @@
<?php
/**
* @brief Template
* @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
* @subpackage
* @since 7/9/2025
*/
namespace IPS\Member\Club;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Helpers\Form;
use IPS\Helpers\Form\Editor;
use IPS\Helpers\Form\Select;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\YesNo;
use IPS\Lang;
use IPS\Member\Club;
use IPS\Node\Model;
use IPS\Request;
use IPS\Theme;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Template extends Model
{
/**
* @brief [ActiveRecord] Multiton Store
*/
protected static array $multitons;
/**
* @brief [ActiveRecord] Database Table
*/
public static ?string $databaseTable = 'core_clubs_templates';
/**
* @brief [ActiveRecord] Database Prefix
*/
public static string $databasePrefix = 'template_';
/**
* @brief language string
*/
public static string $nodeTitle = 'clubs_templates';
/**
* @brief [Node] Order Database Column
*/
public static ?string $databaseColumnOrder = 'position';
/**
* @brief Show form in a modal?
*/
public static bool $modalForms = true;
/**
* [Node] Get Title
*
* @return string
*/
protected function get__title(): string
{
return $this->name ?? '';
}
/**
* @return array
*/
public function get_node_data() : array
{
return isset( $this->_data['node_data'] ) ? json_decode( $this->_data['node_data'], TRUE ) : [];
}
/**
* @param array|null $val
* @return void
*/
public function set_node_data( ?array $val ) : void
{
$this->_data['node_data'] = is_array( $val ) ? json_encode( $val ) : null;
}
/**
* Get the title for a node using the specified language object
* This is commonly used where we cannot use the logged in member's language, such as sending emails
*
* @param Lang $language Language object to fetch the title with
* @param array $options What options to use for language parsing
* @return string
*/
public function getTitleForLanguage( Lang $language, array $options=array() ): string
{
return $this->_title;
}
/**
* [Node] Add/Edit Form
*
* @param Form $form The form
* @return void
*/
public function form( Form &$form ) : void
{
parent::form( $form );
$form->add( new Text( 'template_template_name', $this->name, true ) );
if( $this->_new )
{
/* Override the submit button language string */
$form->actionButtons = [
Theme::i()->getTemplate( 'forms', 'core', 'global' )->button( 'continue', 'submit', null, 'ipsButton ipsButton--primary', array( 'tabindex' => '2', 'accesskey' => 's' ) )
];
$nodeOptions = array();
foreach( Club::availableNodeTypes() as $nodeType )
{
$nodeOptions[ $nodeType ] = $nodeType::clubFrontTitle();
}
$form->add( new Select( 'template_node_class', NULL, TRUE, array( 'options' => $nodeOptions ) ) );
}
else
{
$this->nodeForm( $form );
}
}
/**
* [Node] Format form values from add/edit form for save
*
* @param array $values Values from the form
* @return array
*/
public function formatFormValues( array $values ): array
{
if( $this->id )
{
$nodeData = [];
foreach( $values as $k => $v )
{
if( $k != 'template_template_name' )
{
$nodeData[ $k ] = $v;
unset( $values[ $k ] );
}
}
$values['node_data'] = $nodeData;
}
$values['template_name'] = $values['template_template_name'];
unset( $values['template_template_name'] );
return parent::formatFormValues( $values );
}
/**
* Get node form
*
* @param Form $form
* @return void $form Form
*/
public function nodeForm( Form &$form ) : void
{
$node = new ( $this->node_class );
$node->clubForm( $form, new Club );
$submitted = $form->id . "_submitted";
foreach( $form->elements as $tab => $elements )
{
foreach( $elements as $key => $element )
{
if( $element instanceof Editor )
{
unset( $form->elements[ $tab ][ $key ] );
}
elseif( isset( $this->node_data[ $key ] ) and !isset( Request::i()->$submitted ) )
{
$form->elements[ $tab ][ $key ]->value = $this->node_data[ $key ];
}
}
}
$form->add( new YesNo( 'clubtemplates_leader_edit', $this->node_data['clubtemplates_leader_edit'] ?? null, TRUE ) );
}
/**
* Search
*
* @param string $column Column to search
* @param string $query Search query
* @param string|null $order Column to order by
* @param mixed $where Where clause
* @return array
*/
public static function search( string $column, string $query, ?string $order=NULL, mixed $where=array() ): array
{
if( $column === '_title' )
{
$column = 'template_name';
$order = 'template_name';
}
return parent::search( $column, $query, $order, $where );
}
}
+44 -25
View File
@@ -63,10 +63,7 @@ class History extends \IPS\Helpers\Table\Db
foreach( $apps as $application )
{
$result = $application->extensions( 'core', 'MemberHistory' );
/* Since we're parsing, we only want to have one extension */
static::$extensions[ $application->directory ] = array_pop( $result );
static::$extensions[ $application->directory ] = $application->extensions( 'core', 'MemberHistory' );
}
}
@@ -90,27 +87,30 @@ class History extends \IPS\Helpers\Table\Db
$options = array();
$extensions = static::$extensions;
foreach( $extensions as $app => $extension )
foreach( $extensions as $app => $appExtensions )
{
if( $extension === null )
{
unset( $extensions[ $app ] );
continue;
}
foreach( $appExtensions as $index => $extension )
{
if( $extension === null )
{
unset( $extensions[ $app ][ $index ] );
continue;
}
foreach( $extension->getTypes() as $type )
{
if ( $type === 'oauth' and Db::i()->select( 'COUNT(*)', 'core_oauth_clients', array( Db::i()->findInSet( 'oauth_grant_types', array( 'authorization_code', 'implicit', 'password' ) ) ) )->first() === 1 )
{
foreach ( new ActiveRecordIterator( Db::i()->select( '*', 'core_oauth_clients', array( Db::i()->findInSet( 'oauth_grant_types', array( 'authorization_code', 'implicit', 'password' ) ) ) ), 'IPS\Api\OAuthClient' ) as $client )
{
$options[ $type ] = $client->_title;
continue 2;
}
}
foreach( $extension->getTypes() as $type )
{
if ( $type === 'oauth' and Db::i()->select( 'COUNT(*)', 'core_oauth_clients', array( Db::i()->findInSet( 'oauth_grant_types', array( 'authorization_code', 'implicit', 'password' ) ) ) )->first() === 1 )
{
foreach ( new ActiveRecordIterator( Db::i()->select( '*', 'core_oauth_clients', array( Db::i()->findInSet( 'oauth_grant_types', array( 'authorization_code', 'implicit', 'password' ) ) ) ), 'IPS\Api\OAuthClient' ) as $client )
{
$options[ $type ] = $client->_title;
continue 2;
}
}
$options[ $type ] = 'log_type_title_' . $type;
}
$options[ $type ] = 'log_type_title_' . $type;
}
}
}
$this->advancedSearch = array(
@@ -127,7 +127,13 @@ class History extends \IPS\Helpers\Table\Db
{
try
{
return $extensions[ $row['log_app'] ]->parseLogType( $val, $row );
foreach( $extensions[ $row['log_app'] ] as $extension )
{
if( in_array( $row['log_type'], $extension->getTypes() ) )
{
return $extension->parseLogType( $val, $row );
}
}
}
catch( Throwable $e )
{
@@ -146,13 +152,26 @@ class History extends \IPS\Helpers\Table\Db
},
'log_member'=> function( $val, $row ) use ( $extensions )
{
return $extensions[ $row['log_app'] ]->parseLogMember( $val, $row );
foreach( $extensions[ $row['log_app'] ] as $extension )
{
if( in_array( $row['log_type'], $extension->getTypes() ) )
{
return $extension->parseLogMember( $val, $row );
}
}
return '';
},
'log_data' => function( $val, $row ) use ( $extensions )
{
try
{
return $extensions[ $row['log_app'] ]->parseLogData( $val, $row );
foreach( $extensions[ $row['log_app'] ] as $extension )
{
if( in_array( $row['log_type'], $extension->getTypes() ) )
{
return $extension->parseLogData( $val, $row );
}
}
}
catch( Throwable $e )
{
+152 -28
View File
@@ -34,7 +34,6 @@ use IPS\core\ProfileFields\Api\Field;
use IPS\core\ProfileFields\Api\FieldGroup;
use IPS\core\Reports\Report;
use IPS\Data\Store;
use IPS\Db;
use IPS\Db\Select;
use IPS\Events\Event;
use IPS\Helpers\CoverPhoto;
@@ -57,7 +56,6 @@ use IPS\Member\UserMenu;
use IPS\Patterns\ActiveRecord;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Platform\Bridge;
use IPS\Settings;
use IPS\Text\Encrypt;
use IPS\Xml\SimpleXML;
use OutOfRangeException;
@@ -92,6 +90,7 @@ class Member extends ActiveRecord
{
Followable::followersCount as public _followersCountBase;
Followable::followers as public _followersBase;
Followable::follow as public _followBase;
}
/**
@@ -207,7 +206,7 @@ class Member extends ActiveRecord
'new_device_email' => 16777216, // Send an email when a new device is used to log in.
'no_solved_reenage' => 33554432, // User does not want to get topic re-engagement emails
'datalayer_pii_optout' => 67108864, // User has opted in to having their PII collected by datalayer
'email_messages_bounce' => 134217728, // User's email keeps returning hard bounces so they need to change it
'email_messages_bounce' => 134217728, // @deprecated User's email keeps returning hard bounces so they need to change it
// 268435456 - cookie_optout moved to cookie preferences
'expert_user_disabled' => 536870912, // User does not want to be a community expert
'expert_user_blocked' => 1073741824 // Admin has blocked expert user
@@ -542,12 +541,6 @@ class Member extends ActiveRecord
}
}
/* If the email was changed, make sure that the bounce flag is unset so the member no longer sees the error message */
if ( isset( $changes['email'] ) )
{
$this->members_bitoptions['email_messages_bounce'] = 0;
}
parent::save();
if ( $new )
@@ -564,8 +557,8 @@ class Member extends ActiveRecord
Webhook::fire( 'member_registration_complete', $this, $this->webhookFilters() );
}
/* Run member sync, but not if the only change is the last_activity timestamp */
if( count( $this->changedCustomFields ) > 0 OR ( count( $changes ) > 0 AND !( count( $changes ) === 1 AND isset( $changes['last_activity'] ) ) ) )
/* Run member sync, but not if the only change is the last_activity timestamp and if we're not just updating the member's language during language() init when no language is set */
if( ( Member::loggedIn()->member_id != $this->member_id or $this->_lang !== null ) and ( count( $this->changedCustomFields ) > 0 OR ( count( $changes ) > 0 AND !( count( $changes ) === 1 AND isset( $changes['last_activity'] ) ) ) ) )
{
Event::fire( 'onProfileUpdate', $this, array( 'changes' => array_merge( $changes, $this->changedCustomFields ) ) );
}
@@ -781,7 +774,7 @@ class Member extends ActiveRecord
/* Run sync */
$this->logHistory( 'core', 'account', array( 'type' => 'spammer', 'set' => TRUE, 'actions' => $actions ) );
Event::fire( 'onSetAsSpammer', $this );
Webhook::fire( 'member_flagged_as_spammer', $this );
/* Notify admin */
AdminNotification::send( 'core', 'Spammer', NULL, TRUE, $this->member_id, Member::loggedIn() );
@@ -865,6 +858,7 @@ class Member extends ActiveRecord
/* Run sync */
Event::fire( 'onUnSetAsSpammer', $this );
Webhook::fire( 'member_unflagged_as_spammer', $this );
}
}
@@ -908,6 +902,8 @@ class Member extends ActiveRecord
{
Db::i()->update( 'core_oauth_server_access_tokens', array( 'status' => 'revoked' ), array( 'member_id=?', $this->member_id ) );
}
Webhook::fire( 'member_ban_state_changed', [$this, $value] );
}
/**
@@ -1692,6 +1688,31 @@ class Member extends ActiveRecord
return static::_followers( 'member', $this->member_id ?: 0, $privacy, $frequencyTypes, $date, $limit, $order, FALSE, null );
}
/**
* Follow this object
*
* @param string $frequency ( 'none', 'immediate', 'daily', 'weekly' )
* @param bool $public
* @param Member|null $member
* @return void
*/
public function follow( string $frequency, bool $public=true, ?Member $member=null ) : void
{
$member = $member ?: Member::loggedIn();
$this->_followBase( $frequency, $public, $member );
if( $public )
{
$this->achievementAction( 'core', 'FollowMember', [
'giver' => $member
] );
$notification = new Notification( Application::load( 'core' ), 'member_follow', $member, array( $member ) );
$notification->recipients->attach( $this );
$notification->send();
}
}
/**
* Record a failed login attempt
*
@@ -1823,7 +1844,7 @@ class Member extends ActiveRecord
/**
* Get timezone
*
* @return string
* @return string|null
*/
public function get_timezone(): ?string
{
@@ -2043,6 +2064,12 @@ class Member extends ActiveRecord
return false;
}
/* Is the member in a group that is allowed to be an expert? This setting might have been changed. */
if( Settings::i()->ips_experts_allowed_groups !== '*' and ! $this->inGroup( explode( ',', Settings::i()->ips_experts_allowed_groups ) ) )
{
$this->isExpert = false;
}
if ( $this->isExpert === null )
{
try
@@ -2343,7 +2370,7 @@ class Member extends ActiveRecord
{
if( $this->_url === NULL )
{
$this->_url = Url::internal( "app=core&module=members&controller=profile&id={$this->member_id}", 'front', 'profile', $this->members_seo_name );
$this->_url = Url::internal( "app=core&module=members&controller=profile&id={$this->member_id}", 'front', 'profile', ( $this->members_seo_name ?? '-' ) );
}
return $this->_url;
@@ -3172,6 +3199,21 @@ class Member extends ActiveRecord
return $this->_following[ $_key ];
}
/**
* Clear the cached result used by the following method
*
* @param string $app Application key
* @param string $area Area
* @param int $id Item ID
* @return void
*/
public function clearFollowingCache( string $app, string $area, int $id ): void
{
$key = md5( $app . $area . $id );
unset( $this->_following[ $key ] );
}
/**
* Admin CP Restrictions
*
@@ -3778,12 +3820,18 @@ class Member extends ActiveRecord
* @clientapiresponse datetime|null lastVisit Last distinct visit date on the site.
* @clientapiresponse datetime|null lastPost Latest content submission date.
* @apiresponse int profileViews Number of times member's profile has been viewed
* @clientapiresponse bool spammer Is the member flagged as a spammer?
* @clientapiresponse bool banned Is the member currently banned?
* @apiresponse string birthday Member birthday in MM/DD/YYYY format (or MM/DD format if no year has been supplied).
* @apiresponse array login_methods Array of login methods used <int, array{name: string, id: int|string}>, key is login method ID.
* @apiresponse [\IPS\core\ProfileFields\Api\FieldGroup] customFields Custom profile fields. For requests using an OAuth Access Token for a particular member, only fields the authorized user can view will be included
* @apiresponse [\IPS\core\Achievements\Rank] rank Rank
* @apiresponse int achievements_points Points
* @apiresponse bool allowAdminEmails Whether or not this member wants to receive admin emails
* @clientapiresponse bool allowAdminEmails Whether or not this member wants to receive admin emails
* @apiresponse bool completed Whether or not the registration is completed
* @clientapiresponse int totalMessages Total number of Personal Messages
* @clientapiresponse int unreadMessages Total number of unread Personal Messages
* @apiresponse [IPS\core\Achievements\Badge] badges All badges received by this member
*/
public function apiOutput( Member $authorizedMember = NULL, array $otherFields = NULL ): array
{
@@ -3837,7 +3885,7 @@ class Member extends ActiveRecord
$fields[ $profileFieldGroup ] = ( new FieldGroup( $this->language()->get( 'core_pfieldgroups_' . $profileFieldGroup ), $groupValues ) )->apiOutput( $authorizedMember );
}
}
catch( UnderflowException $e ) { } # Guests will not have any profile field information.
catch( UnderflowException $e ) { } # Guests will not have any profile field information
$return = array();
$return['id'] = $this->member_id;
@@ -3881,8 +3929,29 @@ class Member extends ActiveRecord
if( !$authorizedMember OR $authorizedMember->member_id === $this->member_id )
{
$return['birthday'] = $this->bday_month ? ( $this->bday_month . '/' . $this->bday_day . ( $this->bday_year ? '/' . $this->bday_year : '' ) ) : NULL;
/* Login Links */
$return['login_methods'] = [];
foreach( Db::i()->select( '*', 'core_login_links', [ 'token_member=? and token_linked=?', $this->member_id, 1 ] ) as $link )
{
/* Only include enabled methods */
if( isset( Login::methods()[ $link['token_login_method'] ] ) )
{
$return['login_methods'][ $link['token_login_method'] ] = [
'name' => Login::methods()[ $link['token_login_method'] ]->_title,
'id' => $link['token_identifier']
];
}
}
}
$return['profileViews'] = $this->members_profile_views;
if( $authorizedMember === null or $authorizedMember->member_id == $this->member_id )
{
$return['spammer'] = (bool) $this->members_bitoptions['bw_is_spammer'];
$return['banned'] = (bool) $this->isBanned();
}
$return['customFields'] = $fields;
if ( !$authorizedMember )
@@ -3898,9 +3967,29 @@ class Member extends ActiveRecord
$return['rank'] = $this->rank() ? $this->rank()->apiOutput( $authorizedMember ) : NULL;
$return['achievements_points'] = $this->achievements_points;
$return['allowAdminEmails'] = $this->allow_admin_mails;
if( $authorizedMember === null or $authorizedMember->member_id == $this->member_id )
{
$return['allowAdminEmails'] = $this->allow_admin_mails;
}
$return['completed'] = $this->completed;
$counts = \IPS\Db::i()->select( 'COUNT(map_id) as total_count, COUNT(CASE WHEN map_has_unread = 1 THEN 1 END) as unread_messages', 'core_message_topic_user_map', [ 'map_user_id=?', $this->member_id ] )->first();
if( $authorizedMember === null or $authorizedMember->member_id == $this->member_id )
{
$return['totalMessages'] = $counts['total_count'];
$return['unreadMessages'] = $counts['unread_messages'];
}
/* Add member badges */
$return['badges'] = array();
foreach( $this->recentBadges( NULL ) as $badge )
{
$return['badges'][] = $badge->apiOutput();
}
return $return;
}
@@ -4695,7 +4784,7 @@ class Member extends ActiveRecord
catch ( OutOfRangeException $e )
{
/* Log the error as part of the failure audit trail */
\IPS\Log::log( "Group promotion found a member (#{$this->member_id}: {$this->name} [Group ID {$this->member_group_id}] with an invalid member group", 'group_promotion' );
Log::log( "Group promotion found a member (#{$this->member_id}: {$this->name} [Group ID {$this->member_group_id}] with an invalid member group", 'group_promotion' );
return;
}
@@ -5223,11 +5312,27 @@ class Member extends ActiveRecord
*/
protected function _sendWelcomeEmail() : void
{
try
if( Settings::i()->reg_welcome_email )
{
Email::buildFromTemplate( 'core', 'registration_complete', array( $this ), Email::TYPE_TRANSACTIONAL )->send( $this );
if( $this->language()->checkKeyExists( 'reg_welcome_email_message' ) )
{
$message = $this->language()->get( 'reg_welcome_email_message' );
}
else
{
$message = $this->language()->addToStack( 'email_reg_complete', false, [ 'sprintf' => [ Settings::i()->board_name ] ] );
if( $this->members_pass_hash )
{
$message .= $this->language()->addToStack( 'email_reg_complete_pass' );
}
}
try
{
Email::buildFromTemplate( 'core', 'registration_complete', array( $this, $message ), Email::TYPE_TRANSACTIONAL )->send( $this );
}
catch( ErrorException $e ) { }
}
catch( ErrorException $e ) { }
}
/**
@@ -5264,7 +5369,7 @@ class Member extends ActiveRecord
return FALSE;
}
}
/**
* Profile Sync
*
@@ -5274,7 +5379,7 @@ class Member extends ActiveRecord
{
$profileSync = $this->profilesync;
foreach (Login::methods() as $method )
foreach ( Login::methods() as $method )
{
if ( $method->canProcess( $this ) )
{
@@ -5285,6 +5390,16 @@ class Member extends ActiveRecord
$profileSync[ $type ] = array( 'handler' => $method->_id, 'ref' => NULL, 'error' => NULL );
}
}
/* Perform any extra profile sync the login handler may need */
try
{
if( method_exists( $method, 'extraProfileSync' ) )
{
$method->extraProfileSync( $this );
}
}
catch( Login\Exception $e ) {}
}
}
@@ -5571,7 +5686,7 @@ class Member extends ActiveRecord
if ( $this->member_id )
{
/* Set this only if this was not called by the task system, otherwise we'll set the member who triggered the task as moderator */
if ( Dispatcher::hasInstance() and !Dispatcher::i()->inDestructor and $by === NULL )
if ( Dispatcher::hasInstance() and isset( Dispatcher::i()->inDestructor ) and !Dispatcher::i()->inDestructor and $by === NULL )
{
$by = Session::i()->member; // Not \IPS\Member::loggedIn() because if this is an admin logged in as a member, we want to log that the action was done by the admin
}
@@ -6300,6 +6415,12 @@ class Member extends ActiveRecord
continue;
}
/* Cloudy stuff */
if( !Bridge::i()->checkAchievementRule( array_merge( [ 'action' => "{$app}_{$extension}" ], $ruleData ), $this ) )
{
continue;
}
if ( !$ruleData['filters'] or $extensionObject->filtersMatch( $this, $ruleData['filters'], $extra ) )
{
/* Did we already award for this milestone? */
@@ -6364,6 +6485,9 @@ class Member extends ActiveRecord
}
}
}
/* Fire the post-achievement here */
Bridge::i()->postAchievement( $ruleId, array_merge( [ 'action' => "{$app}_{$extension}" ], $ruleData ), $this, $extra );
}
}
@@ -6631,7 +6755,7 @@ class Member extends ActiveRecord
{
$lastLogin = Db::i()->select( 'member_timestamp', 'core_members_logins', [ 'member_id=?', $this->member_id ], 'member_timestamp DESC', 1 )->first();
}
catch( \UnderflowException $e )
catch( UnderflowException $e )
{
/* This is the first login, or they haven't logged in since upgrading from < 106100 */
try
@@ -6642,7 +6766,7 @@ class Member extends ActiveRecord
return false;
}
}
catch( \UnderflowException $e )
catch( UnderflowException $e )
{
/* This could be a new install, or IN_DEV where upgrade history isn't available */
return false;
@@ -6658,7 +6782,7 @@ class Member extends ActiveRecord
}
/* If this login is within the period, don't send an email */
if( $lastLoginObj > DateTime::create()->sub( new \DateInterval( static::LOGIN_INACTIVITY_NOTIFICATION ) ) )
if( $lastLoginObj > DateTime::create()->sub( new DateInterval( static::LOGIN_INACTIVITY_NOTIFICATION ) ) )
{
return false;
}
@@ -6669,7 +6793,7 @@ class Member extends ActiveRecord
{
$location = GeoLocation::getRequesterLocation();
}
catch ( \Exception $e )
catch ( Exception $e )
{
$location = NULL;
}
+22 -2
View File
@@ -95,8 +95,8 @@ class Team extends ActiveRecord
return iterator_to_array(
new ActiveRecordIterator(
Db::i()->select( '*', 'core_members', Db::i()->in( 'member_id', $this->members ), 'name' ),
Member::class
)
Member::class,
),
);
}
@@ -168,4 +168,24 @@ class Team extends ActiveRecord
return static::$_teams;
}
/**
* Get output for API
*
* @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials
* @return array
* @apiresponse string name Title
* @apiresponse \IPS\Member members The team members
*/
public function apiOutput( Member $authorizedMember = NULL ): array
{
$response = [];
$response['id'] = $this->id;
$response['name'] = $this->name;
foreach( $this->members() as $member )
{
$response['members'][$member->member_id] = $member->apiOutput( $authorizedMember );
}
return $response;
}
}
Loaded 100 of 1407 files, more files were not shown because too many files have changed in this diff. Show more