Version 5.0.13

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

No files matched your search

+2 -2
View File
@@ -533,9 +533,9 @@ class CommentController extends Controller
try
{
$index = Report::loadByClassAndId( get_class( $object ), $object->$idColumn );
$report = Db::i()->select( '*', 'core_rc_reports', array( 'rid=? and report_by=? and date_reported > ?', $index->id, $member, time() - ( Settings::i()->automoderation_report_again_mins * 60 ) ) )->first();
$report = Db::i()->select( '*', 'core_rc_reports', ['rid=? and report_by=? and date_reported > ?', $index->id, $member->member_id, time() - ( Settings::i()->automoderation_report_again_mins * 60 )] )->first();
/* They have aleady reported, so do nothing */
/* They have already reported, so do nothing */
throw new Exception( 'REPORTED_ALREADY', '1S425/C', 404 );
}
catch( \Exception $e )
+20 -4
View File
@@ -231,10 +231,19 @@ class ItemController extends Controller
$class = $this->class;
$item = $class::load( $id );
if ( $this->member and !$item->can( 'read', $this->member ) )
if( $this->member )
{
throw new OutOfRangeException;
if ( method_exists($item, 'canView') and !$item->canView( $this->member ) )
{
throw new OutOfRangeException;
}
else if ( !$item->can( 'read', $this->member ) )
{
throw new OutOfRangeException;
}
}
return new Response( 200, $item->apiOutput( $this->member ) );
}
@@ -571,9 +580,16 @@ class ItemController extends Controller
* @var Comment $commentClass */
$itemClass = $this->class;
$item = $itemClass::load( $id );
if ( $this->member and !$item->can( 'read', $this->member ) )
if( $this->member )
{
throw new OutOfRangeException;
if ( method_exists($item, 'canView') and !$item->canView( $this->member ) )
{
throw new OutOfRangeException;
}
else if ( !$item->can( 'read', $this->member ) )
{
throw new OutOfRangeException;
}
}
$itemIdColumn = $itemClass::$databaseColumnId;
$where [] = array( $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['item'] . '=?', $item->$itemIdColumn );
+12 -1
View File
@@ -15,6 +15,7 @@ namespace IPS\Content;
/* To prevent PHP errors (extending class does not exist) revealing path */
use DomainException;
use IPS\Api\Webhook;
use IPS\core\Assignments\Assignment;
use IPS\Data\Cache;
use IPS\Data\Store;
@@ -94,6 +95,12 @@ trait Assignable
return false;
}
/* Extensions go first */
if( $permCheck = Permissions::can( 'assign', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
return $this->containerAllowsAssignable() AND static::modPermission( 'assign', $member, $this->container() );
}
@@ -290,6 +297,8 @@ trait Assignable
$this->save();
}
Webhook::fire( 'content_assigned', $assignment );
return $assignTo;
}
@@ -302,7 +311,9 @@ trait Assignable
{
try
{
Assignment::loadByItem( $this )->close();
$assignment = Assignment::loadByItem( $this );
$assignment->close();
Webhook::fire( 'content_unassigned', $assignment );
}
catch( OutOfRangeException ){}
}
+62 -11
View File
@@ -20,11 +20,13 @@ use IPS\Helpers\Form\Text;
use IPS\Lang;
use IPS\Member;
use IPS\Member\Club;
use IPS\Member\Club\Template;
use IPS\Node\Model;
use IPS\Output;
use IPS\Output\UI\UiExtension;
use IPS\Request;
use IPS\Settings;
use IPS\Task;
use OutOfRangeException;
use UnderflowException;
use function count;
@@ -107,8 +109,6 @@ trait ClubContainer
*/
public function club(): Club|NULL
{
if ( Settings::i()->clubs )
{
$clubIdColumn = $this->clubIdColumn();
if ( $this->$clubIdColumn )
{
@@ -118,7 +118,6 @@ trait ClubContainer
}
catch ( OutOfRangeException $e ) { }
}
}
return NULL;
}
@@ -179,9 +178,10 @@ trait ClubContainer
*
* @param Club $club The club
* @param array $values Values
* @param Template|null $template
* @return void
*/
public function saveClubForm( Club $club, array $values ): void
public function saveClubForm( Club $club, array $values, ?Template $template = null ): void
{
/* @var Model $nodeClass */
$nodeClass = get_called_class();
@@ -219,11 +219,18 @@ trait ClubContainer
'node_class' => $nodeClass,
'node_id' => $this->_id,
'name' => $values['club_node_name'],
'public' => $values['club_node_public'] ?? 0,
'creators' => $values['club_node_creators'] ?? 0
'public' => $values['club_node_public'] ?? Club::NODE_PRIVATE,
'creators' => $values['club_node_creators'] ?? 0,
'template_id' => $template ? $template->id : 0
) );
$needToUpdatePermissions = TRUE;
/* If auto-follow is enabled in the club, queue the task so all members will follow the node */
if( $this->auto_follow )
{
Task::queue( 'core', 'AutoFollowClubs', [ 'club' => $this->club()->id, 'node' => $nodeClass, 'nodeId' => $this->_id ], 3, [ 'club', 'node', 'nodeId' ] );
}
}
else
{
@@ -235,7 +242,7 @@ trait ClubContainer
$this->save();
Db::i()->update( 'core_clubs_node_map', array(
'name' => $values['club_node_name'],
'public' => isset( $values['club_node_public'] ) ? (int) $values['club_node_public'] : 0,
'public' => isset( $values['club_node_public'] ) ? (int) $values['club_node_public'] : Club::NODE_PRIVATE,
'creators' => isset( $values['club_node_creators'] ) ? (int) $values['club_node_creators'] : 0
), array( 'club_id=? AND node_class=? AND node_id=?', $club->id, $nodeClass, $this->_id ) );
}
@@ -313,10 +320,21 @@ trait ClubContainer
$insert[ 'perm_' . $v ] = '*';
break;
case $club::TYPE_CLOSED:
$insert[ 'perm_' . $v ] = ( $this->isPublic() ) ? "*" : "cm,c{$club->id}";
switch( $this->isPublic() )
{
case Club::NODE_PRIVATE:
$insert['perm_' . $v ] = "cm,c{$club->id}";
break;
case Club::NODE_PUBLIC:
$insert['perm_' . $v ] = '*';
break;
case Club::NODE_MODERATORS:
$insert['perm_' . $v ] = 'cm';
break;
}
break;
case $club::TYPE_PRIVATE:
$insert[ 'perm_' . $v ] = "cm,c{$club->id}";
$insert[ 'perm_' . $v ] = ( $this->isPublic() == Club::NODE_MODERATORS ) ? "cm" : "cm,c{$club->id}";
break;
}
}
@@ -492,7 +510,7 @@ trait ClubContainer
return TRUE;
}
}
elseif ( $member->modPermission( "can_{$type}_{$title}" ) and is_array( $member->modPermission( static::$modPerm ) ) and $member->modPermission('can_access_all_clubs') )
elseif ( $member->modPermission( "can_{$type}_{$title}" ) and ( is_array( $member->modPermission( static::$modPerm ) ) or $member->modPermission( static::$modPerm ) === true ) and $member->modPermission('can_access_all_clubs') )
{
if ( in_array( $type, explode( ',', Settings::i()->clubs_modperms ) ) )
{
@@ -518,7 +536,7 @@ trait ClubContainer
}
catch ( UnderflowException $e )
{
return 0;
return Club::NODE_PRIVATE;
}
}
@@ -538,4 +556,37 @@ trait ClubContainer
return 0;
}
}
/**
* @return Template|null
*/
public function template() : ?Template
{
try
{
$templateId = Db::i()->select( 'template_id', 'core_clubs_node_map', [ 'node_class=? and node_id=?', get_called_class(), $this->_id ] )->first();
return Template::load( $templateId );
}
catch( UnderflowException | OutOfRangeException ){}
return null;
}
/**
* Determines if the club leader can manage this node
*
* @return bool
*/
public function canLeaderManage() : bool
{
if( $template = $this->template() )
{
if( array_key_exists( 'clubtemplates_leader_edit', $template->node_data ) )
{
return $template->node_data['clubtemplates_leader_edit'];
}
}
return true;
}
}
+73 -54
View File
@@ -291,6 +291,31 @@ abstract class Comment extends Content
$member->save();
}
/* Send webhook */
if ( IPS::classUsesTrait( $obj, 'IPS\Content\Hideable' ) and in_array( $obj->hidden(), array( -1, 0, 1 ) ) ) // i.e. not post before register or pending deletion
{
Webhook::fire( str_replace( '\\', '', substr( get_called_class(), 3 ) ) . '_create', $obj, $obj->webhookFilters() );
}
/* DataLayer Event */
if ( DataLayer::enabled() and static::dataLayerEventActive( 'comment_create' ) and !( $item::$firstCommentRequired and $obj->isFirst() ) )
{
$obj->_dataLayerProperties = [];
$event = 'comment_create';
$properties = $obj->getDataLayerProperties();
/* todo this should probably be deleted as I'm not sure why it's here. Leaving here for a reference just in case */
// if ( $properties['content_area'] === 'personal_messages' )
// {
// return NULL;
// }
DataLayer::i()->addEvent( $event, $properties );
}
/* Update item */
$obj->postCreate();
/* Send notifications and dish out points */
if ( !in_array( 'IPS\Content\Review', class_parents( get_called_class() ) ) )
{
@@ -307,34 +332,6 @@ abstract class Comment extends Content
$obj->sendUnapprovedNotification();
}
}
/* Send webhook */
if ( IPS::classUsesTrait( $obj, 'IPS\Content\Hideable' ) and in_array( $obj->hidden(), array( -1, 0, 1 ) ) ) // i.e. not post before register or pending deletion
{
Webhook::fire( str_replace( '\\', '', substr( get_called_class(), 3 ) ) . '_create', $obj, $obj->webhookFilters() );
}
/* DataLayer Event */
if ( Settings::i()->core_datalayer_enabled and !( $item::$firstCommentRequired and $obj->isFirst() ) )
{
$event = 'comment_create';
$properties = $obj->getDataLayerProperties();
if ( $properties['content_area'] === 'personal_messages' )
{
return NULL;
}
$properties = array_merge( $properties, array(
'author_id' => $obj->author()->member_id,
'author_name' => $obj->author()->name ?? 'Guest',
) );
DataLayer::i()->addEvent( $event, $properties );
}
/* Update item */
$obj->postCreate();
/* This needs to happen before the index is added */
if( $item::$firstCommentRequired and $obj->isFirst() )
@@ -372,13 +369,7 @@ abstract class Comment extends Content
{
if ( empty( $this->_dataLayerProperties ) )
{
$item = $this->item();
$this->_dataLayerProperties = $item->getDataLayerProperties( $this );
if ( $this->_dataLayerProperties['content_type'] === 'question' )
{
$this->_dataLayerProperties['comment_type'] = 'answer';
}
$this->_dataLayerProperties = DataLayer::i()->filterProperties( $this->_dataLayerProperties );
$this->_dataLayerProperties = $this->item()->getDataLayerProperties( $this, clearCache: true );
}
return $this->_dataLayerProperties;
@@ -728,6 +719,12 @@ abstract class Comment extends Content
*/
public function canView( Member|null $member= null ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'view', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( $member === NULL )
{
$member = Member::loggedIn();
@@ -749,6 +746,12 @@ abstract class Comment extends Content
*/
public function canEdit( Member|null $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'edit', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'edit', $member ) )
{
return false;
@@ -805,6 +808,12 @@ abstract class Comment extends Content
*/
public function canDelete( Member|null $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'delete', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'delete', $member ) )
{
return false;
@@ -823,6 +832,12 @@ abstract class Comment extends Content
*/
public function canSplit( Member|null $member=null ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'split', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'split', $member ) )
{
return false;
@@ -869,14 +884,7 @@ abstract class Comment extends Content
*/
public function dateLine(): string
{
if( $this->mapped('first') )
{
return Member::loggedIn()->language()->addToStack( static::$formLangPrefix . 'date_started', FALSE, array( 'htmlsprintf' => array( DateTime::ts( $this->mapped('date') )->html() ) ) );
}
else
{
return Member::loggedIn()->language()->addToStack( static::$formLangPrefix . 'date_replied', FALSE, array( 'htmlsprintf' => array( DateTime::ts( $this->mapped('date') )->html() ) ) );
}
return Member::loggedIn()->language()->addToStack( static::$formLangPrefix . 'date_replied', FALSE, array( 'htmlsprintf' => array( DateTime::ts( $this->mapped('date') )->html() ) ) );
}
/**
@@ -1014,11 +1022,8 @@ abstract class Comment extends Content
Db::i()->update( 'core_reputation_index', array( 'item_id' => $item->$idColumn ), array( 'class_type_id_hash=?', md5( get_class( $this ) . ':' . $oldItem->$idColumn ) ) );
}
/* Update solved */
if ( IPS::classUsesTrait( $this, 'IPS\Content\Solvable' ) )
{
Db::i()->update( 'core_solved_index', array( 'item_id' => $item->$idColumn ), array( 'comment_class=? and comment_id=?', get_class( $this ), $oldItem->$idColumn ) );
}
/* Update solved - do this without checking for the trait, because multiple traits use it */
Db::i()->update( 'core_solved_index', array( 'item_id' => $item->$idColumn ), array( 'comment_class=? and comment_id=?', get_class( $this ), $this->$commentIdColumn ) );
/* Update member map */
try
@@ -1037,6 +1042,13 @@ abstract class Comment extends Content
if( $skip === FALSE )
{
/* Update Helpfuls */
if( IPS::classUsesTrait( $this, Helpful::class ) and isset( $item::$databaseColumnMap['num_helpful'] ) )
{
$oldItem->recountHelpfuls();
$item->recountHelpfuls();
}
$oldItem->rebuildFirstAndLastCommentData();
$item->rebuildFirstAndLastCommentData();
@@ -1119,7 +1131,7 @@ abstract class Comment extends Content
}
/* Remove featured comment associations */
if( $item and IPS::classUsesTrait( $item, 'IPS\Content\MetaData' ) and $this->isFeatured() )
if( $item and $this->isFeaturedComment() )
{
Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->unfeatureComment( $item, $this );
}
@@ -1186,16 +1198,23 @@ abstract class Comment extends Content
if ( $item->mapped('solved_comment_id') and $item->mapped('solved_comment_id') == $this->$idColumn )
{
Db::i()->update( $itemClass::$databaseTable, array( $itemClass::$databaseColumnMap['solved_comment_id'] => 0 ), array( $itemIdColumn . '=?', $itemId ) );
Db::i()->delete( 'core_solved_index', array( 'app=? and comment_class=? and item_id=? and comment_id=?', static::$application, get_called_class(), $itemId, $this->$idColumn ) );
}
}
/* Clear the solved index regardless of trait; multiple traits use it */
Db::i()->delete( 'core_solved_index', array( 'app=? and comment_class=? and item_id=? and comment_id=?', static::$application, get_called_class(), $itemId, $this->$idColumn ) );
if ( $item AND IPS::classUsesTrait( $this->item(), 'IPS\Content\Statistics' ) )
{
$item->clearCachedStatistics( TRUE );
}
/* Update helpfuls */
if( IPS::classUsesTrait( $this, Helpful::class ) and IPS::classUsesTrait( $item, Helpful::class ) )
{
$item->recountHelpfuls();
}
/* Delete any notifications telling people about this */
$memberIds = array();
@@ -1516,7 +1535,7 @@ abstract class Comment extends Content
/* @var array $databaseColumnMap */
/* Finally, we do not want posts from future items */
if ( in_array( 'IPS\Content\FuturePublishing', class_implements( $itemClass ) ) )
if( IPS::classUsesTrait( $itemClass, FuturePublishing::class ) )
{
$itemWhere[] = array( $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['is_future_entry'] . '=0' );
}
@@ -1739,9 +1758,9 @@ abstract class Comment extends Content
*/
public function isFeaturedComment(): bool
{
if ( IPS::classUsesTrait( $this->item(), 'IPS\Content\MetaData' ) )
if ( !( $this instanceof Review ) and IPS::classUsesTrait( $this->item(), 'IPS\Content\MetaData' ) )
{
return Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->isFeaturedComment( $this );
return Application::load('core')->extensions( 'core', 'MetaData' )['FeaturedComments']->isCommentShownAtTheTop( $this );
}
return FALSE;
+68 -3
View File
@@ -25,7 +25,9 @@ use IPS\Content\Item;
use IPS\Content\Review;
use IPS\Content\Solvable;
use IPS\core\Approval;
use IPS\core\DataLayer;
use IPS\core\Reports\Report;
use IPS\core\Reports\Types;
use IPS\Db\Select;
use IPS\Events\Event;
use IPS\Http\Url;
@@ -76,6 +78,7 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
* @mixin \IPS\Content\EditHistory
* @mixin \IPS\Content\Followable
* @mixin \IPS\Content\FuturePublishing
* @mixin \IPS\Content\Helpful
* @mixin \IPS\Content\Hideable
* @mixin \IPS\Content\ItemTopic
* @mixin \IPS\Content\Lockable
@@ -125,6 +128,12 @@ abstract class Content extends ActiveRecord
*/
public static bool $canBeModeratedFromFrontend = TRUE;
/**
* An array containing data layer event keys to bypass. Optionally, this can be set to 'true' to bypass all events.
* @var bool|string[]
*/
protected static array|bool $_bypassDataLayerEvents = [];
/**
* Should posting this increment the poster's post count?
*
@@ -680,6 +689,7 @@ abstract class Content extends ActiveRecord
Webhook::fire( str_replace( '\\', '', substr( get_called_class(), 3 ) ) . '_modaction', ['action' => $action, 'item' => $this->apiOutput()], $this->webhookFilters() );
/* These ones just need a property setting */
$dataLayerEvent = null;
if ( in_array( $action, array( 'pin', 'unpin', 'feature', 'unfeature', 'lock', 'unlock' ) ) )
{
$val = TRUE;
@@ -714,6 +724,11 @@ abstract class Content extends ActiveRecord
$this->$column = $val;
$this->save();
if ( $action === 'pin' or $action === 'unpin' )
{
$dataLayerEvent = $action === 'pin' ? 'pin' : 'unpin';
}
/* Fire events for these here, the others will be fired at a later point */
Event::fire( 'onStatusChange', $this, array( $action ) );
}
@@ -721,10 +736,12 @@ abstract class Content extends ActiveRecord
/* Hide is a tiny bit more complicated */
elseif ( $action === 'hide' )
{
$dataLayerEvent = 'hide';
$this->hide( $member, $reason );
}
elseif ( $action === 'unhide' )
{
$dataLayerEvent = 'unhide';
$this->unhide( $member );
}
@@ -737,8 +754,8 @@ abstract class Content extends ActiveRecord
$this->logDelete( $member );
return;
}
$idColumn = static::$databaseColumnId;
$dataLayerEvent = 'delete';
$this->delete();
}
@@ -769,6 +786,11 @@ abstract class Content extends ActiveRecord
$this->move( $args[2][0], $args[2][1] );
}
}
if ( $dataLayerEvent and DataLayer::enabled( 'analytics_full' ) and static::dataLayerEventActive( 'content_' . $dataLayerEvent ) )
{
DataLayer::i()->addEvent( 'content_' . $dataLayerEvent, $this->getDataLayerProperties() );
}
}
/**
@@ -902,6 +924,7 @@ abstract class Content extends ActiveRecord
$item = ( $this instanceof Comment ) ? $this->item() : $this;
$itemIdColumn = $item::$databaseColumnId;
$new = false;
try
{
$index = Report::load( $this->$idColumn, 'content_id', array( 'class=?', get_called_class() ) );
@@ -909,6 +932,7 @@ abstract class Content extends ActiveRecord
}
catch ( OutOfRangeException $e )
{
$new = true;
$index = new Report;
$index->class = get_called_class();
$index->content_id = $this->$idColumn;
@@ -991,7 +1015,7 @@ abstract class Content extends ActiveRecord
}
}
$notification = new Notification( Application::load('core'), 'report_center', $index, array( $index, $reportInsert, $this ) );
$notification = new Notification( Application::load('core'), 'report_center', $index, array( $index, $reportInsert, $this, $member ) );
foreach (Db::i()->select( '*', 'core_members', ( count( $moderators['m'] ) ? Db::i()->in( 'member_id', $moderators['m'] ) . ' OR ' : '' ) . Db::i()->in( 'member_group_id', $moderators['g'] ) . ' OR ' . Db::i()->findInSet( 'mgroup_others', $moderators['g'] ) ) as $mem )
{
$memberObj = Member::constructFromData( $mem );
@@ -1022,6 +1046,33 @@ abstract class Content extends ActiveRecord
$this->alreadyReported = TRUE;
$this->reportData = $reportInsert;
/* If this is new, we need to add a data layer event */
if ( $index->status === 1 and DataLayer::enabled( 'analytics_full' ) and static::dataLayerEventActive( 'content_report' ) )
{
$properties = $this->getDataLayerProperties();
try
{
$reportNode = ( ( $member->member_id or !Settings::i()->automoderation_enabled ) and $reportType > 0 ) ? Types::load( $reportType ) : null;
}
catch ( Exception )
{
$reportNode = null;
}
$properties['report_type_name'] = Lang::load( Lang::defaultLanguage() )->addToStack( $reportNode?->_titleLanguageKey ?: ( $this instanceof Content\Comment ? 'report_message_comment' : 'report_message_item' ) );
$properties['report_type_id'] = $reportNode ? $reportType : 0;
Lang::load( Lang::defaultLanguage() )->parseOutputForDisplay( $properties['report_type_name'] );
DataLayer::i()->addEvent( 'content_report', DataLayer::i()->filterProperties( $properties ) );
}
if( $new )
{
Webhook::fire( 'content_reported', $index);
Event::fire( 'onReport', $this, [ $index ] );
}
/* Return */
return $index;
}
@@ -2002,4 +2053,18 @@ abstract class Content extends ActiveRecord
return (string) $url;
}
/**
* Check whether an event is active for this content type based on the event key. For example, reports don't fire any content_* or comment_* events. This uses the $_bypassDataLayerEvents static property
*
* @see Content::$_bypassDataLayerEvents
*
* @param string $eventKey The data layer event key.
*
* @return bool
*/
public static function dataLayerEventActive( string $eventKey ) : bool
{
return empty( static::$_bypassDataLayerEvents ) or ( is_array( static::$_bypassDataLayerEvents ) and !in_array( $eventKey, static::$_bypassDataLayerEvents ) );
}
}
+50 -28
View File
@@ -120,6 +120,7 @@ class Controller extends CoverPhotoController
/* Ensure JS loaded for forms/content functions such as moderation */
Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'front_core.js', 'core' ) );
Output::i()->bodyAttributes['contentClass'] = static::$contentModel;
parent::execute();
@@ -152,6 +153,7 @@ class Controller extends CoverPhotoController
try
{
$class = static::$contentModel;
/**@var Content $item */
$item = $class::loadAndCheckPerms( Request::i()->id );
/* Have we moved? If a user has loaded a forum, and a moderator merges two topics leaving a link, then we need to account for this if the
@@ -215,9 +217,8 @@ class Controller extends CoverPhotoController
}
/* Get ready to store data layer info */
$dataLayer = Settings::i()->core_datalayer_enabled;
$dataLayerProperties = array();
if ( $dataLayer )
if ( DataLayer::enabled() )
{
$dataLayerProperties = $item->getDataLayerProperties();
}
@@ -343,9 +344,9 @@ class Controller extends CoverPhotoController
);
/* Data Layer */
if ( $dataLayer )
if ( DataLayer::enabled() )
{
if ( !Request::i()->isAjax() )
if ( !Request::i()->isAjax() and $item::dataLayerEventActive( 'content_view' ) )
{
DataLayer::i()->addEvent( 'content_view', $dataLayerProperties );
}
@@ -414,9 +415,17 @@ class Controller extends CoverPhotoController
{
try
{
if ( ! is_int( Request::i()->id ) )
{
throw new BadMethodCallException( $e );
}
Output::i()->redirect( $class::getRedirectFrom( Request::i()->id )->url() );
}
catch( OutOfRangeException $e ) { }
catch( BadMethodCallException|UnderflowException $e )
{
/* There is no valid redirect, so we will just show an error */
Output::i()->error( 'page_doesnt_exist', '2S136/1Z', 404, '' );
}
return NULL;
}
@@ -460,9 +469,9 @@ class Controller extends CoverPhotoController
/**
* AJAX - check for new replies
*
* @return Item|NULL
* @return void
*/
protected function checkForNewReplies(): Item|NULL
protected function checkForNewReplies(): void
{
Session::i()->csrfCheck();
@@ -1610,7 +1619,7 @@ class Controller extends CoverPhotoController
'blurb' => ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $item )
);
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() )
{
$output['datalayer'] = array_replace( $item->getDataLayerProperties(), ['reaction_type' => $reaction->_title] );
}
@@ -1619,7 +1628,7 @@ class Controller extends CoverPhotoController
}
else
{
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() )
{
DataLayer::i()->addEvent( 'content_react', array_replace( $item->getDataLayerProperties(), [ 'reaction_type' => $reaction->_title ] ) );
}
@@ -1750,10 +1759,12 @@ class Controller extends CoverPhotoController
{
$class = static::$contentModel;
$item = $class::loadAndCheckPerms( Request::i()->id );
$reactionId = isset( Request::i()->reaction ) ? (int) Request::i()->reaction : null;
if ( Request::i()->isAjax() and isset( Request::i()->tooltip ) and isset( Request::i()->reaction ) )
if ( Request::i()->isAjax() and isset( Request::i()->tooltip ) and !empty( $reactionId ) )
{
$reaction = Reaction::load( Request::i()->reaction );
$reaction = Reaction::load( $reactionId );
$numberToShowInPopup = 10;
$where = $item->getReactionWhereClause( $reaction );
@@ -1779,12 +1790,8 @@ class Controller extends CoverPhotoController
$tabs[ $reaction->id ] = array( 'title' => $reaction->_title, 'icon' => $reaction->_icon, 'count' => $blurb[$reaction->id] ?? 0 );
}
}
$activeTab = 'all';
if ( isset( Request::i()->reaction ) )
{
$activeTab = Request::i()->reaction;
}
$activeTab = $reactionId ?: 'all';
$url = $item->url('showReactions');
$url = $url->setQueryString( 'changed', 1 );
@@ -3017,7 +3024,13 @@ class Controller extends CoverPhotoController
/* Moderator log */
Session::i()->modLog( 'modlog__comment_edit', array( $comment->url()->__toString() => FALSE, $item::$title => TRUE, $item->url()->__toString() => FALSE, $item->mapped( 'title' ) => FALSE ), $item );
/* Add a data layer event indicating this was updated */
if ( DataLayer::enabled( 'analytics_full' ) and $comment::dataLayerEventActive( 'content_edit' ) )
{
DataLayer::i()->addEvent( 'content_edit', $comment->getDataLayerProperties() );
}
/* If this is an AJAX request and the comment hidden status has not changed just output the comment HTML */
if ( Request::i()->isAjax() AND $isHidden == $comment->hidden() AND $reloadPage === false )
{
@@ -3117,6 +3130,12 @@ class Controller extends CoverPhotoController
$item->topic_answered_pid = 0;
$item->save();
}
/* This doesn't go through modAction, so we will add the datalayer event here */
if ( $comment and DataLayer::enabled( 'analytics_full' ) and $comment::dataLayerEventActive( 'content_delete' ) )
{
DataLayer::i()->addEvent( 'content_delete', $comment->getDataLayerProperties() );
}
if ( Request::i()->isAjax() )
{
@@ -3239,6 +3258,8 @@ class Controller extends CoverPhotoController
$comment->save();
}
Event::fire( 'onItemSplit', $item, array( $oldItem ) );
}
else
{
@@ -3520,7 +3541,7 @@ class Controller extends CoverPhotoController
'blurb' => ( Settings::i()->reaction_count_display == 'count' ) ? '' : Theme::i()->getTemplate( 'global', 'core' )->reactionBlurb( $comment )
);
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() )
{
$item = $item ?? $comment->item();
$object = ( !isset( $item::$databaseColumnMap['content'] ) AND ( $item::$commentClass ) AND $comment->isFirst() ) ? $item : $comment;
@@ -3533,7 +3554,7 @@ class Controller extends CoverPhotoController
else
{
/* Data Layer Event */
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() )
{
$properties = $comment->getDataLayerProperties();
$properties['reaction_type'] = $reaction->_title;
@@ -3651,10 +3672,11 @@ class Controller extends CoverPhotoController
{
/* @var Comment $commentClass */
$idColumn = $commentClass::$databaseColumnId;
if ( Request::i()->isAjax() and isset( Request::i()->tooltip ) and isset( Request::i()->reaction ) )
$reactionId = isset( Request::i()->reaction ) ? (int) Request::i()->reaction : null;
if ( Request::i()->isAjax() and isset( Request::i()->tooltip ) and !empty( $reactionId ) )
{
$reaction = Reaction::load( Request::i()->reaction );
$reaction = Reaction::load( $reactionId );
$numberToShowInPopup = 10;
$where = $comment->getReactionWhereClause( $reaction );
@@ -3680,11 +3702,7 @@ class Controller extends CoverPhotoController
}
}
$activeTab = 'all';
if ( isset( Request::i()->reaction ) )
{
$activeTab = Request::i()->reaction;
}
$activeTab = $reactionId ?: 'all';
$url = $comment->url('showReactions');
$url = $url->setQueryString( 'changed', 1 );
@@ -3721,6 +3739,10 @@ class Controller extends CoverPhotoController
Session::i()->csrfCheck();
$checkAgainst = Request::i()->modaction;
if( !$checkAgainst )
{
throw new DomainException;
}
$classToCheck = $commentClass;
if( $checkAgainst == 'split' OR $checkAgainst == 'merge' )
+12 -3
View File
@@ -12,6 +12,7 @@ namespace IPS\Content;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Content\Item;
use IPS\IPS;
use IPS\Settings;
use IPS\Theme;
@@ -90,15 +91,23 @@ trait EditHistory
{
$logEdit = 'comment_log_edit';
$editReason = 'comment_edit_reason';
$column = 'comment_value';
$column = static::$formLangPrefix . 'comment_value';
/* If this is the first comment and the first comment is required, the content will be in a different array element */
/* @var Item $itemClass */
$itemClass = static::$itemClass;
if( $itemClass::$firstCommentRequired and $this->isFirst() )
{
$column = $itemClass::$formLangPrefix . 'content';
}
}
$editIsPublic = Member::loggedIn()->group['g_append_edit'] ? $values[ $logEdit ] : TRUE;
$idField = static::$databaseColumnId;
if ( Settings::i()->edit_log == 2 and isset( $values[static::$formLangPrefix . $column] ) )
if ( Settings::i()->edit_log == 2 and isset( $values[ $column ] ) )
{
$content = $values[static::$formLangPrefix . $column];
$content = $values[ $column ];
Db::i()->insert( 'core_edit_history', array(
'class' => get_class( $this ),
+12 -3
View File
@@ -13,13 +13,13 @@ namespace IPS\Content;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\core\Feature;
use IPS\Events\Event;
use IPS\File;
use IPS\IPS;
use IPS\Member;
use IPS\Patterns\ActiveRecordIterator;
use OutOfRangeException;
use function class_implements;
use function get_called_class;
use function in_array;
if( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
@@ -37,6 +37,12 @@ trait Featurable
*/
public function canFeature( ?Member $member=NULL ) : bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'feature', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'feature', $member ) )
{
return false;
@@ -113,6 +119,9 @@ trait Featurable
$this->$featuredColumn = $featured;
$this->save();
/* Fire an event */
Event::fire( 'onStatusChange', $this, array( ( $featured ? 'feature' : 'unfeature' ) ) );
}
return true;
@@ -130,7 +139,7 @@ trait Featurable
{
$where = array( array( static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnMap['featured'] . '=?', 1 ) );
if ( in_array( 'IPS\Content\FuturePublishing', class_implements( get_called_class() ) ) )
if( IPS::classUsesTrait( get_called_class(), FuturePublishing::class ) )
{
$where[] = array( static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnMap['is_future_entry'] . '=?', 0 );
}
+111
View File
@@ -13,8 +13,11 @@ namespace IPS\Content;
use ArrayObject;
use BadMethodCallException;
use IPS\Content;
use IPS\core\Followed\Follow;
use IPS\Db;
use IPS\Events\Event;
use IPS\IPS;
use IPS\Member;
use IPS\Notification;
use IPS\Application;
use IPS\DateTime;
@@ -22,6 +25,7 @@ use IPS\Node\Model;
use IPS\Db\Select;
use ArrayIterator;
use Iterator;
use OutOfRangeException;
use UnderflowException;
use function defined;
use function header;
@@ -44,6 +48,113 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
*/
trait Followable
{
/**
* Follow an object. Using a static method here
* because Model does not use the Followable trait
*
* @param object $object
* @param string $frequency
* @param bool $public
* @param Member|null $member
* @return void
*/
public static function staticFollow( object $object, string $frequency, bool $public=true, ?Member $member=null ) : void
{
$member = $member ?: Member::loggedIn();
try
{
/* Are we already following this? */
$follow = Follow::loadByObject( $object, $member );
}
catch( OutOfRangeException )
{
$follow = new Follow;
$follow->object = $object;
$follow->member_id = $member->member_id;
}
$follow->is_anon = !$public;
$follow->notify_do = ( $frequency == 'none' ? 0 : 1 );
$follow->notify_freq = $frequency;
$follow->save();
$followParams = Follow::getFollowParameters( $object );
$idCol = $object::$databaseColumnId;
$member->clearFollowingCache( $followParams['app'], $followParams['area'], $object->$idCol );
/* Fire the event */
Event::fire( 'onFollow', $member, [ $object, $public ] );
}
/**
* Follow this object
*
* @param string $frequency ( 'none', 'immediate', 'daily', 'weekly' )
* @param bool $public
* @param Member|null $member
* @return void
*/
public function follow( string $frequency, bool $public=true, ?Member $member=null ) : void
{
static::staticFollow( $this, $frequency, $public, $member );
/* If this is an item, handle achievements */
if( $this instanceof Item )
{
Member::loggedIn()->achievementAction( 'core', 'FollowContentItem', [
'item' => $this,
'author' => $this->author()
] );
}
}
/**
* Nodes don't use followable, so we have a static method on the item level
*
* @param Model $container
* @param Member|null $member
* @return void
*/
public static function containerUnfollow( Model $container, ?Member $member=null ) : void
{
try
{
$member = $member ?: Member::loggedIn();
Follow::loadByObject( $container, $member )->delete();
}
catch( OutOfRangeException ){}
}
/**
* Unfollow this object
*
* @param Member|null $member
* @param string|null $followId
* @return void
*/
public function unfollow( ?Member $member=null, ?string $followId=null ) : void
{
$member = $member ?: Member::loggedIn();
try
{
/* We might already have the follow ID (like in the notifications controller) */
if( $followId === null )
{
Follow::loadByObject( $this, $member )->delete();
$followParams = Follow::getFollowParameters( $this );
$idCol = $this::$databaseColumnId;
$member->clearFollowingCache( $followParams['app'], $followParams['area'], $this->$idCol );
}
else
{
Follow::load( $followId )->delete();
}
}
catch( OutOfRangeException ){}
}
/**
* @brief Cache for current follow data, used on "My Followed Content" screen
*/
+6
View File
@@ -75,6 +75,12 @@ trait FuturePublishing
*/
public function canPublish( ?Member $member=NULL, ?Model $container = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'publish', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'publish', $member ) )
{
return false;
+74 -9
View File
@@ -85,6 +85,26 @@ trait Helpful
'node_id' => $this->item()->container()->_id
) );
/* If we have a mapping, increment that column */
if( isset( static::$databaseColumnMap['num_helpful'] ) )
{
$helpfulCountColumn = static::$databaseColumnMap['num_helpful'];
$this->$helpfulCountColumn++;
$this->save();
}
/* And if this is a comment, check if item has a mapping */
if( $this instanceof Comment )
{
$item = $this->item();
if( isset( $item::$databaseColumnMap['num_helpful'] ) )
{
$helpfulCountColumn = $item::$databaseColumnMap['num_helpful'];
$item->$helpfulCountColumn++;
$item->save();
}
}
/* Trending */
if( Application::appIsEnabled( 'cloud' ) and Bridge::i()->featureIsEnabled( 'trending' ) )
{
@@ -141,6 +161,26 @@ trait Helpful
unset( $this->hasMarkedHelpful[ $member->member_id ] );
}
/* If we have a mapping, decrement that column */
if( isset( static::$databaseColumnMap['num_helpful'] ) )
{
$helpfulCountColumn = static::$databaseColumnMap['num_helpful'];
$this->$helpfulCountColumn--;
$this->save();
}
/* And if this is a comment, check if item has a mapping */
if( $this instanceof Comment )
{
$item = $this->item();
if( isset( $item::$databaseColumnMap['num_helpful'] ) )
{
$helpfulCountColumn = $item::$databaseColumnMap['num_helpful'];
$item->$helpfulCountColumn--;
$item->save();
}
}
if ( IPS::classUsesTrait( $this->item(), 'IPS\Content\Statistics' ) )
{
$this->item()->clearCachedStatistics();
@@ -170,22 +210,36 @@ trait Helpful
*/
public function helpfulsRepliesCount(): int
{
if( isset( static::$databaseColumnMap['num_helpful'] ) )
{
return $this->mapped( 'num_helpful' );
}
if ( $this->_helpfulRepliesCount === NULL )
{
$idColumn = static::$databaseColumnId;
try
{
$this->_helpfulRepliesCount = (int)Db::i()->select( 'COUNT( DISTINCT(comment_id) )', 'core_solved_index', [["app=? AND item_id=? AND type=? AND hidden=0", static::$application, $this->$idColumn, 'helpful']], null, null, 'item_id' )->first();
}
catch( Exception )
{
$this->_helpfulRepliesCount = 0;
}
$this->_helpfulRepliesCount = (int) Db::i()->select( 'COUNT( DISTINCT(comment_id) )', 'core_solved_index', ["app=? AND item_id=? AND type=? AND hidden=0", static::$application, $this->$idColumn, 'helpful'] )->first();
}
return $this->_helpfulRepliesCount;
}
/**
* Recount the total helpful replies
*
* @return void
*/
public function recountHelpfuls() : void
{
if( isset( static::$databaseColumnMap['num_helpful'] ) )
{
$idColumn = static::$databaseColumnId;
$helpfulColumn = static::$databaseColumnMap['num_helpful'];
$this->$helpfulColumn = (int) Db::i()->select( 'COUNT( DISTINCT(comment_id) )', 'core_solved_index', [ 'app=? and item_id=? and type=? and hidden=?', static::$application, $this->$idColumn, 'helpful', 0 ], flags: Db::SELECT_FROM_WRITE_SERVER )->first();
$this->save();
}
}
/**
* Helpful Count
*
@@ -280,6 +334,12 @@ trait Helpful
*/
public function canMarkHelpful( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'helpful', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
$member = $member ?: Member::loggedIn();
/* It's a no for guests */
@@ -320,7 +380,7 @@ trait Helpful
public function authorIsAnExpert() : bool
{
/* Do they have it disabled? */
if( !Bridge::i()->featureIsEnabled( 'experts' ) or $this->author()->members_bitoptions['expert_user_disabled'] or $this->author()->members_bitoptions['expert_user_blocked'] )
if( !Bridge::i()->featureIsEnabled( 'experts' ) or $this->author()->members_bitoptions['expert_user_disabled'] or !$this->author()->isExpert() )
{
return false;
}
@@ -350,6 +410,11 @@ trait Helpful
*/
public static function itemHasHelpful( Item $item ): bool
{
if( isset( $item::$databaseColumnMap['num_helpful'] ) )
{
return (bool) $item->mapped( 'num_helpful' );
}
try
{
return (bool) $item->helpfulPosts(1);
+54 -2
View File
@@ -317,6 +317,12 @@ trait Hideable
*/
public function canRestore( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'restore', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'restore', $member ) )
{
return false;
@@ -451,16 +457,27 @@ trait Hideable
if ( $this instanceof Item )
{
$item = $this;
if( isset( static::$commentClass ) )
{
$solvedWhere[] = array( 'comment_class=?', static::$commentClass );
}
$solvedWhere[] = array( 'item_id=?', $this->$idColumn );
}
else
{
$item = $this->item();
$solvedWhere[] = array( 'comment_class=?', get_called_class() );
$solvedWhere[] = array( 'comment_id=?', $this->$idColumn );
}
Db::i()->update( 'core_solved_index', [ 'hidden' => 1 ], $solvedWhere );
if( IPS::classUsesTrait( $item, Helpful::class ) )
{
$item->recountHelpfuls();
}
/* Update search index */
if( SearchContent::isSearchable( $this ) )
{
@@ -657,16 +674,27 @@ trait Hideable
if ( $this instanceof Item )
{
$item = $this;
if( isset( static::$commentClass ) )
{
$solvedWhere[] = array( 'comment_class=?', static::$commentClass );
}
$solvedWhere[] = array( 'item_id=?', $this->$idColumn );
}
else
{
$item = $this->item();
$solvedWhere[] = array( 'comment_class=?', get_called_class() );
$solvedWhere[] = array( 'comment_id=?', $this->$idColumn );
}
Db::i()->update( 'core_solved_index', [ 'hidden' => 0 ], $solvedWhere );
if( IPS::classUsesTrait( $item, Helpful::class ) )
{
$item->recountHelpfuls();
}
/* Clear the item stats */
if ( IPS::classUsesTrait( $item, 'IPS\Content\Statistics' ) )
{
@@ -1373,12 +1401,18 @@ trait Hideable
*/
public function canHide( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'hide', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'hide', $member ) )
{
return false;
}
if ( $this->hidden() <> 0 )
if ( $this->hidden() < 0 )
{
return FALSE;
}
@@ -1400,6 +1434,12 @@ trait Hideable
*/
public function canUnhide( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'unhide', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'unhide', $member ) )
{
return false;
@@ -1492,7 +1532,13 @@ trait Hideable
* @return bool
*/
public function canViewHiddenComments( ?Member $member=NULL ): bool
{
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'view_hidden', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
$commentClass = static::$commentClass;
return $commentClass::modPermission( 'view_hidden', $member, $this->containerWrapper() );
@@ -1506,6 +1552,12 @@ trait Hideable
*/
public function canViewHiddenReviews( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'view_hidden', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
$reviewClass = static::$reviewClass;
return $reviewClass::modPermission( 'view_hidden', $member, $this->containerWrapper() );
+243 -167
View File
@@ -20,6 +20,7 @@ use IPS\Api\Webhook;
use IPS\Application;
use IPS\Application\Module;
use IPS\Content;
use IPS\Content\Permissions as PermissionsExtension;
use IPS\Content\Search\Index;
use IPS\core\Achievements\Recognize;
use IPS\core\Approval;
@@ -84,6 +85,8 @@ use function is_null;
use function is_numeric;
use function is_object;
use function is_string;
use function mb_strrpos;
use function mb_strtolower;
use function substr;
use const IPS\CIC;
@@ -195,12 +198,14 @@ abstract class Item extends Content
* Build form to create
*
* @param Model|NULL $container Container (e.g. forum), if appropriate
* @param bool $showError Bypass canCreate check? This will show the form even when the member is not allowed to create the item.
* @return Form
*/
public static function create( Model|null $container=NULL ): Form
public static function create( Model|null $container=NULL, bool $showError = true ): Form
{
/* Perform permission checks */
static::canCreate( Member::loggedIn(), $container, TRUE );
/* Perform permission checks */
static::canCreate( Member::loggedIn(), $container, $showError );
/* Build the form */
$form = static::buildCreateForm( $container );
@@ -566,26 +571,7 @@ abstract class Item extends Content
/* Auto-follow */
if( isset( $values[ static::$formLangPrefix . 'auto_follow'] ) AND $values[ static::$formLangPrefix . 'auto_follow'] )
{
$followArea = mb_strtolower( mb_substr( get_called_class(), mb_strrpos( get_called_class(), '\\' ) + 1 ) );
/* Insert */
$idColumn = static::$databaseColumnId;
$save = array(
'follow_id' => md5( static::$application . ';' . $followArea . ';' . $obj->$idColumn . ';' . Member::loggedIn()->member_id ),
'follow_app' => static::$application,
'follow_area' => $followArea,
'follow_rel_id' => $obj->$idColumn,
'follow_member_id' => Member::loggedIn()->member_id,
'follow_is_anon' => isset( $values[ 'post_anonymously' ] ) ? (bool) $values[ 'post_anonymously' ] : 0,
'follow_added' => time() + 1, // Make sure streams show follows after content is created
'follow_notify_do' => 1,
'follow_notify_meta' => '',
'follow_notify_freq' => Member::loggedIn()->auto_follow['method'],
'follow_notify_sent' => 0,
'follow_visible' => 1
);
Db::i()->insert( 'core_follow', $save );
$obj->follow( Member::loggedIn()->auto_follow['method'], isset( $values['post_anonymously'] ) ? !$values['post_anonymously'] : true );
}
/* Auto-share */
@@ -609,7 +595,7 @@ abstract class Item extends Content
}
/* Send notifications */
if ( $sendNotification and ( IPS::classUsesTrait( $obj, 'IPS\Content\FuturePublishing' ) AND !$obj->isFutureDate() ) )
if ( $sendNotification and ( !IPS::classUsesTrait( $obj, 'IPS\Content\FuturePublishing' ) or !$obj->isFutureDate() ) )
{
if ( !$obj->hidden() )
{
@@ -844,7 +830,12 @@ abstract class Item extends Content
{
if ( isset( static::$databaseColumnMap['author_name'] ) )
{
$return['guest_name'] = new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name'), 'htmlAutocomplete' => "username" ), NULL, NULL, NULL, 'guest_name' );
$return['guest_name'] = new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name'), 'htmlAutocomplete' => "username" ), function( $val ){
if( !empty( $val ) and filter_var( $val, FILTER_VALIDATE_EMAIL ) !== false )
{
throw new InvalidArgumentException( 'form_no_email_allowed' );
}
}, NULL, NULL, 'guest_name' );
}
}
if ( Settings::i()->bot_antispam_type !== 'none' )
@@ -879,7 +870,7 @@ abstract class Item extends Content
'key' => IPS::mb_ucfirst( static::$module ),
'autoSaveKey' => ( $item === NULL ? ( 'newContentItem-' . static::$application . '/' . static::$module . '-' . ( $container ? $container->_id : 0 ) ) : ( 'contentEdit-' . static::$application . '/' . static::$module . '-' . $item->$idColumn ) ),
'attachIds' => ( $item === NULL ? NULL : array( $item->$idColumn, $commentObj->$commentIdColumn ) )
), '\IPS\Helpers\Form::floodCheck', NULL, NULL, static::$formLangPrefix . 'content_editor' );
), ( $item ? null : '\IPS\Helpers\Form::floodCheck' ), NULL, NULL, static::$formLangPrefix . 'content_editor' );
if ( $item AND IPS::classUsesTrait( $commentClass, 'IPS\Content\EditHistory' ) and Settings::i()->edit_log )
{
@@ -1049,13 +1040,9 @@ abstract class Item extends Content
}
/* Data Layer Event */
if ( Settings::i()->core_datalayer_enabled )
if ( DataLayer::enabled() and static::dataLayerEventActive( 'content_create' ) )
{
/* make sure it isn't a PM Conversation */
if ( !isset( static::$title ) or static::$title !== Conversation::$title )
{
DataLayer::i()->addEvent( 'content_create', $this->getDataLayerProperties() );
}
DataLayer::i()->addEvent( 'content_create', $this->getDataLayerProperties( createOrEditValues: $values ) );
}
/* Send this URL to IndexNow if the guest can view it */
@@ -1158,9 +1145,11 @@ abstract class Item extends Content
/* Initial Comment */
if ( isset( static::$commentClass ) and static::$firstCommentRequired )
{
/* @var Comment $commentClass */
$commentClass = static::$commentClass;
$commentObj = $this->firstComment();
/* @var Comment $commentClass */
$commentClass = get_class( $commentObj );
/* @var $databaseColumnMap array */
$column = $commentClass::$databaseColumnMap['content'];
$idField = $commentClass::$databaseColumnId;
@@ -1251,6 +1240,11 @@ abstract class Item extends Content
$this->itemEdited();
}
if ( DataLayer::enabled( 'analytics_full' ) and static::dataLayerEventActive( 'content_edit' ) )
{
DataLayer::i()->addEvent( 'content_edit', $this->getDataLayerProperties() );
}
Event::fire( 'onCreateOrEdit', $this, array( $values ) );
$this->ui( 'formPostSave', array( $values ) );
@@ -1517,7 +1511,7 @@ abstract class Item extends Content
}
else
{
throw new BadMethodCallException;
return null;
}
}
else
@@ -2064,6 +2058,9 @@ abstract class Item extends Content
{
Index::i()->massUpdate( $commentClass, NULL, $item->$idColumn, $extension->searchIndexPermissions(), $this->hidden() ? 2 : NULL, $extension->searchIndexContainer(), NULL, $this->$idColumn, $this->author()->member_id );
}
/* Solved Index */
Db::i()->update( 'core_solved_index', [ 'item_id' => $this->$idColumn ], [ 'app=? and comment_class=? and item_id=?', static::$application, $commentClass, $item->$idColumn ] );
}
if ( isset( static::$reviewClass ) )
{
@@ -2194,6 +2191,12 @@ abstract class Item extends Content
$viewColumn = $item::$databaseColumnMap['views'];
$this->$viewColumn = $this->mapped('views') + $views;
}
/* Recount helpfuls */
if( IPS::classUsesTrait( $this, Helpful::class ) )
{
$this->recountHelpfuls();
}
$this->rebuildFirstAndLastCommentData();
@@ -2576,6 +2579,12 @@ abstract class Item extends Content
Db::i()->delete( 'core_ratings', ['class=? AND item_id=?', get_called_class(), $this->$idColumn] );
}
/* Pending view updates */
if( IPS::classUsesTrait( $this, ViewUpdates::class ) )
{
Db::i()->delete( 'core_view_updates', [ 'classname=? and id=?', get_called_class(), $this->$idColumn ] );
}
foreach ( $memberIds as $member )
{
Member::load( $member )->recountNotifications();
@@ -2615,6 +2624,7 @@ abstract class Item extends Content
Db::i()->delete( 'core_reputation_index', array('rep_class=? AND ' . Db::i()->in( 'type_id', $ids ), $class) );
Db::i()->delete( 'core_solved_index', array('comment_class=? AND ' . Db::i()->in( 'comment_id', $ids ), $class) );
Db::i()->delete( 'core_approval_queue', array( 'approval_content_class=? AND ' . Db::i()->in( 'approval_content_id', $ids ), $class ) );
}
/**
@@ -2691,7 +2701,7 @@ abstract class Item extends Content
try
{
$permissions = $this->container()->permissions();
return $permissions['perm_id'];
return is_array( $permissions ) ? $permissions['perm_id'] : null;
}
catch( BadMethodCallException )
{
@@ -2948,7 +2958,7 @@ abstract class Item extends Content
{
$authorCol = static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnMap['author'];
/* Only fetching a count, for a single container with a item cache count, no future publishing, and there is only one where clause (the container limitation) */
if( $countOnly === TRUE AND $skipPermission instanceof Model AND $skipPermission->_items !== NULL AND !in_array( 'IPS\Content\FuturePublishing', class_implements( get_called_class() ) ) AND count( $where ) === 1 )
if( $countOnly === TRUE AND $skipPermission instanceof Model AND $skipPermission->_items !== NULL AND !IPS::classUsesTrait( get_called_class(), FuturePublishing::class ) AND count( $where ) === 1 )
{
$countShortcut = TRUE;
$where[] = array( "({$col}=0 AND ( {$authorCol}={$member->member_id}{$extra} ) )" );
@@ -3062,47 +3072,13 @@ abstract class Item extends Content
{
$containerClass = static::$containerNodeClass;
$member = $member ?: Member::loggedIn();
$categories = array();
$lookupKey = md5( $containerClass::$permApp . $containerClass::$permType . $permissionKey . json_encode( $member->groups ) );
/* EME: Switch to use exclusion instead of inclusion */
/* @var $permissionMap array */
if( !isset( static::$permissionSelect[ $lookupKey ] ) )
{
static::$permissionSelect[ $lookupKey ] = array();
$permQueryJoinContainer = (bool) ( count( $containerWhere ) );
$clubWhere = "";
/* If we cannot access clubs, skip them */
if ( IPS::classUsesTrait( $containerClass, 'IPS\Content\ClubContainer' ) AND !$member->canAccessModule( Module::get( 'core', 'clubs', 'front' ) ) )
{
$permQueryWhere = array( "core_permission_index.app='" . $containerClass::$permApp . "' AND core_permission_index.perm_type='" . $containerClass::$permType . "' AND (" . $containerClass::$databaseTable . '.' . $containerClass::$databasePrefix . $containerClass::clubIdColumn() . " IS NOT NULL OR !(" . Db::i()->findInSet( 'perm_' . $containerClass::$permissionMap[ $permissionKey ], $member->permissionArray() ) . ' OR ' . 'perm_' . $containerClass::$permissionMap[ $permissionKey ] . "='*' ))" );
$permQueryJoinContainer = true;
}
else
{
$permQueryWhere = array( "core_permission_index.app='" . $containerClass::$permApp . "' AND core_permission_index.perm_type='" . $containerClass::$permType . "' AND !(" . Db::i()->findInSet( 'perm_' . $containerClass::$permissionMap[ $permissionKey ], $member->permissionArray() ) . ' OR ' . 'perm_' . $containerClass::$permissionMap[ $permissionKey ] . "='*' )" );
}
$permQuery = Db::i()->select( 'perm_type_id', 'core_permission_index', $permQueryWhere );
if ( $permQueryJoinContainer )
{
$permQuery->join( $containerClass::$databaseTable, array_merge( $containerWhere, array( 'core_permission_index.perm_type_id=' . $containerClass::$databaseTable . '.' . $containerClass::$databasePrefix . $containerClass::$databaseColumnId ) ), 'STRAIGHT_JOIN' );
}
foreach( $permQuery as $result )
{
static::$permissionSelect[ $lookupKey ][] = $result;
}
}
$categories = static::$permissionSelect[ $lookupKey ];
if( count( $categories ) )
{
$where[] = array( static::$databaseTable . "." . static::$databasePrefix . static::$databaseColumnMap['container'] . ' NOT IN (' . implode( ',', $categories ) . ')' );
}
$permQueryWhere = PermissionsExtension::nodePermissionClause( $permissionKey, $containerClass, $member );
$permQueryWhere[] = [ 'core_permission_index.app=?', $containerClass::$permApp ];
$permQueryWhere[] = [ 'core_permission_index.perm_type=?', $containerClass::$permType ];
$where[] = array(
'(' . static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnMap['container'] . ' IN( ' . Db::i()->select( 'perm_type_id', 'core_permission_index', $permQueryWhere )->returnFullQuery() . ') )',
);
}
$groupBy = ( $joinComments ? static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnId : NULL );
@@ -4639,21 +4615,23 @@ abstract class Item extends Content
}
/* Data Layer stuff for comments here, not in Comment class. We track user actions, and the user action is handled here */
$dataLayer = Settings::i()->core_datalayer_enabled ? $this->getDataLayerProperties( $comment ) : array();
Output::i()->json(
array(
'type' => 'add',
'id' => $comment->$commentIdColumn,
'page' => $newPageCount,
'total' => $this->mapped('num_comments'),
'content' => $output,
'message' => $message,
'postedByLoggedInMember' => true,
/* This is used on the front end */
"dataLayer" => $dataLayer,
)
$json = array(
'type' => 'add',
'id' => $comment->$commentIdColumn,
'page' => $newPageCount,
'total' => $this->mapped('num_comments'),
'content' => $output,
'message' => $message,
'postedByLoggedInMember' => true,
);
/* This is used on the front end */
if ( DataLayer::enabled() and $commentClass::dataLayerEventActive( 'comment_create' ) )
{
$json['dataLayer'] = $this->getDataLayerProperties( $comment );
}
Output::i()->json( $json );
}
}
else
@@ -4727,21 +4705,25 @@ abstract class Item extends Content
* Most, if not all of these are the same for different events, so we can just have one method
*
* @param Comment|null $comment A comment item, leave null for these keys to be omitted
* @param array $createOrEditValues=[] Values from the create or edit form, if applicable.
* @param bool $clearCache=false Whether to clear the cached properties and regenerate them from scratch
*
* @return array
*/
public function getDataLayerProperties( ?Comment $comment = null ): array
public function getDataLayerProperties( ?Comment $comment = null, array $createOrEditValues=[], bool $clearCache=false ): array
{
$commentIdColumn = $comment ? $comment::$databaseColumnId : null;
$index = $commentIdColumn ? ( $comment->$commentIdColumn ?: 0 ) : 0;
$index = "idx_" . ( $commentIdColumn ? ( $comment->$commentIdColumn ?: 0 ) : -1 );
if ( !isset( $this->_dataLayerProperties[$index] ) )
if ( $clearCache OR !isset( $this->_dataLayerProperties[$index] ) )
{
$app = static::$application ?? null;
$idColumn = static::$databaseColumnId;
$dataLayer = array(
'author_id' => DataLayer::i()->getSsoId( $this->author()->member_id ),
'author_name' => $this->author()->real_name ?: null,
$anonymous = IPS::classUsesTrait( $comment ?? $this,'IPS\\Content\\Anonymous' ) and ($comment ?? $this)->isAnonymous();
$author = $comment ? $comment->author() : $this->author();
$dataLayer = $comment ? $this->getDataLayerProperties() : array(
'author_id' => $anonymous ? 0 : DataLayer::i()->getSsoId( $author->member_id ),
'author_name' => $anonymous ? null : ( $author->real_name ?: null ),
'content_age' => $this->mapped( 'date' ) ? intval( floor( ( time() - $this->mapped( 'date' ) ) / 86400 ) ) : null,
'content_container_id' => null,
'content_container_name' => null,
@@ -4751,11 +4733,20 @@ abstract class Item extends Content
'content_title' => $this->mapped( 'title' ),
'content_type' => static::$contentType ?? null,
'content_url' => (string) $this->url(),
'content_anonymous' => $anonymous,
'content_is_followed' => ( isset( $createOrEditValues[ static::$formLangPrefix . 'auto_follow'] ) AND $createOrEditValues[ static::$formLangPrefix . 'auto_follow'] ) or (bool) (
Member::loggedIn()->member_id and
$this->$idColumn and
IPS::classUsesTrait( $this::class, 'IPS\Content\Followable' ) and
Member::loggedIn()->following( $app, mb_strtolower( mb_substr( $this::class, mb_strrpos( $this::class, '\\' ) + 1 ) ), $this->$idColumn )
),
);
if ( $comment AND ( $commentIdColumn = $comment::$databaseColumnId ?? null ) )
if ( $comment )
{
$dataLayer = array_replace( $dataLayer, array(
'author_id' => $anonymous ? 0 : DataLayer::i()->getSsoId( $comment->author()->member_id ),
'author_name' => $anonymous ? null : ( $comment->author()->real_name ?: null ),
'comment_id' => $comment->$commentIdColumn,
'comment_type' => $comment::$commentType ?? null,
'comment_url' => (string) $comment->url()
@@ -4763,12 +4754,18 @@ abstract class Item extends Content
}
/* For QA forums, the comment_type and content_type is an exception */
if ( Settings::i()->core_datalayer_distinguish_qa AND
$this instanceof Topic AND
$this->container()->_forum_type === 'qa' )
static $isQA = null;
if ( $isQA === null )
{
$isQA = ( Settings::i()->core_datalayer_distinguish_qa AND
$this instanceof Topic AND
$this->container()->_forum_type === 'qa');
}
if ( $isQA )
{
$dataLayer['content_type'] = 'question';
if ( isset( $comment ) )
if ( $comment and $comment instanceof Topic\Post )
{
$dataLayer['comment_type'] = 'answer';
}
@@ -4776,7 +4773,7 @@ abstract class Item extends Content
/* If we didn't find a Comment or Content type, try pulling that info from the static title fields */
elseif ( ( !isset( $dataLayer['content_type'] ) AND isset( static::$title ) ) OR ( !isset( $dataLayer['comment_type'] ) AND isset( $comment, $comment::$title ) ) )
{
$contentType = ( $app ? preg_replace( "/[{$app}_]?(.*)/", '$1', static::$title ?? "" ) : ( static::$title ?? null ) ) ?: null;
$contentType = ( $app ? preg_replace( "/^" . preg_quote( $app . "_" ) . "/", '', static::$title ?? "" ) : ( static::$title ?? null ) ) ?: null;
if ( $contentType AND !isset( $dataLayer['content_type'] ) )
{
$dataLayer['content_type'] = $contentType;
@@ -4784,7 +4781,7 @@ abstract class Item extends Content
if ( $comment AND isset( $comment::$title ) AND !isset( $dataLayer['comment_type'] ) )
{
$commentType = ($app or $contentType) ? preg_replace( "/" . ( $app ? "[{$app}_]?" : '' ) . ( $contentType ? "[{$contentType}_]?" : '' ) . "(.*)/", "$1", $comment::$title ) : $comment::$title;
$commentType = ($app or $contentType) ? preg_replace( "/^" . ( $app ? ( '(?:' . preg_quote( $app . '_' ) . ')?' ) : '' ) . ( $contentType ? ( '(?:' . preg_quote( $contentType . '_' ) . ')?' ) : '' ) . "/", "", $comment::$title ) : $comment::$title;
$dataLayer['comment_type'] = $commentType;
}
}
@@ -4795,19 +4792,25 @@ abstract class Item extends Content
$dataLayer['comment_type'] = $comment instanceof Review ? 'review' : 'comment';
}
try
/* Since the comment properties is based on the item's properties (the recursive call above), we only need this for non-comment properties */
if ( !$comment )
{
$container = $this->container();
$dataLayer = array_replace( $dataLayer, $container->getDataLayerProperties() );
}
catch ( OutOfRangeException | BadMethodCallException $e ) {}
if ( !isset( $dataLayer['content_area'] ) AND $app )
{
$lang = Lang::load( Lang::defaultLanguage() );
if ( $lang->checkKeyExists( '__app_' . static::$application ) )
try
{
$dataLayer['content_area'] = $lang->addToStack( '__app_' . static::$application );
$container = $this->container();
$dataLayer = array_replace( $dataLayer, $container->getDataLayerProperties() );
}
catch ( OutOfRangeException|BadMethodCallException $e )
{
}
if ( !isset( $dataLayer['content_area'] ) and $app )
{
$lang = Lang::load( Lang::defaultLanguage() );
if ( $lang->checkKeyExists( '__app_' . static::$application ) )
{
$dataLayer['content_area'] = $lang->addToStack( '__app_' . static::$application );
}
}
}
@@ -4897,7 +4900,12 @@ abstract class Item extends Content
{
if ( isset( $commentClass::$databaseColumnMap['author_name'] ) )
{
$return['guest_name'] = new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name') ) );
$return['guest_name'] = new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name') ), function( $val ){
if( !empty( $val ) and filter_var( $val, FILTER_VALIDATE_EMAIL ) !== false )
{
throw new InvalidArgumentException( 'form_no_email_allowed' );
}
} );
}
}
if ( Settings::i()->bot_antispam_type !== 'none' )
@@ -4957,7 +4965,7 @@ abstract class Item extends Content
$commentClass = static::$commentClass;
$idColumn = static::$databaseColumnId;
$commentIdColumn = $commentClass::$databaseColumnId;
$followArea = mb_strtolower( mb_substr( get_called_class(), mb_strrpos( get_called_class(), '\\' ) + 1 ) );
$followArea = mb_strtolower( mb_substr( $this::class, mb_strrpos( $this::class, '\\' ) + 1 ) );
/* Moderator wants to hide the comment */
if( isset( $values['hide'] ) AND $values['hide'] )
@@ -4969,41 +4977,24 @@ abstract class Item extends Content
$hidden = NULL;
}
$comment = $commentClass::create( $this, $values[ static::$formLangPrefix . 'comment' . '_' . $this->$idColumn ], FALSE, $values['guest_name'] ?? NULL, NULL, NULL, NULL, NULL, $hidden, ( isset( $values[ 'post_anonymously' ] ) ? (bool) $values[ 'post_anonymously' ] : NULL ) );
Event::fire( 'onCreateOrEdit', $comment, array( $values, TRUE ) );
/* Auto-follow - If posted anonymously we should set follow to anonymous as well */
if( isset( $values[ static::$formLangPrefix . 'auto_follow' ] ) )
{
if ( $values[ static::$formLangPrefix . 'auto_follow' ] and !Member::loggedIn()->following( static::$application, $followArea, $this->$idColumn ) )
{
/* Insert */
$save = array(
'follow_id' => md5( static::$application . ';' . $followArea . ';' . $this->$idColumn . ';' . Member::loggedIn()->member_id ),
'follow_app' => static::$application,
'follow_area' => $followArea,
'follow_rel_id' => $this->$idColumn,
'follow_member_id' => Member::loggedIn()->member_id,
'follow_is_anon' => isset( $values[ 'post_anonymously' ] ) ? (bool) $values[ 'post_anonymously' ] : 0,
'follow_added' => time() + 1, // Make sure streams show follows after content is created
'follow_notify_do' => 1,
'follow_notify_meta' => '',
'follow_notify_freq' => Member::loggedIn()->auto_follow['method'],
'follow_notify_sent' => 0,
'follow_visible' => 1
);
Db::i()->insert( 'core_follow', $save );
Db::i()->delete( 'core_follow_count_cache', array( 'class=? AND id=?', get_called_class(), (int) $this->$idColumn ) );
$this->follow( Member::loggedIn()->auto_follow['method'], isset( $values['post_anonymously'] ) ? !$values['post_anonymously'] : true );
}
else if ( $values[ static::$formLangPrefix . 'auto_follow' ] === false AND Member::loggedIn()->following( static::$application, $followArea, $this->$idColumn ) )
{
Db::i()->delete( 'core_follow', array( 'follow_id=?', md5( static::$application . ';' . $followArea . ';' . $this->$idColumn . ';' . Member::loggedIn()->member_id ) ) );
Db::i()->delete( 'core_follow_count_cache', array( 'id=? AND class=?', $this->$idColumn, get_called_class() ) );
$this->unfollow();
}
}
$this->_dataLayerProperties = [];
$comment = $commentClass::create( $this, $values[ static::$formLangPrefix . 'comment' . '_' . $this->$idColumn ], FALSE, $values['guest_name'] ?? NULL, NULL, NULL, NULL, NULL, $hidden, ( isset( $values[ 'post_anonymously' ] ) ? (bool) $values[ 'post_anonymously' ] : NULL ) );
Event::fire( 'onCreateOrEdit', $comment, array( $values, TRUE ) );
/* Update the search index (note: we already index the comment in Comment::create()) */
if( Content\Search\SearchContent::isSearchable( $this ) )
{
@@ -5059,7 +5050,12 @@ abstract class Item extends Content
{
if ( isset( $reviewClass::$databaseColumnMap['author_name'] ) )
{
$form->add( new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name') ) ) );
$form->add( new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name') ), function( $val ){
if( !empty( $val ) and filter_var( $val, FILTER_VALIDATE_EMAIL ) !== false )
{
throw new InvalidArgumentException( 'form_no_email_allowed' );
}
} ) );
}
}
if ( Settings::i()->bot_antispam_type !== 'none' )
@@ -5229,6 +5225,12 @@ abstract class Item extends Content
*/
public function canComment( ?Member $member=NULL, bool $considerPostBeforeRegistering = TRUE ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'reply', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( $this->containerWrapper() and !$this->container()->checkAction( 'comment' ) )
{
return FALSE;
@@ -5246,6 +5248,12 @@ abstract class Item extends Content
*/
public function canReview( ?Member $member=NULL, bool $considerPostBeforeRegistering = TRUE ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'review', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( $this->containerWrapper() and !$this->container()->checkAction( 'review' ) )
{
return FALSE;
@@ -6510,6 +6518,12 @@ abstract class Item extends Content
return FALSE;
}
/* Extensions go first */
if( $permCheck = Permissions::can( $permission, $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
/* If we can find the node... */
try
{
@@ -6553,6 +6567,12 @@ abstract class Item extends Content
*/
public function canView( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'view', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
$member = $member ?: Member::loggedIn();
/* Check it isn't hidden */
@@ -6586,6 +6606,11 @@ abstract class Item extends Content
}
}
/* Check if this is club content and if clubs are enabled */
if ( $this->containerWrapper() and IPS::classUsesTrait( $this->container(), 'IPS\Content\ClubContainer' ) and $this->container()->club() and !Settings::i()->clubs )
{
return false;
}
/* Check node */
return $this->containerWrapper() ? $this->container()->can( 'read', $member ) : true;
}
@@ -6598,6 +6623,12 @@ abstract class Item extends Content
*/
public function canChangeAuthor( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'edit', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
return static::modPermission( 'edit', $member, $this->container() );
}
@@ -6611,6 +6642,12 @@ abstract class Item extends Content
*/
public function canEdit( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'edit', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'edit', $member ) )
{
return false;
@@ -6656,6 +6693,12 @@ abstract class Item extends Content
{
$member = $member ?: Member::loggedIn();
/* Extensions go first */
if( $permCheck = Permissions::can( 'edit', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
/* Are we restricted from posting or have an unacknowledged warning? */
if ( $member->restrict_post or ( $member->members_bitoptions['unacknowledged_warnings'] and Settings::i()->warn_on and Settings::i()->warnings_acknowledge ) )
{
@@ -6704,6 +6747,12 @@ abstract class Item extends Content
*/
public function canMove( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'move', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'move', $member ) )
{
return false;
@@ -6729,6 +6778,12 @@ abstract class Item extends Content
*/
public function canMerge( Member|null $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'merge', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'merge', $member ) )
{
return false;
@@ -6750,6 +6805,12 @@ abstract class Item extends Content
*/
public function canDelete( Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'delete', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'delete', $member ) )
{
return false;
@@ -7150,29 +7211,29 @@ abstract class Item extends Content
if( $this->canEdit( $member ) )
{
$menu->add( new ContentMenuLink( $this->url()->setQueryString( 'do', 'edit' ), 'edit', identifier: 'edit' ) );
$menu->add( new ContentMenuLink( $this->url()->setQueryString( 'do', 'edit' ), 'edit', identifier: 'edit', icon: 'fa-solid fa-pen-to-square' ) );
}
if( $member->modPermission('can_manage_deleted_content') AND $this->hidden() == -2 )
{
if( IPS::classUsesTrait( $this, Hideable::class ) and $this->canRestore() )
{
$restore = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'restore' ) ), languageString: 'restore_as_visible', identifier: 'restore' );
$restore = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'restore' ) ), languageString: 'restore_as_visible', identifier: 'restore', icon: 'fa-solid fa-eye' );
$restore->requiresConfirm( 'restore_as_visible_desc' );
$menu->add( $restore );
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'restoreAsHidden' ) ), 'restore_as_hidden', identifier: 'restore_hidden' ) );
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'restoreAsHidden' ) ), 'restore_as_hidden', identifier: 'restore_hidden', icon: 'fa-solid fa-eye-slash' ) );
}
if( $this->canDelete() )
{
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'delete', 'immediate' => 1 ) ), 'delete_immediately', identifier: 'delete_immediately' ) );
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'delete', 'immediate' => 1 ) ), 'delete_immediately', identifier: 'delete_immediately', icon: 'fa-solid fa-trash-can' ) );
}
}
else
{
if( !$this::$firstCommentRequired and $this->canReportOrRevoke() === TRUE )
{
$report = new ContentMenuLink( url: $this->url('report'), languageString: 'report', identifier: 'report' );
$report = new ContentMenuLink( url: $this->url('report'), languageString: 'report', identifier: 'report', icon: 'fa-solid fa-flag' );
if( $member->member_id OR Captcha::supportsModal() )
{
$report->opensDialog(title: 'report', remoteSubmit: TRUE );
@@ -7182,26 +7243,26 @@ abstract class Item extends Content
if ( IPS::classUsesTrait( $this, 'IPS\Content\FuturePublishing' ) AND $this->isFutureDate() AND static::canFuturePublish($member, $this->container()))
{
$publish = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'publish' ) ), languageString: 'publish', dataAttributes: [ 'title=\'{lang="publish_desc"}\'' ], identifier: 'publish' );
$publish = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'publish' ) ), languageString: 'publish', dataAttributes: [ 'title=\'{lang="publish_desc"}\'' ], identifier: 'publish', icon: 'fa-solid fa-clock' );
$publish->requiresConfirm();
$menu->add( $publish );
}
if( IPS::classUsesTrait( $this, 'IPS\Content\Pinnable' ) AND $this->canPin( $member ) )
{
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'pin' ) ), 'pin', identifier: 'pin' ) );
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'pin' ) ), 'pin', identifier: 'pin', icon: 'fa-solid fa-thumbtack' ) );
}
if( IPS::classUsesTrait( $this, 'IPS\Content\Pinnable' ) AND $this->canUnpin( $member ) )
{
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unpin' ) ), 'unpin', identifier: 'unpin' ) );
$menu->add( new ContentMenuLink( $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unpin' ) ), 'unpin', identifier: 'unpin', icon: 'fa-solid fa-thumbtack-slash' ) );
}
if( IPS::classUsesTrait( $this, 'IPS\Content\Hideable' ) )
{
if( $this->canHide($member))
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'hide' ) ), languageString: 'hide' , dataAttributes: [
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'hide' ) ), languageString: 'hide', icon: 'fa-solid fa-eye-slash', dataAttributes: [
'data-ipsDialog' => 'true',
'data-ipsDialog-size' => 'medium',
'data-ipsDialog-title' => Member::loggedIn()->language()->addToStack('hide'),
@@ -7211,7 +7272,7 @@ abstract class Item extends Content
if( $this->canUnhide($member) )
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unhide' ) ), languageString: $this->hidden() === 1 ? 'approve' : 'unhide', dataAttributes: [
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unhide' ) ), languageString: $this->hidden() === 1 ? 'approve' : 'unhide', icon: 'fa-solid fa-eye', dataAttributes: [
'data-ipsDialog-destructOnClose' => 'true'
], identifier: 'unhide' ) );
}
@@ -7221,7 +7282,7 @@ abstract class Item extends Content
{
if( $this->canLock( $member ) )
{
$lock = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'lock' ) ), languageString: 'lock', identifier: 'lock' );
$lock = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'lock' ) ), languageString: 'lock', identifier: 'lock', icon: 'fa-solid fa-lock' );
if( $member->modPermission('can_manage_alerts') AND $this->author()->member_id )
{
$lock->addAttribute( 'data-ipsDialog')
@@ -7234,32 +7295,32 @@ abstract class Item extends Content
if( $this->canUnlock( $member ) )
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unlock' ) ), languageString: 'unlock' ) );
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' => 'unlock' ) ), languageString: 'unlock', icon: 'fa-solid fa-unlock' ) );
}
}
if( $this->canMove( $member ) )
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'move' ) ), languageString: 'move' , opensDialog: true ) );
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'move' ) ), languageString: 'move' , opensDialog: true, icon: 'fa-solid fa-arrow-right' ) );
}
if( $this->canMerge( $member ) )
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'merge' ) ), languageString: 'merge', opensDialog: true ) );
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'merge' ) ), languageString: 'merge', opensDialog: true, icon: 'fa-solid fa-down-left-and-up-right-to-center' ) );
}
if( isset( static::$archiveClass ) )
{
if( $this->canUnarchive( $member ) )
{
$unarchive = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'unarchive' ) ), languageString: 'unarchive');
$unarchive = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'unarchive' ) ), languageString: 'unarchive', icon: 'fa-solid fa-box-open');
$unarchive->requiresConfirm( $this->unarchiveBlurb() );
$menu->add( $unarchive );
}
if( $this->canRemoveArchiveExcludeFlag( $member ) )
{
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'removeArchiveExcludeFlag' ) ), languageString: 'remove_archive_exclude_flag') );
$menu->add( new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'removeArchiveExcludeFlag' ) ), languageString: 'remove_archive_exclude_flag', icon: 'fa-solid fa-box') );
}
}
@@ -7274,6 +7335,7 @@ abstract class Item extends Content
if ( !$this->isFeatured() )
{
$feature = new ContentMenuLink( url: $this->url()->setQueryString( array( 'do' => 'feature', 'fromItem' => 1 ) ), languageString: 'promote_social_button' );
$feature->icon ="fa-solid fa-star";
$feature->addAttribute( 'data-ipsDialog-flashMessage', Member::loggedIn()->language()->addToStack( 'promote_flash_msg' ) )
->addAttribute( 'data-ipsDialog-flashMessageTimeout', 5 )
->addAttribute( 'data-ipsDialog-flashMessageEscape', 'false' )
@@ -7284,6 +7346,7 @@ abstract class Item extends Content
else
{
$feature = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'unfeature', 'fromItem' => 1 ) ), languageString: 'demote_social_button' );
$feature->icon ="fa-regular fa-star";
$feature->addAttribute( 'data-ipsDialog-flashMessage', Member::loggedIn()->language()->addToStack( 'demote_flash_msg' ) )
->addAttribute( 'data-ipsDialog-flashMessageTimeout', 5 )
->addAttribute( 'data-ipsDialog-flashMessageEscape', 'false' )
@@ -7297,6 +7360,7 @@ abstract class Item extends Content
if( $this->canDelete( $member ) )
{
$delete = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'moderate', 'action' =>'delete' ) ), languageString: 'delete');
$delete->icon ="fa-solid fa-trash";
$delete->requiresConfirm();
$menu->add( $delete );
}
@@ -7308,23 +7372,24 @@ abstract class Item extends Content
if( IPS::classUsesTrait( $this, Statistics::class ) )
{
$analytics = new ContentMenuLink( url:$this->url()->setQueryString( array( 'do' => 'analytics' ) ), languageString: 'analytics_and_stats' );
$analytics->icon = "fa-solid fa-chart-simple";
$analytics->opensDialog( 'analytics_and_stats', 'large' );
$menu->add( $analytics );
}
$menu->add( new ContentMenuLink( url:$this->url()->setQueryString( array( 'do' => 'modlog' ) ), languageString: 'moderation_history' , opensDialog: true ) );
$menu->add( new ContentMenuLink( url:$this->url()->setQueryString( array( 'do' => 'modlog' ) ), languageString: 'moderation_history' , opensDialog: true, icon: 'fa-solid fa-clock-rotate-left' ) );
}
if( IPS::classUsesTrait( $this, MetaData::class ) )
{
if( $this->canOnMessage( 'add', $member ) )
{
$menu->add( new ContentMenuLink( url: $this->url()->setQueryString( array( 'do' => 'messageForm' ) ), languageString: 'add_message' , opensDialog: true ) );
$menu->add( new ContentMenuLink( url: $this->url()->setQueryString( array( 'do' => 'messageForm' ) ), languageString: 'add_message' , opensDialog: true, icon: 'fa-solid fa-message' ) );
}
if( $this->canToggleItemModeration( $member ) )
{
$toggle = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'toggleItemModeration' ) ), languageString: $this->itemModerationEnabled() ?'disable_topic_moderation' : 'enable_topic_moderation' );
$toggle = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'toggleItemModeration' ) ), languageString: $this->itemModerationEnabled() ?'disable_topic_moderation' : 'enable_topic_moderation', icon: 'fa-solid fa-traffic-light' );
$toggle->requiresConfirm( $this->itemModerationEnabled() ? 'disable_topic_moderation_confirm' : 'enable_topic_moderation_confirm' );
$menu->add( $toggle );
}
@@ -7335,7 +7400,7 @@ abstract class Item extends Content
$menu->addSeparator();
foreach ( $actions as $action )
{
$actionLink = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'savedAction', 'action' => $action->_id ) ), languageString: $action->_title );
$actionLink = new ContentMenuLink( url: $this->url()->csrf()->setQueryString( array( 'do' => 'savedAction', 'action' => $action->_id ) ), languageString: $action->_title, icon: 'fa-solid fa-wand-magic' );
$actionLink->requiresConfirm();
$menu->add( $actionLink );
}
@@ -7474,17 +7539,28 @@ abstract class Item extends Content
* @apiresponse datetime date Date
* @apiresponse string content Content
* @apiresponse string url URL
* @apiresponse \IPS\core\Assignments\Assignment|null assignment Assignment data
*/
public function apiOutput( Member $authorizedMember = NULL ): array
{
return array(
$data = [
'id' => $this->id,
'title' => $this->title ? $this->title : NULL,
'container' => $this->containerWrapper()?->apiOutput( $authorizedMember ),
'title' => $this->mapped( 'title' ),
'container' => $this->containerWrapper( true )?->apiOutput( $authorizedMember ),
'author' => $this->author()->apiOutput( $authorizedMember ),
'date' => ( isset( static::$databaseColumnMap['date'] ) ? DateTime::ts( $this->mapped( 'date' ) )->rfc3339() : null ),
'content' => $this->content(),
'url' => (string) $this->url()
);
'url' => (string) $this->url(),
];
if ( IPS::classUsesTrait( $this, Assignable::class ) )
{
$data['assignment'] = $this->assignment ? $this->assignment->apiOutput($authorizedMember, false ) : NULL;
}
else
{
$data['assignment'] = NULL;
}
return $data;
}
}
+26
View File
@@ -103,10 +103,29 @@ trait ItemTopic
try
{
$topic = Topic::load( $this->$column );
if ( $topic->isArchived() )
{
/* Do not update archived topics */
return;
}
/* If this is a future publish date, make sure we update that as well */
if( IPS::classUsesTrait( $this, FuturePublishing::class ) and $topic->isFutureDate() )
{
$topic->start_date = $this->mapped( 'date' );
$topic->is_future_entry = $this->isFutureDate();
$topic->publish_date = $this->mapped( 'future_date' );
}
$title = $this->getTopicTitle();
Member::loggedIn()->language()->parseOutputForDisplay( $title );
$topic->title = $title;
$topic->save();
/* Synch the last comment data in case timestamps have changed */
$topic->resyncLastComment();
$firstPost = $topic->comments( 1 );
/* If the first post of the topic is missing, NULL will be returned */
@@ -118,6 +137,7 @@ trait ItemTopic
$content = $this->getTopicContent();
Member::loggedIn()->language()->parseOutputForDisplay( $content );
$firstPost->post = $content;
$firstPost->post_date = $topic->start_date;
$firstPost->save();
Index::i()->index( $firstPost );
}
@@ -181,6 +201,12 @@ trait ItemTopic
$topic->sendNotifications();
}
/* Check our auto-follow settings */
if( $topic->author()->auto_follow['content'] )
{
$topic->follow( $topic->author()->auto_follow['method'], !$this->isAnonymous(), $topic->author() );
}
/* Update file */
$this->$column = $topic->tid;
$this->save();
+19
View File
@@ -13,6 +13,7 @@ namespace IPS\Content;
use BadMethodCallException;
use IPS\Member;
use IPS\Request;
use function defined;
use function get_class;
use function header;
@@ -61,6 +62,18 @@ trait Lockable
*/
public function canLock( ?Member $member=NULL ): bool
{
/* Is this the task? Let it through */
if( Request::i()->isCliEnvironment() )
{
return true;
}
/* Extensions go first */
if( $permCheck = Permissions::can( 'lock', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'lock', $member ) )
{
return false;
@@ -94,6 +107,12 @@ trait Lockable
*/
public function canUnlock( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'unlock', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'unlock', $member ) )
{
return false;
+24
View File
@@ -216,6 +216,12 @@ trait MetaData
*/
public function canOnMessage( string $action, ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'onMessage', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'onMessage', $member ) )
{
return false;
@@ -300,6 +306,12 @@ trait MetaData
*/
public function canFeatureComment( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'featureComment', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'featureComment', $member ) )
{
return false;
@@ -317,6 +329,12 @@ trait MetaData
*/
public function canUnfeatureComment( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'unfeatureComment', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'unfeatureComment', $member ) )
{
return false;
@@ -398,6 +416,12 @@ trait MetaData
*/
public function canToggleItemModeration( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'toggleItemModeration', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'toggleItemModeration', $member ) )
{
return false;
+196
View File
@@ -0,0 +1,196 @@
<?php
/**
* @brief Permissions
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @subpackage
* @since 4/16/2025
*/
namespace IPS\Content;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Application;
use IPS\Application\Module;
use IPS\Db;
use IPS\Dispatcher;
use IPS\Extensions\PermissionsAbstract;
use IPS\IPS;
use IPS\Member;
use IPS\Node\Model;
use IPS\Settings;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Permissions
{
/**
* @brief Used for Permissions extensions; override default permissions
*/
const PERM_ALLOW = 1;
/**
* @brief Used for Permissions extensions; override default permissions
*/
const PERM_DENY = 2;
/**
* @brief Used for Permissions extensions; revert to default permission checks
*/
const PERM_DEFAULT = 0;
/**
* @var array
*/
protected static array $nodeIncludesMap = [];
/**
* @var array
*/
protected static array $nodeExcludeMap = [];
/**
* Load all the inclusion/exclusion mappings from extensions
*
* @param string $permission
* @param string $nodeClass
* @param Member $member
* @return void
*/
protected static function loadMappings( string $permission, string $nodeClass, Member $member ) : void
{
if( !isset( static::$nodeIncludesMap[ $nodeClass ][ $permission ] ) )
{
$include = [];
$exclude = [];
foreach( Application::allExtensions( 'core', 'Permissions' ) as $ext )
{
/* @var PermissionsAbstract $ext */
$include = array_merge( $include, $ext->nodeIdsToAllow( $permission, $nodeClass, $member ) );
$exclude = array_merge( $exclude, $ext->nodeIdsToDeny( $permission, $nodeClass, $member ) );
}
static::$nodeIncludesMap[ $nodeClass ][ $permission ] = $include;
static::$nodeExcludeMap[ $nodeClass ][ $permission ] = $exclude;
}
}
/**
* Easily load included IDs, for my sanity
*
* @param string $permission
* @param string $nodeClass
* @param Member|null $member
* @param int $permType
* @return array|null
*/
protected static function getMapping( string $permission, string $nodeClass, ?Member $member, int $permType ) : ?array
{
/* Only allow these extensions to run on the front-end or from the API */
if( !Dispatcher::hasInstance() or ( Dispatcher::i()->controllerLocation != 'front' and Dispatcher::i()->controllerLocation != 'api' ) )
{
return null;
}
$member = $member ?: Member::loggedIn();
static::loadMappings( $permission, $nodeClass, $member );
switch( $permType )
{
case static::PERM_ALLOW:
return count( static::$nodeIncludesMap[ $nodeClass ][ $permission ] ) ? static::$nodeIncludesMap[ $nodeClass ][ $permission ] : null;
case static::PERM_DENY:
return count( static::$nodeExcludeMap[ $nodeClass ][ $permission ] ) ? static::$nodeExcludeMap[ $nodeClass ][ $permission ] : null;
}
return null;
}
/**
* Wrapper method to call extension
*
* @param string $permission
* @param object $object
* @param Member|null $member
* @return int
*/
public static function can( string $permission, object $object, ?Member $member=null ) : int
{
/* Only allow these extensions to run on the front-end or from the API */
if( !Dispatcher::hasInstance() or ( Dispatcher::i()->controllerLocation != 'front' and Dispatcher::i()->controllerLocation != 'api' ) )
{
return static::PERM_DEFAULT;
}
foreach( Application::allExtensions( 'core', 'Permissions' ) as $ext )
{
/* @var PermissionsAbstract $ext */
$result = $ext->checkPermission( $permission, $object, $member );
if( $result !== static::PERM_DEFAULT )
{
return $result;
}
}
return static::PERM_DEFAULT;
}
/**
* Build a query clause to pull node IDs from the permissions table.
*
* @param string $permission
* @param string $nodeClass
* @param Member|null $member
* @param bool $includeClubs
* @return array
*/
public static function nodePermissionClause( string $permission, string $nodeClass, ?Member $member=null, bool $includeClubs=true ) : array
{
$member = $member ?: Member::loggedIn();
/* If clubs are disabled, or we don't have permission, force it to exclude them */
if( !Settings::i()->clubs or !$member->canAccessModule( Module::get( 'core', 'clubs', 'front' ) ) or !IPS::classUsesTrait( $nodeClass, ClubContainer::class ) )
{
$includeClubs = false;
}
/* @var Model $nodeClass */
/* @var array $permissionMap */
$clause = [
Db::i()->findInSet( 'core_permission_index.perm_' . $nodeClass::$permissionMap[ $permission ], ( $includeClubs ? $member->permissionArray() : $member->groups ) ),
'core_permission_index.perm_' . $nodeClass::$permissionMap[ $permission ] . '=?'
];
$binds = [
'*'
];
/* If we have any specific inclusions, put them in */
if( $includes = static::getMapping( $permission, $nodeClass, $member, static::PERM_ALLOW ) )
{
$clause[] = 'core_permission_index.perm_type_id in (' . implode( ",", $includes ) . ')';
}
$return = [
array_merge( array( '(' . implode( ' OR ', $clause ) . ')' ), $binds )
];
/* If we have any specific exclusions, we need it separate so that we make it an AND */
if( $excludes = static::getMapping( $permission, $nodeClass, $member, static::PERM_DENY ) )
{
$return[] = [ Db::i()->in( 'core_permission_index.perm_type_id', $excludes, true ) ];
}
return $return;
}
}
+12
View File
@@ -45,6 +45,12 @@ trait Pinnable
*/
public function canPin( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'pin', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'pin', $member ) )
{
return false;
@@ -67,6 +73,12 @@ trait Pinnable
*/
public function canUnpin( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'unpin', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'unpin', $member ) )
{
return false;
+3 -1
View File
@@ -11,6 +11,7 @@
namespace IPS\Content;
use BadMethodCallException;
use IPS\IPS;
use IPS\Member;
use IPS\Poll;
use IPS\Node\Model;
@@ -60,7 +61,8 @@ trait Polls
if( $this->mapped('poll') )
{
/* If the poll is in a club, return the special extended class */
if( $this->containerWrapper() AND $club = $this->containerWrapper()->club() )
$container = $this->containerWrapper( true );
if( $container and IPS::classUsesTrait( $container, ClubContainer::class ) and $club = $container->club() )
{
$poll = ClubPoll::load( $this->mapped('poll') );
$poll->club = $club;
+6
View File
@@ -48,6 +48,12 @@ trait Ratings
*/
public function canRate( Member|null $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'rate', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
$member = $member ?: Member::loggedIn();
if( !$member->member_id )
{
+10
View File
@@ -16,6 +16,7 @@ use BadMethodCallException;
use DateInterval;
use DomainException;
use Exception;
use IPS\Api\Webhook;
use IPS\Application;
use IPS\Content;
use IPS\DateTime;
@@ -164,6 +165,8 @@ trait Reactable
/* Fire event */
Event::fire( 'onReact', $member, [ $this, $reaction ] );
Webhook::fire( 'content_reaction_added', [ 'member' => $member, 'content' => $this, 'reaction' => $reaction ] );
/* Have we hit highlighted content? */
if( $this->isHighlighted() )
{
@@ -262,6 +265,7 @@ trait Reactable
/* Fire event */
Event::fire( 'onUnreact', $member, [ $this ] );
Webhook::fire( 'content_reaction_removed', [ 'member' => $member, 'content' => $this, 'reaction' => $reaction ] );
/* Remove Notifications */
if( $removeNotifications === TRUE )
@@ -296,6 +300,12 @@ trait Reactable
*/
public function canReact( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'react', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'react', $member ) )
{
return false;
+31 -1
View File
@@ -13,6 +13,7 @@ namespace IPS\Content;
/* To prevent PHP errors (extending class does not exist) revealing path */
use Exception;
use IPS\IPS;
use IPS\Node\CustomBadge;
use IPS\Platform\Bridge;
use IPS\Data\Store;
@@ -257,8 +258,16 @@ class Reaction extends Model
{
return $badge->file()?->url;
}
if( $this->_data['icon'] )
{
try
{
return File::get( 'core_Reaction', $this->_data['icon'] );
}
catch( Exception $e ){}
return File::get( 'core_Reaction', $this->_data['icon'] );
}
return NULL;
}
/**
@@ -489,4 +498,25 @@ class Reaction extends Model
parent::delete();
}
/**
* Get output for API
*
* @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials
* @return array
* @apiresponse int id ID number
* @apiresponse string icon URL to the icon,
* @apiresponse string value Value of the reaction
*/
public function apiOutput( Member $authorizedMember = NULL ): array
{
$return = array(
'id' => $this->id,
'title' => $this->title,
'icon' => (string) $this->icon,
'value' => $this->value
);
return $return;
}
}
+12
View File
@@ -40,6 +40,12 @@ trait Recognizable
*/
public function canRemoveRecognize( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'unrecognize', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'unrecognize', $member ) )
{
return false;
@@ -82,6 +88,12 @@ trait Recognizable
*/
public function canRecognize( ?Member $member=NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'recognize', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'recognize', $member ) )
{
return false;
+6
View File
@@ -320,6 +320,12 @@ abstract class Review extends Comment
*/
public function canView( Member|null $member= null ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'view', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( $member === NULL )
{
$member = Member::loggedIn();
+3
View File
@@ -103,6 +103,7 @@ class Index extends SearchIndex
'settings' => $settings,
'mappings' => array(
'properties' => array(
'index_id' => array( 'type' => 'keyword' ),
'index_class' => array( 'type' => 'keyword' ),
'index_object_id' => array( 'type' => 'long' ),
'index_item_id' => array( 'type' => 'long' ),
@@ -205,6 +206,8 @@ class Index extends SearchIndex
{
if ( $indexData = $this->indexData( $object ) )
{
$indexData['index_id'] = $this->getIndexId( $object );
/* If nobody has permission to access it, just remove it */
if ( !$indexData['index_permissions'] )
{
@@ -44,6 +44,8 @@ class MassIndexer extends Index
{
if ( $indexData = $this->indexData( $object ) and $indexData['index_permissions'] )
{
$indexData['index_id'] = $this->getIndexId( $object );
if ( $object instanceof Item )
{
$parent = $object;
+45 -5
View File
@@ -25,8 +25,10 @@ use IPS\IPS;
use IPS\Log;
use IPS\Member;
use IPS\Member\Club;
use IPS\Node\Grouping;
use IPS\Settings;
use StdClass;
use function array_pop;
use function count;
use function defined;
use function in_array;
@@ -79,6 +81,11 @@ class Query extends SearchQuery
*/
protected array|null $itemClasses = NULL;
/**
* @brief If true, just fetch the title record, or the first comment if a post, etc
*/
protected bool $getTitleRecordOnly = false;
/**
* Constructor
*
@@ -133,6 +140,19 @@ class Query extends SearchQuery
{
if ( $filter->containerIdFilter )
{
if ( $filter->containerIds )
{
if ( $filter->itemClass )
{
$itemClass = $filter->itemClass;
if ( isset( $itemClass::$containerNodeClass ) )
{
$containerClass = $itemClass::$containerNodeClass;
$filter->containerIds = $containerClass::normalizeIds( $filter->containerIds );
}
}
}
$conditions[] = array(
'terms' => array(
'index_container_id' => $filter->containerIds
@@ -217,9 +237,7 @@ class Query extends SearchQuery
/* Only first comment? */
if ( $filter->onlyFirstComment )
{
$conditions[] = array(
'exists' => array( 'field' => 'index_title' )
);
$this->getTitleRecordOnly = true;
}
/* Only last comment? */
@@ -290,7 +308,7 @@ class Query extends SearchQuery
/**
* Filter by club
*
* @param Club|int|array|null $club The club, or array of club IDs
* @param Club|int|array|null $club The club, or array of club IDs or NULL to exlude content from clubs
* @return Query (for daisy chaining)
*/
public function filterByClub( Club|int|array|null $club ): static
@@ -1103,6 +1121,9 @@ class Query extends SearchQuery
if ( $method & static::TERM_TITLES_ONLY )
{
$searchConditions[] = array( 'match_phrase' => array( 'index_title' => array( 'query' => $term ) ) );
/* Just get the title record only, or the first post if it's a topic as we don't want to show each comment as each comment will have the topic title in the index */
$this->getTitleRecordOnly = true;
}
else
{
@@ -1115,6 +1136,9 @@ class Query extends SearchQuery
if ( $method & static::TERM_TITLES_ONLY )
{
$searchConditions[] = array( 'wildcard' => array( 'index_title' => array( 'value' => $term ) ) );
/* Just get the title record only, or the first post if it's a topic as we don't want to show each comment as each comment will have the topic title in the index */
$this->getTitleRecordOnly = true;
}
else
{
@@ -1138,6 +1162,9 @@ class Query extends SearchQuery
if ( $method & static::TERM_TITLES_ONLY )
{
$searchConditions[] = array( 'match' => array( 'index_title' => array( 'query' => $term, 'operator' => $operator ) ) );
/* Just get the title record only, or the first post if it's a topic as we don't want to show each comment as each comment will have the topic title in the index */
$this->getTitleRecordOnly = true;
}
else
{
@@ -1164,7 +1191,7 @@ class Query extends SearchQuery
/* If we're not searching with a term, then just show the title record, not comments */
if ( $term === NULL and ! $this->lastCommentMustBeTrue )
{
$must[] = ['exists' => ['field' => 'index_title']];
$this->getTitleRecordOnly = true;
}
}
@@ -1221,6 +1248,19 @@ class Query extends SearchQuery
/* Peform the search */
try
{
/* We only want the main record (of first comment) not the comments themselves, so we check to make sure _id (aka index_id) is the same as index_item_index_id */
if ( $this->getTitleRecordOnly )
{
$filters[] = [
'script' => [
'script' => [
'lang' => 'painless',
'source' => "doc['index_item_index_id'] == doc['index_id']"
]
]
];
}
/* Initial query */
$query = array(
'bool' => array(
+16 -1
View File
@@ -22,6 +22,7 @@ use IPS\Db\Exception;
use IPS\Db\Select;
use IPS\Member;
use IPS\Member\Club;
use IPS\Node\Grouping;
use IPS\Settings;
use IPS\IPS;
use Throwable;
@@ -33,6 +34,7 @@ use function in_array;
use function intval;
use function is_array;
use function is_null;
use function is_string;
use const IPS\CIC;
use const IPS\USE_MYSQL_SEARCH_OPTIMIZED_MODE;
@@ -145,10 +147,23 @@ class Query extends \IPS\Content\Search\Query
$clause[] = 'index_class=?';
$params[] = array_pop( $filter->classes );
}
/* Set the containers */
if ( $filter->containerIdFilter !== NULL )
{
if ( $filter->containerIds )
{
if ( $filter->itemClass )
{
$itemClass = $filter->itemClass;
if ( isset( $itemClass::$containerNodeClass ) )
{
$containerClass = $itemClass::$containerNodeClass;
$filter->containerIds = $containerClass::normalizeIds( $filter->containerIds );
}
}
}
$clause[] = Db::i()->in( 'index_container_id', $filter->containerIds, $filter->containerIdFilter === FALSE );
}
+11 -3
View File
@@ -66,12 +66,19 @@ abstract class Query
{
if ( Settings::i()->search_method == 'elastic' )
{
return new Elastic\Query( $member ?: Member::loggedIn(), Url::external( rtrim( Settings::i()->search_elastic_server, '/' ) . '/' . Settings::i()->search_elastic_index ) );
$search = new Elastic\Query( $member ?: Member::loggedIn(), Url::external( rtrim( Settings::i()->search_elastic_server, '/' ) . '/' . Settings::i()->search_elastic_index ) );
}
else
{
return new Mysql\Query( $member ?: Member::loggedIn() );
$search = new Mysql\Query( $member ?: Member::loggedIn() );
}
/* Don't include any club content if we have disabled clubs */
if( !Settings::i()->clubs )
{
$search->filterByClub( NULL );
}
return $search;
}
/**
@@ -99,9 +106,10 @@ abstract class Query
{
$this->setHiddenFilter( static::HIDDEN_VISIBLE );
}
$filters = [];
/* Exclude disabled applications */
$filters = array();
foreach( SearchContent::searchableClasses() as $class )
{
/* We only want items here */
+6 -3
View File
@@ -161,11 +161,14 @@ class Content extends Result
$data = array_pop( $images );
$key = key( $data );
try
if( !empty( $data[ $key ] ) )
{
$enclosure = File::get( $key, $data[ $key ] );
try
{
$enclosure = File::get( $key, $data[ $key ] );
}
catch( Exception ) { }
}
catch( Exception ) { }
}
$document->addItem( $object instanceof Comment ? $object->item()->mapped( 'title' ) : $object->mapped( 'title' ), $object->url(), Result::preDisplay( $this->indexData['index_content'] ), DateTime::ts( $this->indexData['index_date_created'] ), NULL, $enclosure );
@@ -169,7 +169,22 @@ abstract class SearchContentAbstract
}
/* Articles language */
$articles = static::articlesFromIndexData( $objectClass, $containerData );
if( is_subclass_of( $objectClass, Comment::class ) and
(
( $itemClass::$firstCommentRequired and $indexData['index_item_index_id'] == $indexData['index_id'] ) or
( isset( $itemData['author'] ) )
)
)
{
/* If the first comment is required, and this is the first comment, treat it as an item, not a comment.
OR.... if we are commenting on someone else's content. */
$articles = static::articlesFromIndexData( $itemClass, $containerData );
}
else
{
$articles = static::articlesFromIndexData( $objectClass, $containerData );
}
$summaryLanguage = static::searchResultSummaryLanguage( $authorData, $articles, $indexData, $itemData );
/* Container details */
@@ -292,7 +307,7 @@ abstract class SearchContentAbstract
{
if( $itemClass::$firstCommentRequired )
{
if( $indexData['index_title'] )
if( $indexData['index_item_index_id'] == $indexData['index_id'] )
{
return Member::loggedIn()->language()->addToStack( "user_own_activity_item", FALSE, array( 'sprintf' => array( $articles['indefinite'] ), 'htmlsprintf' => array( $authorTemplate ) ) );
}
+6
View File
@@ -44,6 +44,12 @@ trait Shareable
*/
public function canShare(): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'share', $this ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'share' ) )
{
return false;
+12
View File
@@ -226,6 +226,12 @@ trait Solvable
*/
public function canSolve( ?Member $member = NULL ): bool
{
/* Extensions go first */
if( $permCheck = Permissions::can( 'solve', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
if( !$this->actionEnabled( 'solve', $member ) )
{
return false;
@@ -238,6 +244,12 @@ trait Solvable
return FALSE;
}
/* Guests cannot do this */
if( !$member->member_id )
{
return false;
}
/* If we have no replies, it's not solvable yet */
if( $this->commentCount() <= 0 OR ( static::$firstCommentRequired AND $this->commentCount() == 1 ) )
{
+147 -10
View File
@@ -18,6 +18,7 @@ use BadMethodCallException;
use Exception;
use InvalidArgumentException;
use IPS\Application;
use IPS\cms\Records;
use IPS\Content;
use IPS\Data\Store;
use IPS\DateTime;
@@ -27,6 +28,7 @@ use IPS\Helpers\Form;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\Translatable;
use IPS\Helpers\Form\Upload;
use IPS\Helpers\Form\YesNo;
use IPS\Http\Url;
use IPS\Http\Url\Friendly;
use IPS\IPS;
@@ -127,6 +129,11 @@ class Tag extends Model
*/
public static string $application = 'core';
/**
* @brief [Node] Sortable?
*/
public static bool $nodeSortable = false;
/**
* [Node] Return the custom badge for each row
*
@@ -222,6 +229,21 @@ class Tag extends Model
$this->_data['totals'] = ( is_array( $val ) and count( $val ) ) ? json_encode( $val ) : null;
}
/**
* [Node] Get content table meta description
*
* @return string|null
*/
public function metaDescription(): ?string
{
if( !Member::loggedIn()->language()->checkKeyExists( static::$titleLangPrefix . $this->id . static::$descriptionLangSuffix ) )
{
return null;
}
return parent::metaDescription();
}
/**
* Is this node currently queued for deleting or moving content OR is it the target of content queued to be moved from another node?
*
@@ -237,6 +259,16 @@ class Tag extends Model
return parent::deleteOrMoveQueued();
}
/**
* [Node] Get the title to store in the log
*
* @return string|null
*/
public function titleForLog(): ?string
{
return $this->text;
}
/**
* Determines if the tag has been flagged for merging.
* We need to flag it because this can take a while if there
@@ -257,7 +289,9 @@ class Tag extends Model
*/
public function form( Form &$form ): void
{
$form->add( new Text( 'tag_text', $this->text, true, [], function( $val ){
$form->add( new Text( 'tag_text', $this->text, true, [
'regex' => '/^[^,]+$/i',
], function( $val ){
try
{
$test = Db::i()->select( '*', static::$databaseTable, [ 'tag_text=? and tag_id <> ?', $val, (int) $this->id ] )->first();
@@ -299,6 +333,8 @@ class Tag extends Model
'multiple' => false,
'allowStockPhotos' => true
] ) );
$form->add( new YesNo( 'tag_recommended', $this->recommended, false ) );
}
/**
@@ -334,6 +370,56 @@ class Tag extends Model
return $values;
}
/**
* [Node] Get buttons to display in tree
* Example code explains return value
*
* @code
* array(
* array(
* 'icon' => 'plus-circle', // Name of FontAwesome icon to use
* 'title' => 'foo', // Language key to use for button's title parameter
* 'link' => \IPS\Http\Url::internal( 'app=foo...' ) // URI to link to
* 'class' => 'modalLink' // CSS Class to use on link (Optional)
* ),
* ... // Additional buttons
* );
* @endcode
* @param Url $url Base URL
* @param bool $subnode Is this a subnode?
* @return array
*/
public function getButtons( Url $url, bool $subnode=FALSE ): array
{
$buttons = [];
if( $this->recommended )
{
$buttons['recommend'] = [
'icon' => 'star',
'title' => 'tag_mark_norecommend',
'link' => $url->setQueryString( [ 'do' => 'recommend', 'id' => $this->id ] )->csrf()
];
}
else
{
$buttons['recommend'] = [
'icon' => 'regular fa-star',
'title' => 'tag_mark_recommend',
'link' => $url->setQueryString( [ 'do' => 'recommend', 'id' => $this->id ] )->csrf()
];
}
$buttons['view'] = [
'icon' => 'search',
'title' => 'tag_view_page',
'link' => $this->url(),
'target' => '_blank'
];
return array_merge( $buttons, parent::getButtons( $url, $subnode ) );
}
/**
* Recount the totals for this content type all affected tags
*
@@ -446,7 +532,7 @@ class Tag extends Model
public static function allTags() : array
{
return iterator_to_array(
Db::i()->select( 'tag_text', static::$databaseTable, null, 'tag_text' )
Db::i()->select( 'tag_text', static::$databaseTable, null, 'tag_recommended desc, tag_text' )
);
}
@@ -462,7 +548,10 @@ class Tag extends Model
{
if( IPS::classUsesTrait( $class, Taggable::class ) )
{
$return[ $class::$title ] = $class;
$bits = explode( "\\", $class );
$tab = $bits[1] . '__' . array_pop( $bits );
$return[ $tab ] = $class;
Member::loggedIn()->language()->words[ $tab . '_pl' ] = Member::loggedIn()->language()->get( $class::$title . '_pl' );
}
}
return $return;
@@ -501,9 +590,11 @@ class Tag extends Model
try
{
$item = $itemClass::load( $row['pinned_item_id'] );
$item->pinnedData = $row;
$items[] = $item;
if( $item->canView() )
{
$item->pinnedData = $row;
$items[] = $item;
}
}
catch( OutOfRangeException ){}
}
@@ -579,14 +670,14 @@ class Tag extends Model
if( !isset( Store::i()->tagSlugs ) )
{
Store::i()->tagSlugs = iterator_to_array(
Db::i()->select( 'tag_text,tag_text_seo', static::$databaseTable, [ 'tag_enabled=?', 1 ] )
Db::i()->select( 'LOWER(tag_text) as tag_text,tag_text_seo', static::$databaseTable, [ 'tag_enabled=?', 1 ] )
->setKeyField( 'tag_text' )
->setValueField( 'tag_text_seo' )
);
}
$cache = Store::i()->tagSlugs;
$slug = $cache[ $tagText ] ?? Friendly::seoTitle( $tagText );
$slug = $cache[ mb_strtolower( $tagText ) ] ?? Friendly::seoTitle( $tagText );
return Url::internal( "app=core&module=discover&controller=tag&tag=" . $slug, "front", "tags" );
}
@@ -652,11 +743,21 @@ class Tag extends Model
if ( !isset( Store::i()->tags ) )
{
/* Cache only the enabled tags */
Store::i()->tags = iterator_to_array(
Db::i()->select( 'tag_id,tag_text', static::$databaseTable, [ 'tag_enabled=?', 1 ], 'tag_text' )
$recommended = iterator_to_array(
Db::i()->select( 'tag_id,tag_text', static::$databaseTable, [ 'tag_enabled=? and tag_recommended=?', 1, 1 ], 'tag_text' )
->setKeyField( 'tag_id' )
->setValueField( 'tag_text' )
);
natcasesort( $recommended );
$other = iterator_to_array(
Db::i()->select( 'tag_id,tag_text', static::$databaseTable, [ 'tag_enabled=? and tag_recommended=?', 1, 0 ], 'tag_text' )
->setKeyField( 'tag_id' )
->setValueField( 'tag_text' )
);
natcasesort( $other );
Store::i()->tags = $recommended + $other;
}
return Store::i()->tags;
@@ -740,4 +841,40 @@ class Tag extends Model
/* Queue the background task to remove the tag from all currently tagged items */
Task::queue( 'core', 'UpdateTaggedItems', [ 'tag' => $this->text, 'delete' => true ], 5, [ 'tag' ] );
}
/**
* Get output for API
*
* @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials
* @return array
* @apiresponse int id ID number
* @apiresponse string tag Name
* @apiresponse string text_seo URL Slug
* @apiresponse string headline Headline
* @apiresponse string description description
* @apiresponse string cover_photo Cover Photo Path
* @apiresponse int recommended Recommended
*/
public function apiOutput( Member $authorizedMember = NULL ): array
{
$return = [
'id' => $this->id,
'tag' => $this->_title,
'text-seo' => $this->text_seo,
'headline' => $this->headline,
'url' => (string) $this->url(),
'description' => $this->description,
];
if( $file = $this->coverPhotoFile() )
{
$return[ 'cover_photo' ] = (string) $file;
}else
{
$return[ 'cover_photo' ] = null;
}
$return['recommended'] = $this->recommended;
return $return;
}
}
+7 -31
View File
@@ -11,13 +11,11 @@
namespace IPS\Content;
use DomainException;
use IPS\Content\Search\SearchContent;
use IPS\Member;
use IPS\Node\Model;
use IPS\Request;
use IPS\Settings;
use IPS\Db;
use IPS\Content\Search\Index;
use IPS\Helpers\Form\Text;
use IPS\IPS;
@@ -105,9 +103,7 @@ trait Taggable
*/
public static function definedTags(): array
{
$tags = array_values( Tag::getStore() );
natcasesort( $tags );
return $tags;
return array_values( Tag::getStore() );
}
/**
@@ -144,21 +140,17 @@ trait Taggable
{
$idColumn = static::$databaseColumnId;
$this->tags = array( 'tags' => array(), 'prefix' => NULL );
$tagList = Tag::getStore();
array_walk( $tagList, function( &$value, $key ) use ( &$tagList ) {
$tagList[ $key ] = strtolower( $value );
} );
foreach ( Db::i()->select( '*', 'core_tags', array( 'tag_meta_app=? AND tag_meta_area=? AND tag_meta_id=?', static::$application, static::$module, $this->$idColumn ), 'tag_text' ) as $tag )
{
$tagText = Settings::i()->tags_force_lower ? mb_strtolower( $tag['tag_text'] ) : $tag['tag_text'];
if ( $tag['tag_prefix'] )
{
$this->tags['prefix'] = $tag['tag_text'];
$this->tags['prefix'] = $tagText;
}
/* Sanity check in case the tag is pending deletion */
elseif( in_array( strtolower( $tag['tag_text'] ), $tagList ) )
else
{
$this->tags['tags'][] = $tag['tag_text'];
$this->tags['tags'][] = $tagText;
}
}
}
@@ -304,31 +296,15 @@ trait Taggable
*/
public static function tagsFormField( ?Item $item, Model|string|null $container = null, bool $minimized = FALSE ): Text|NULL
{
/* Include existing tags in case we're editing */
if ( $item )
{
$source = $item->prefix() ? array_merge( array( 'prefix' => $item->prefix() ), $item->tags() ) : $item->tags();
}
else
{
$source = array();
}
/* And get the defined tags */
if( static::definedTags() )
{
$source = array_unique( array_merge( $source, static::definedTags() ) );
}
$options = array(
'autocomplete' => array(
'unique' => TRUE,
'source' => $source,
'source' => static::definedTags(),
'resultItemTemplate' => 'core.autocomplete.tagsResultItem',
'freeChoice' => false,
'minimized' => $minimized,
'addTokenText' => Member::loggedIn()->language()->get( 'tags_optional' ),
'alphabetical' => true
'alphabetical' => false
) );
if ( Settings::i()->tags_force_lower )