Version 5.0.13
This commit is contained in:
1 parent
b89858b2ff
commit
5efe744c5f
1407 files changed
+69690
-30177
No files matched your search
-3
@@ -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
|
||||
Vendored
-3
@@ -1,4 +1 @@
|
||||
patreon: FlorentMorselli
|
||||
|
||||
|
||||
# 315662YNVPFR37N8S30
|
||||
Vendored
-3
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+557
-486
File diff suppressed because it is too large.
Load diff
@@ -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 )
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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 ){}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
@@ -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,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 ),
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 )
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 ) ) );
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 )
|
||||
|
||||
@@ -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
@@ -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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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() );
|
||||
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 )
|
||||
{
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) );
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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( "'", "'", 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( "'", "'", 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( "'", "'", 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( "'", "'", 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 ) {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' )
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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] )
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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 ) { }
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ abstract class MFAHandler
|
||||
/**
|
||||
* Get handlers
|
||||
*
|
||||
* @return array
|
||||
* @return array<MFAHandler>
|
||||
*/
|
||||
public static function handlers(): array
|
||||
{
|
||||
|
||||
@@ -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
@@ -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 ){}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user