Invision Power Services, Inc. * @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc. * @license http://www.invisionpower.com/legal/standards/ * @package IPS Social Suite * @since 3 Oct 2013 * @version SVN_VERSION_NUMBER */ namespace IPS; /* To prevent PHP errors (extending class does not exist) revealing path */ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Abstract Content Model */ abstract class _Content extends \IPS\Patterns\ActiveRecord { /** * @brief [Content\Comment] Database Column Map */ protected static $databaseColumnMap = array(); /** * @brief [Content] Key for hide reasons */ public static $hideLogKey = NULL; /** * @brief [Content\Comment] Language prefix for forms */ public static $formLangPrefix = ''; /** * @brief [Content\Item] Include this content type in user profiles */ public static $includeInUserProfiles = TRUE; /** * @brief Include In Sitemap */ public static $includeInSitemap = TRUE; /** * Should posting this increment the poster's post count? * * @param \IPS\Node\Model|NULL $container Container * @return void */ public static function incrementPostCount( \IPS\Node\Model $container = NULL ) { return TRUE; } /** * Post count for member * * @param \IPS\Member $member The memner * @return int */ public static function memberPostCount( \IPS\Member $member ) { return static::incrementPostCount() ? \IPS\Db::i()->select( 'COUNT(*)', static::$databaseTable, array( static::$databasePrefix . static::$databaseColumnMap['author'] . '=?', $member->member_id ) )->first() : 0; } /** * Load and check permissions * * @return static * @throws \OutOfRangeException */ public static function loadAndCheckPerms( $id ) { $obj = static::load( $id ); if ( !$obj->canView( \IPS\Member::loggedIn() ) ) { throw new \OutOfRangeException; } return $obj; } /** * Construct ActiveRecord from database row * * @param array $data Row from database table * @param bool $updateMultitonStoreIfExists Replace current object in multiton store if it already exists there? * @return static */ public static function constructFromData( $data, $updateMultitonStoreIfExists = TRUE ) { if ( isset( $data[ static::$databaseTable ] ) and is_array( $data[ static::$databaseTable ] ) ) { /* Add author data to multiton storeto prevent ->author() running another query later */ if ( isset( $data['author'] ) and is_array( $data['author'] ) ) { $author = \IPS\Member::constructFromData( $data['author'], FALSE ); if ( isset( $data['author_pfields'] ) ) { unset( $data['author_pfields']['member_id'] ); $author->contentProfileFields(); } } /* Load content */ $obj = parent::constructFromData( $data[ static::$databaseTable ], $updateMultitonStoreIfExists ); /* Return */ return $obj; } else { return parent::constructFromData( $data, $updateMultitonStoreIfExists ); } } /** * Get WHERE clause for Social Group considerations for getItemsWithPermission * * @param string $socialGroupColumn The column which contains the social group ID * @param \IPS\Member $member The member (NULL to use currently logged in member) * @return string */ public static function socialGroupGetItemsWithPermissionWhere( $socialGroupColumn, $member ) { $socialGroups = array(); $member = $member ?: \IPS\Member::loggedIn(); if ( $member->member_id ) { $socialGroups = iterator_to_array( \IPS\Db::i()->select( 'group_id', 'core_sys_social_group_members', array( 'member_id=?', $member->member_id ) ) ); } if ( count( $socialGroups ) ) { return $socialGroupColumn . '=0 OR ( ' . \IPS\Db::i()->in( $socialGroupColumn, $socialGroups ) . ' )'; } else { return $socialGroupColumn . '=0'; } } /** * Check the request for legacy parameters we may need to redirect to * * @return NULL|\IPS\Http\Url */ public function checkForLegacyParameters() { $paramsToSet = array(); $paramsToUnset = array(); /* st=20 needs to go to page=2 (or whatever the comments per page setting is set to) */ if( isset( \IPS\Request::i()->st ) ) { $commentsPerPage = static::getCommentsPerPage(); $paramsToSet['page'] = floor( intval( \IPS\Request::i()->st ) / $commentsPerPage ) + 1; $paramsToUnset[] = 'st'; } /* Did we have any? */ if( count( $paramsToSet ) ) { $url = $this->url(); if( count( $paramsToUnset ) ) { $url = $url->stripQueryString( $paramsToUnset ); } $url = $url->setQueryString( $paramsToSet ); return $url; } return NULL; } /** * Get mapped value * * @param string $key date,content,ip_address,first * @return mixed */ public function mapped( $key ) { if ( isset( static::$databaseColumnMap[ $key ] ) ) { $field = static::$databaseColumnMap[ $key ]; if ( is_array( $field ) ) { $field = array_pop( $field ); } return $this->$field; } return NULL; } /** * Get author * * @return \IPS\Member */ public function author() { if ( $this->mapped('author') or !isset( static::$databaseColumnMap['author_name'] ) or !$this->mapped('author_name') ) { return \IPS\Member::load( $this->mapped('author') ); } else { $guest = new \IPS\Member; $guest->name = $this->mapped('author_name'); return $guest; } } /** * Returns the content * * @return string */ public function content() { return $this->mapped('content'); } /** * Text for use with data-ipsTruncate * Returns the post with paragraphs turned into line breaks * * @param bool $oneLine If TRUE, will use spaces instead of line breaks. Useful if using a single line display. * @return string * @note For now we are removing all HTML. If we decide to change this to remove specific tags in future, we can use \IPS\Text\Parser::removeElements( $this->content() ) */ public function truncated( $oneLine=FALSE ) { /* Specifically remove quotes, and any scripts (which someone with HTML posting allowed may have legitimately enabled */ $text = \IPS\Text\Parser::removeElements( $this->content(), array( 'blockquote', 'script' ) ); /* Convert headers and paragraphs into line breaks or just spaces */ $text = str_replace( array( '

', '', '', '', '', '', '' ), ( $oneLine ? ' ' : '
' ), $text ); /* Add a space at the end of list items to prevent two list items from running into each other */ $text = str_replace( '', ' ', $text ); /* Remove all HTML apart from
s*/ $text = strip_tags( $text, '
' ); /* Remove any
s from the start so there isn't just blank space at the top, but maintaining
s elsewhere */ $text = preg_replace( '/^[(\s)(
)\x{00a0}]*/u', '', $text ); /* Return */ return $text; } /** * Delete Record * * @return void */ public function delete() { if ( $this instanceof \IPS\Content\Reputation ) { $idColumn = static::$databaseColumnId; \IPS\Db::i()->delete( 'core_reputation_index', array( 'app=? AND type=? AND type_id=?', static::$application, static::$reputationType, $this->$idColumn ) ); } parent::delete(); $this->expireWidgetCaches(); } /** * Is this a future entry? * * @return bool */ public function isFutureDate() { if ( $this instanceof \IPS\Content\FuturePublishing ) { if ( isset( static::$databaseColumnMap['is_future_entry'] ) and isset( static::$databaseColumnMap['future_date'] ) ) { $column = static::$databaseColumnMap['future_date']; if ( $this->$column > time() ) { return TRUE; } } } return FALSE; } /** * Return the tooltip blurb for future entries * * @return string */ public function futureDateBlurb() { $column = static::$databaseColumnMap['future_date']; $time = \IPS\DateTime::ts( $this->$column ); return \IPS\Member::loggedIn()->language()->addToStack("content_future_date_blurb", FALSE, array( 'sprintf' => array( $time->localeDate(), $time->localeTime() ) ) ); } /** * Content is hidden? * * @return int * @li -1 is hidden having been hidden by a moderator * @li 0 is unhidden * @li 1 is hidden needing approval * @note The actual column may also contain 2 which means the item is hidden because the parent is hidden, but it is not hidden in itself. This method will return -1 in that case. * * @note A piece of content (item and comment) can have an alias for hidden OR approved. * With hidden: 0=not hidden, 1=hidden (needs moderator approval), -1=hidden by moderator, 2=parent item is hidden * With approved: 1=not hidden, 0=hidden (needs moderator approval), -1=hidden by moderator * * User posting has moderator approval set: When adding an unapproved ITEM (approved=0, hidden=1) you should *not* increment container()->_comments but you should update container()->_unapprovedItems * User posting has moderator approval set: When adding an unapproved COMMENT (approved=0, hidden=1) you should *not* increment item()->num_comments in item or container()->_comments but you should update item()->unapproved_comments and container()->_unapprovedComments * * User post is hidden by moderator (approved=-1, hidden=0) you should decrement item()->num_comments and decrement container()->_comments but *not* increment item()->unapproved_comments or container()->_unapprovedComments * User item is hidden by a moderator (approved=-1, hidden=0) you should decrement container()->comments and subtract comment count from container()->_comments, but *not* increment container()->_unapprovedComments * * Moderator hides item (approved=-1, hidden=-1) you should substract num_comments from container()->_comments. Comments inside item are flagged as approved=-1, hidden=2 but item()->num_comments should not be substracted from * * Comments with a hidden value of 2 should increase item()->num_comments but not container()->_comments * @throws \RuntimeException */ public function hidden() { if ( $this instanceof \IPS\Content\Hideable ) { if ( isset( static::$databaseColumnMap['hidden'] ) ) { $column = static::$databaseColumnMap['hidden']; return ( $this->$column == 2 ) ? -1 : intval( $this->$column ); } elseif ( isset( static::$databaseColumnMap['approved'] ) ) { $column = static::$databaseColumnMap['approved']; return $this->$column == -1 ? intval( $this->$column ) : intval( !$this->$column ); } else { throw new \RuntimeException; } } return 0; } /** * Can see moderation tools * * @note This is used generally to control if the user has permission to see multi-mod tools. Individual content items may have specific permissions * @param \IPS\Member|NULL $member The member to check for or NULL for the currently logged in member * @param \IPS\Node\Model|NULL $container The container * @return bool */ public static function canSeeMultiModTools( \IPS\Member $member = NULL, \IPS\Node\Model $container = NULL ) { return static::modPermission( 'edit', $member, $container ) or static::modPermission( 'hide', $member, $container ) or static::modPermission( 'unhide', $member, $container ) or static::modPermission( 'delete', $member, $container ); } /** * Check Moderator Permission * * @param string $type 'edit', 'hide', 'unhide', 'delete', etc. * @param \IPS\Member|NULL $member The member to check for or NULL for the currently logged in member * @param \IPS\Node\Model|NULL $container The container * @return bool */ public static function modPermission( $type, \IPS\Member $member = NULL, \IPS\Node\Model $container = NULL ) { /* Compatibility checks */ if ( ( $type == 'hide' or $type == 'unhide' ) and !in_array( 'IPS\Content\Hideable', class_implements( get_called_class() ) ) ) { return FALSE; } if ( ( $type == 'pin' or $type == 'unpin' ) and !in_array( 'IPS\Content\Pinnable', class_implements( get_called_class() ) ) ) { return FALSE; } if ( ( $type == 'feature' or $type == 'unfeature' ) and !in_array( 'IPS\Content\Featurable', class_implements( get_called_class() ) ) ) { return FALSE; } if ( ( $type == 'future_publish' ) and !in_array( 'IPS\Content\FuturePublishing', class_implements( get_called_class() ) ) ) { return FALSE; } /* If this is called from a gateway script, i.e. email piping, just return false as we are a "guest" */ if( $member === NULL AND !\IPS\Dispatcher::hasInstance() ) { return FALSE; } /* Load Member */ $member = $member ?: \IPS\Member::loggedIn(); /* Global permission */ if ( $member->modPermission( "can_{$type}_content" ) ) { return TRUE; } /* Per-container permission */ elseif ( $container ) { $containerClass = get_class( $container ); $title = static::$title; if ( isset( $containerClass::$modPerm ) and ( $member->modPermission( $containerClass::$modPerm ) === -1 or ( is_array( $member->modPermission( $containerClass::$modPerm ) ) and in_array( $container->_id, $member->modPermission( $containerClass::$modPerm ) ) ) ) and $member->modPermission( "can_{$type}_{$title}" ) ) { return TRUE; } } /* Still here? return false */ return FALSE; } /** * Do Moderator Action * * @param string $action The action * @param \IPS\Member|NULL $member The member doing the action (NULL for currently logged in member) * @param string|NULL $reason Reason (for hides) * @return void * @throws \OutOfRangeException|\InvalidArgumentException|\RuntimeException */ public function modAction( $action, \IPS\Member $member = NULL, $reason = NULL ) { if( $action === 'approve' ) { $action = 'unhide'; } /* Check it's a valid action */ if ( !in_array( $action, array( 'pin', 'unpin', 'feature', 'unfeature', 'hide', 'unhide', 'move', 'lock', 'unlock', 'delete', 'publish' ) ) ) { throw new \InvalidArgumentException; } /* And that we can do it */ if ( !call_user_func( array( $this, 'can' . ucfirst( $action ) ), $member ) ) { throw new \OutOfRangeException; } /* Log */ \IPS\Session::i()->modLog( 'modlog__action_' . $action, array( static::$title => TRUE, $this->url()->__toString() => FALSE, $this->mapped('title') ?: ( method_exists( $this, 'item' ) ? $this->item()->mapped('title') : NULL ) => FALSE ), ( $this instanceof \IPS\Content\Item ) ? $this : $this->item() ); /* These ones just need a property setting */ if ( in_array( $action, array( 'pin', 'unpin', 'feature', 'unfeature', 'lock', 'unlock' ) ) ) { $val = TRUE; switch ( $action ) { case 'unpin': $val = FALSE; case 'pin': $column = static::$databaseColumnMap['pinned']; break; case 'unfeature': $val = FALSE; case 'feature': $column = static::$databaseColumnMap['featured']; break; case 'unlock': $val = FALSE; case 'lock': if ( isset( static::$databaseColumnMap['locked'] ) ) { $column = static::$databaseColumnMap['locked']; } else { $val = $val ? 'closed' : 'open'; $column = static::$databaseColumnMap['status']; } break; } $this->$column = $val; $this->save(); return; } /* Hide is a tiny bit more complicated */ elseif ( $action === 'hide' ) { $this->hide( $member, $reason ); return; } elseif ( $action === 'unhide' ) { $this->unhide( $member ); return; } /* Delete is just a method */ elseif ( $action === 'delete' ) { $this->delete(); return; } /* Publish is just a method */ elseif ( $action === 'publish' ) { $this->publish(); return; } /* Move is just a method */ elseif ( $action === 'move' ) { $args = func_get_args(); $this->move( $args[2][0], $args[2][1] ); return; } } /** * Hide * * @param \IPS\Member|NULL $member The member doing the action (NULL for currently logged in member) * @param string $reason Reason * @return void */ public function hide( \IPS\Member $member = NULL, $reason = NULL ) { if ( isset( static::$databaseColumnMap['hidden'] ) ) { $column = static::$databaseColumnMap['hidden']; } elseif ( isset( static::$databaseColumnMap['approved'] ) ) { $column = static::$databaseColumnMap['approved']; } else { throw new \RuntimeException; } /* Already hidden? */ if( $this->$column == -1 ) { return; } $this->$column = -1; $this->save(); $this->onHide(); if ( static::$hideLogKey ) { $idColumn = static::$databaseColumnId; \IPS\Db::i()->delete( 'core_soft_delete_log', array( 'sdl_obj_id=? AND sdl_obj_key=?', $this->$idColumn, static::$hideLogKey ) ); \IPS\Db::i()->insert( 'core_soft_delete_log', array( 'sdl_obj_id' => $this->$idColumn, 'sdl_obj_key' => static::$hideLogKey, 'sdl_obj_member_id' => intval( $member ? $member->member_id : \IPS\Member::loggedIn()->member_id ), 'sdl_obj_date' => time(), 'sdl_obj_reason' => $reason, ) ); } if ( $this instanceof \IPS\Content\Tags ) { \IPS\Db::i()->update( 'core_tags_perms', array( 'tag_perm_visible' => 0 ), array( 'tag_perm_aai_lookup=?', $this->tagAAIKey() ) ); } /* Update search index */ if ( $this instanceof \IPS\Content\Searchable ) { \IPS\Content\Search\Index::i()->index( $this ); } $this->expireWidgetCaches(); } /** * Unhide * * @param \IPS\Member|NULL $member The member doing the action (NULL for currently logged in member) * @return void */ public function unhide( $member=NULL ) { /* If we're approving, we have to do extra stuff */ $approving = FALSE; if ( $this->hidden() === 1 ) { $approving = TRUE; if ( isset( static::$databaseColumnMap['approved_by'] ) ) { $column = static::$databaseColumnMap['approved_by']; $this->$column = $member ? $member->member_id : \IPS\Member::loggedIn()->member_id; } if ( isset( static::$databaseColumnMap['approved_date'] ) ) { $column = static::$databaseColumnMap['approved_date']; $this->$column = time(); } } /* Now do the actual stuff */ if ( isset( static::$databaseColumnMap['hidden'] ) ) { $column = static::$databaseColumnMap['hidden']; /* Already approved? */ if( $this->$column == 0 ) { return; } $this->$column = 0; } elseif ( isset( static::$databaseColumnMap['approved'] ) ) { $column = static::$databaseColumnMap['approved']; /* Already approved? */ if( $this->$column == 1 ) { return; } $this->$column = 1; } else { throw new \RuntimeException; } $this->save(); $this->onUnhide( $approving ); /* And update the tags perm cache */ if ( $this instanceof \IPS\Content\Tags ) { \IPS\Db::i()->update( 'core_tags_perms', array( 'tag_perm_visible' => 1 ), array( 'tag_perm_aai_lookup=?', $this->tagAAIKey() ) ); } /* Update search index */ if ( $this instanceof \IPS\Content\Searchable ) { \IPS\Content\Search\Index::i()->index( $this ); } /* Send notifications if necessary */ if ( $approving ) { $this->sendApprovedNotification(); } } /** * Send /** * Blurb for when/why/by whom this content was hidden * * @return string */ public function hiddenBlurb() { if ( !( $this instanceof \IPS\Content\Hideable ) or !static::$hideLogKey ) { throw new \BadMethodCallException; } try { $idColumn = static::$databaseColumnId; $log = \IPS\Db::i()->select( '*', 'core_soft_delete_log', array( 'sdl_obj_id=? AND sdl_obj_key=?', $this->$idColumn, static::$hideLogKey ) )->first(); return \IPS\Member::loggedIn()->language()->addToStack('hidden_blurb', FALSE, array( 'sprintf' => array( \IPS\Member::load( $log['sdl_obj_member_id'] )->name, \IPS\DateTime::ts( $log['sdl_obj_date'] )->relative(), $log['sdl_obj_reason'] ?: \IPS\Member::loggedIn()->language()->addToStack('hidden_no_reason') ) ) ); } catch ( \UnderflowException $e ) { return \IPS\Member::loggedIn()->language()->addToStack('hidden'); } } /** * Can report? * * @param \IPS\Member|NULL $member The member to check for (NULL for currently logged in member) * @return TRUE|string TRUE or a language string for why not * @note This requires a few queries, so don't run a check in every template */ public function canReport( $member=NULL ) { $member = $member ?: \IPS\Member::loggedIn(); /* Is this type of comment reportabe? */ if ( !( $this instanceof \IPS\Content\ReportCenter ) ) { return 'generic_error'; } /* Can the member report content? */ if ( $member->group['gbw_no_report'] ) { return 'no_module_permission'; } /* Can they view this? */ if ( !$this->canView() ) { return 'no_module_permission'; } /* Have they already subitted a report? */ $idColumn = static::$databaseColumnId; $report = \IPS\Db::i()->select( 'id', 'core_rc_index', array( 'class=? AND content_id=?', get_called_class(), $this->$idColumn ) ); if ( count( $report ) ) { $report = \IPS\Db::i()->select( '*', 'core_rc_reports', array( 'rid=? AND report_by=?', $report->first(), $member->member_id ) ); if ( count( $report ) ) { return 'report_err_already_reported'; } } return TRUE; } /** * Report * * @param string $reportContent Report content message from member * @return \\IPS\core\Reports\Report * @throws \UnexpectedValueException If there is a permission error - you should only call this method after checking canReport */ public function report( $reportContent ) { /* Permission check */ if ( $this->canReport() !== TRUE ) { throw new \UnexpectedValueException; } /* Find or create an index */ $idColumn = static::$databaseColumnId; try { $index = \IPS\core\Reports\Report::load( $this->$idColumn, 'content_id', array( 'class=?', get_called_class() ) ); } catch ( \OutOfRangeException $e ) { $index = new \IPS\core\Reports\Report; $index->class = get_called_class(); $index->content_id = $this->$idColumn; $index->perm_id = $this->permId(); $index->first_report_by = (int) \IPS\Member::loggedIn()->member_id; $index->first_report_date = time(); $index->author = (int) $this->author()->member_id; } $index->status = 1; $index->save(); /* Create a report */ $reportInsert = array( 'rid' => $index->id, 'report' => $reportContent, 'report_by' => (int) \IPS\Member::loggedIn()->member_id, 'date_reported' => time(), 'ip_address' => \IPS\Request::i()->ipAddress() ); \IPS\Db::i()->insert( 'core_rc_reports', $reportInsert ); /* Rebuild */ $index->rebuild(); /* Send notification to mods */ $moderators = array( 'm' => array(), 'g' => array() ); foreach ( \IPS\Db::i()->select( '*', 'core_moderators' ) as $mod ) { $canView = FALSE; if ( $mod['perms'] == '*' ) { $canView = TRUE; } if ( $canView === FALSE ) { $perms = json_decode( $mod['perms'], TRUE ); if ( isset( $perms['can_view_reports'] ) AND $perms['can_view_reports'] === TRUE ) { $canView = TRUE; } } if ( $canView === TRUE ) { $moderators[ $mod['type'] ][] = $mod['id']; } } $notification = new \IPS\Notification( \IPS\Application::load('core'), 'report_center', $index, array( $index, $reportInsert, $this ) ); foreach ( \IPS\Db::i()->select( '*', 'core_members', ( count( $moderators['m'] ) ? \IPS\Db::i()->in( 'member_id', $moderators['m'] ) . ' OR ' : '' ) . \IPS\Db::i()->in( 'member_group_id', $moderators['g'] ) . ' OR ' . \IPS\Db::i()->findInSet( 'mgroup_others', $moderators['g'] ) ) as $member ) { $notification->recipients->attach( \IPS\Member::constructFromData( $member ) ); } $notification->send(); /* Return */ return $index; } /** * Change Author * * @param \IPS\Member $newAuthor The new author * @return void */ public function changeAuthor( \IPS\Member $newAuthor ) { $oldAuthor = $this->author(); foreach ( array( 'author', 'author_name' ) as $k ) { if ( isset( static::$databaseColumnMap[ $k ] ) ) { $col = static::$databaseColumnMap[ $k ]; switch ( $k ) { case 'author': $this->$col = $newAuthor->member_id ? $newAuthor->member_id : 0; break; case 'author_name': $this->$col = $newAuthor->member_id ? $newAuthor->name : ''; break; } } } $this->save(); if ( \IPS\Dispatcher::hasInstance() ) { \IPS\Session::i()->modLog( 'modlog__action_changeauthor', array( static::$title => TRUE, $this->url()->__toString() => FALSE, $this->mapped('title') ?: ( method_exists( $this, 'item' ) ? $this->item()->mapped('title') : NULL ) => FALSE ), ( $this instanceof \IPS\Content\Item ) ? $this : $this->item() ); } } /** * Get HTML for search result display * * @param array $indexData Data from the search index * @param array $authorData Basic data about the author. Only includes columns returned by \IPS\Member::columnsForPhoto() * @param array $itemData Basic data about the item. Only includes columns returned by item::basicDataColumns() * @param array|NULL $containerData Basic data about the author. Only includes columns returned by container::basicDataColumns() * @param array $reputationData Array of people who have given reputation and the reputation they gave * @param int|NULL $reviewRating If this is a review, the rating * @param bool $iPostedIn If the user has posted in the item * @param string $view 'expanded' or 'condensed' * @param bool $asItem Displaying results as items? * @return string */ public static function searchResult( array $indexData, array $authorData, array $itemData, array $containerData = NULL, array $reputationData, $reviewRating, $iPostedIn, $view, $asItem ) { /* Item details */ $itemClass = $indexData['index_class']; if ( in_array( 'IPS\Content\Comment', class_parents( get_called_class() ) ) ) { $itemClass = static::$itemClass; $unread = $itemClass::unreadFromData( NULL, $indexData['index_date_updated'], $indexData['index_date_created'], $indexData['index_item_id'], $indexData['index_container_id'], FALSE ); } else { $unread = static::unreadFromData( NULL, $indexData['index_date_updated'], $indexData['index_date_created'], $indexData['index_item_id'], $indexData['index_container_id'], FALSE ); } $itemUrl = $itemClass::urlFromIndexData( $indexData, $itemData ); /* Object URL */ $indefiniteArticle = static::_indefiniteArticle( $containerData ); if ( in_array( 'IPS\Content\Comment', class_parents( get_called_class() ) ) ) { if ( in_array( 'IPS\Content\Review', class_parents( get_called_class() ) ) ) { $objectUrl = $itemUrl->setQueryString( array( 'do' => 'findReview', 'review' => $indexData['index_object_id'] ) ); $showRepUrl = $itemUrl->setQueryString( array( 'do' => 'showRepReview', 'review' => $indexData['index_object_id'] ) ); } else { $objectUrl = $itemUrl->setQueryString( array( 'do' => 'findComment', 'comment' => $indexData['index_object_id'] ) ); $showRepUrl = $itemUrl->setQueryString( array( 'do' => 'showRepComment', 'comment' => $indexData['index_object_id'] ) ); } $indefiniteArticle = $itemClass::_indefiniteArticle( $containerData ); } else { $objectUrl = $itemUrl; $showRepUrl = $itemUrl->setQueryString( 'do', 'showRep' ); } $articles = array( 'indefinite' => $indefiniteArticle, 'definite' => \IPS\Member::loggedIn()->language()->addToStack( $itemClass::$title, FALSE, array( 'strtolower' => TRUE ) ) ); /* Container details */ $containerUrl = NULL; $containerTitle = NULL; if ( isset( $itemClass::$containerNodeClass ) ) { $containerClass = $itemClass::$containerNodeClass; $containerTitle = \IPS\Member::loggedIn()->language()->addToStack( $containerClass::$titleLangPrefix . $indexData['index_container_id'] ); $containerUrl = $containerClass::urlFromIndexData( $indexData, $itemData, $containerData ); } /* Reputation */ $repCount = array_sum( $reputationData ); /* Snippet */ $snippet = static::searchResultSnippet( $indexData, $authorData, $itemData, $containerData, $reputationData, $reviewRating, $view ); /* Return */ return \IPS\Theme::i()->getTemplate( 'system', 'core', 'front' )->searchResult( $indexData, $articles, $authorData, $itemData, $unread, $asItem ? $itemUrl : $objectUrl, $itemUrl, $containerUrl, $containerTitle, $repCount, $showRepUrl, $snippet, $iPostedIn, $view ); } /** * Get snippet HTML for search result display * * @param array $indexData Data from the search index * @param array $authorData Basic data about the author. Only includes columns returned by \IPS\Member::columnsForPhoto() * @param array $itemData Basic data about the item. Only includes columns returned by item::basicDataColumns() * @param array|NULL $containerData Basic data about the author. Only includes columns returned by container::basicDataColumns() * @param array $reputationData Array of people who have given reputation and the reputation they gave * @param int|NULL $reviewRating If this is a review, the rating * @param string $view 'expanded' or 'condensed' * @return callable */ public static function searchResultSnippet( array $indexData, array $authorData, array $itemData, array $containerData = NULL, array $reputationData, $reviewRating, $view ) { return $view == 'expanded' ? \IPS\Theme::i()->getTemplate( 'system', 'core', 'front' )->searchResultSnippet( $indexData ) : ''; } /** * Return the filters that are available for selecting table rows * * @return array */ public static function getTableFilters() { $return = array(); if ( in_array( 'IPS\Content\Hideable', class_implements( get_called_class() ) ) ) { $return[] = 'hidden'; $return[] = 'unhidden'; $return[] = 'unapproved'; } return $return; } /** * Get content table states * * @return string */ public function tableStates() { $return = array(); if ( $this instanceof \IPS\Content\Hideable ) { switch ( $this->hidden() ) { case -1: $return[] = 'hidden'; break; case 0: $return[] = 'unhidden'; break; case 1: $return[] = 'unapproved'; break; } } return implode( ' ', $return ); } /* !Reputation */ /** * Can give reputation? * * @note This method is also ran to check if a member can "unrep" * @param int $type 1 for positive, -1 for negative * @param \IPS\Member|NULL $member The member to check for (NULL for currently logged in member) * @return bool */ public function canGiveReputation( $type, \IPS\Member $member = NULL ) { return static::_canGiveReputation( $type, $this->author()->member_id, $this->author()->member_group_id, $this->repGiven( $member ), $member ); } /** * Can give reputation? * * @note This method is also ran to check if a member can "unrep" * @param int $type 1 for positive, -1 for negative * @param int $authorId The author ID * @param int $authorGroup The author group * @param int $repGiven The reputation we have already given (1 = positive, -1 = negative, 0 = no rep given) * @param \IPS\Member|NULL $member The member to check for (NULL for currently logged in member) * @return bool */ public static function _canGiveReputation( $type, $authorId, $authorGroup, $repGiven, \IPS\Member $member = NULL ) { /* Reputation needs to be enabled */ if ( !\IPS\Settings::i()->reputation_enabled or !in_array( 'IPS\Content\Reputation', class_implements( get_called_class() ) ) ) { return FALSE; } /* Guests cannot rep */ $member = $member ?: \IPS\Member::loggedIn(); if ( !$member->member_id ) { return FALSE; } /* Author needs to not be protected */ if ( in_array( $authorGroup, explode( ',', \IPS\Settings::i()->reputation_protected_groups ) ) ) { return FALSE; } /* Users may not be able to rep themselves */ if ( !\IPS\Settings::i()->reputation_can_self_vote and $authorId == $member->member_id ) { return FALSE; } /* Rep per day */ $limit = ( $type === 1 ) ? $member->group['g_rep_max_positive'] : $member->group['g_rep_max_negative']; if ( $limit == 0 and !$repGiven ) { return FALSE; } /* Have we already given rep? */ switch ( \IPS\Settings::i()->reputation_point_types ) { /* If we're using a positive system, we can rep positive only if we haven't repped yet, or negative only if we have */ case 'like': case 'positive': return ( $repGiven === 1 ) ? ( $type === -1 ) : ( $type === 1 ); /* If we're using a negative system, we can rep negative only if we haven't repped yet, or positive only if we have */ case 'negative': return $repGiven ? ( $type === 1 ) : ( $type === -1 ); /* If we're using a positive and negative system - it's more complicated... */ case 'both': switch ( $repGiven ) { /* If we haven't repped yet, we can do either */ case 0: return TRUE; /* If we've given positive rep already, we can only do negative now */ case 1: return ( $type === -1 ); /* If we've given negative rep already, we can only do positive now */ case -1: return ( $type === 1 ); } } /* Catch all */ return FALSE; } /** * Give reputation * * @param int $type 1 for positive, -1 for negative * @param \IPS\Member|NULL $member The member to check for (NULL for currently logged in member) * @return void * @throws \DomainException|\BadMethodCallException */ public function giveReputation( $type, \IPS\Member $member=NULL ) { if ( !( $this instanceof \IPS\Content\Reputation ) ) { throw new \BadMethodCallException; } $member = $member ?: \IPS\Member::loggedIn(); if ( !$this->canGiveReputation( $type, $member ) ) { throw new \DomainException( 'cannot_rep_user' ); } $idColumn = static::$databaseColumnId; \IPS\Db::i()->delete( 'core_reputation_index', array( 'app=? AND type=? AND member_id=? AND type_id=?', static::$application, static::$reputationType, $member->member_id, $this->$idColumn ) ); if ( $this->repGiven( $member ) ) { if ( $this->reputation !== NULL ) { unset( $this->reputation[ $member->member_id ] ); } } else { $limit = ( $type === 1 ) ? $member->group['g_rep_max_positive'] : $member->group['g_rep_max_negative']; if ( $limit !== 999 ) { $count = \IPS\Db::i()->select( 'COUNT(*)', 'core_reputation_index', array( 'member_id=? AND rep_date>? AND rep_rating=?', $member->member_id, \IPS\DateTime::create()->sub( new \DateInterval( 'P1D' ) )->getTimestamp(), $type ) )->first(); if ( $count >= $limit ) { throw new \DomainException( \IPS\Member::loggedIn()->language()->addToStack( \IPS\Settings::i()->reputation_point_types == 'like' ? 'rep_daily_exceeded_likes' :'rep_daily_exceeded', FALSE, array( 'sprintf' => array( $limit ) ) ) ); } } \IPS\Db::i()->insert( 'core_reputation_index', array( 'member_id' => $member->member_id, 'app' => static::$application, 'type' => static::$reputationType, 'type_id' => $this->$idColumn, 'rep_date' => time(), 'rep_rating' => $type, 'member_received' => (int) $this->author()->member_id ) ); /* Send Notification */ if ( $type === 1 AND $this->author()->member_id AND $this->author() != \IPS\Member::loggedIn() AND $this->canView( $this->author() ) ) { $notification = new \IPS\Notification( \IPS\Application::load('core'), 'new_likes', $this, array( $this, \IPS\Member::loggedIn() ) ); $notification->recipients->attach( $this->author() ); $notification->send(); } if ( $this->reputation !== NULL ) { $this->reputation[ $member->member_id ] = $type; } } if( $this->author()->member_id ) { $this->author()->pp_reputation_points += $type; $this->author()->save(); } } /** * @brief Reputation Cache */ public $reputation = NULL; /** * Get reputation count * * @return int * @throws \BadMethodCallException */ public function reputation() { if ( !( $this instanceof \IPS\Content\Reputation ) ) { throw new \BadMethodCallException; } if ( $this->reputation === NULL ) { $this->reputation = iterator_to_array( \IPS\Db::i()->select( 'member_id, rep_rating', 'core_reputation_index', $this->getReputationWhereClause() )->setKeyField( 'member_id' )->setValueField( 'rep_rating' ) ); return array_sum( $this->reputation ); } else { $repCount = 0; foreach( $this->reputation as $member => $value ) { if( is_int( $value ) ) { $repCount += $value; } else { $repCount += $value['rep_rating']; } } return $repCount; } } /** * Get reputation where clause * * @return array * @throws \BadMethodCallException */ public function getReputationWhereClause() { if ( !( $this instanceof \IPS\Content\Reputation ) ) { throw new \BadMethodCallException; } $idColumn = static::$databaseColumnId; $where = array( array( 'app=? AND type=? AND type_id=?', static::$application, static::$reputationType, $this->$idColumn ) ); switch( \IPS\Settings::i()->reputation_point_types ) { case 'positive': case 'like': $where[] = array( 'rep_rating=?', "1" ); break; case 'negative': $where[] = array( 'rep_rating=?', "-1" ); break; } return $where; } /** * @brief Cached like blurb */ public $likeBlurb = NULL; /** * Get "like" blurb (You and X others like this) * * @return string */ public function likeBlurb() { if( $this->likeBlurb !== NULL ) { return $this->likeBlurb; } $reputation = $this->reputation(); $people = array(); /* Nobody */ if ( !$reputation ) { return ''; } /* You */ if ( $this->repGiven() === 1 ) { $reputation--; if ( $reputation ) { $people[] = \IPS\Member::loggedIn()->language()->addToStack('like_blurb_you_and_others'); } else { $this->likeBlurb = \IPS\Member::loggedIn()->language()->addToStack('like_blurb_just_you'); return $this->likeBlurb; } } if( \IPS\Member::loggedIn()->group['gbw_view_reps'] ) { /* 3 random names */ $idColumn = static::$databaseColumnId; foreach ( \IPS\Db::i()->select( '*', 'core_reputation_index', array( 'app=? AND type=? AND type_id=? AND member_id!=? AND rep_rating=?', static::$application, static::$reputationType, $this->$idColumn, ( \IPS\Member::loggedIn()->member_id ) ?: 0, 1 ), 'RAND()', 3 ) as $rep ) { $people[] = \IPS\Theme::i()->getTemplate( 'global', 'core' )->userLink( \IPS\Member::load( $rep['member_id'] ) ); $reputation--; } } /* X others */ $count = count( $people ) + $reputation; if ( $reputation ) { $others = \IPS\Member::loggedIn()->language()->addToStack( empty( $people ) ? 'like_blurb_generic' : 'like_blurb_others', FALSE, array( 'pluralize' => array( $reputation ) ) ); if ( \IPS\Member::loggedIn()->group['gbw_view_reps'] ) { $others = \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' )->reputationOthers( $this, $others ); } $people[] = $others; } $this->likeBlurb = \IPS\Member::loggedIn()->language()->addToStack( 'like_blurb', FALSE, array( 'pluralize' => array( $count ), 'htmlsprintf' => array( \IPS\Member::loggedIn()->language()->formatList( $people ) ) ) ); return $this->likeBlurb; } /** * Has reputation been given by a particular member? * * @param \IPS\Member|NULL $member The member to check for (NULL for currently logged in member) * @return int 1 = Positive rep given. -1 = Negative rep given. 0 = No rep given * @throws \BadMethodCallException */ public function repGiven( \IPS\Member $member = NULL ) { if ( !( $this instanceof \IPS\Content\Reputation ) ) { throw new \BadMethodCallException; } if ( $this->reputation === NULL ) { $this->reputation(); } $member = $member ?: \IPS\Member::loggedIn(); return array_key_exists( $member->member_id, $this->reputation ) ? intval( $this->reputation[ $member->member_id ] ) : 0; } /** * Get table showing who has given reputation * * @return \IPS\Helpers\Table\Db * @throws \DomainException * @throws \BadMethodCallException */ public function reputationTable() { if ( !( $this instanceof \IPS\Content\Reputation ) ) { throw new \BadMethodCallException; } if ( !\IPS\Member::loggedIn()->group['gbw_view_reps'] ) { throw new \DomainException; } $idColumn = static::$databaseColumnId; $table = new \IPS\Helpers\Table\Db( 'core_reputation_index', $this->url('showRep'), $this->getReputationWhereClause() ); $table->sortBy = 'rep_date'; $table->sortDirection = 'desc'; $table->tableTemplate = array( \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' ), 'reputationLogTable' ); $table->rowsTemplate = array( \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' ), 'reputationLog' ); return $table; } /* !Follow */ const FOLLOW_PUBLIC = 1; const FOLLOW_ANONYMOUS = 2; const NOTIFICATIONS_PER_BATCH = 50; /** * Send notifications * * @return void */ public function sendNotifications() { /* Send quote and mention notifications */ $sentTo = $this->sendQuoteAndMentionNotifications(); /* How many followers? */ $idColumn = $this::$databaseColumnId; try { $count = $this->notificationRecipients()->count( TRUE ); } catch ( \BadMethodCallException $e ) { return; } /* Queue if there's lots, or just send them */ if ( $count > static::NOTIFICATIONS_PER_BATCH ) { \IPS\Task::queue( 'core', 'Follow', array( 'class' => get_class( $this ), 'item' => $this->$idColumn, 'exclude' => array_keys( $sentTo ) ), 1 ); } else { $this->sendNotificationsBatch( 0, $sentTo ); } } /** * Send notifications batch * * @param int $offset Current offset * @param array $exclude IDs to exclude * @param string|NULL $extra Additional data * @return int|null New offset or NULL if complete */ public function sendNotificationsBatch( $offset=0, $exclude=array(), $extra=NULL ) { $followIds = array(); $followers = $this->notificationRecipients( array( $offset, static::NOTIFICATIONS_PER_BATCH ), $extra ); /* Send notification */ $notification = $this->createNotification( $extra ); $notification->unsubscribeType = 'follow'; foreach ( $followers as $follower ) { $member = \IPS\Member::load( $follower['follow_member_id'] ); if ( $member != $this->author() and $this->canView( $member ) and !in_array( $follower['follow_member_id'], $exclude ) ) { $followIds[] = $follower['follow_id']; $notification->recipients->attach( $member, $follower ); } } $notification->send(); /* Log that we sent it */ if( count( $followIds ) ) { \IPS\Db::i()->update( 'core_follow', array( 'follow_notify_sent' => time() ), \IPS\Db::i()->in( 'follow_id', $followIds ) ); } /* Update the queue */ $newOffset = $offset + static::NOTIFICATIONS_PER_BATCH; if ( $newOffset > $followers->count( TRUE ) ) { return NULL; } return $newOffset; } /** * Send Approved Notification * * @return void */ public function sendApprovedNotification() { $this->sendNotifications(); } /** * Send Unapproved Notification * * @return void */ public function sendUnapprovedNotification() { $moderators = array( 'g' => array(), 'm' => array() ); foreach( \IPS\Db::i()->select( '*', 'core_moderators' ) AS $mod ) { $canView = FALSE; if ( $mod['perms'] == '*' ) { $canView = TRUE; } if ( $canView === FALSE ) { $perms = json_decode( $mod['perms'], TRUE ); if ( isset( $perms['can_view_hidden_content'] ) AND $perms['can_view_hidden_content'] ) { $canView = TRUE; } else if ( isset( $perms['can_view_hidden_' . static::$title ] ) AND $perms['can_view_hidden_' . static::$title ] ) { $canView = TRUE; } } if ( $canView === TRUE ) { $moderators[ $mod['type'] ][] = $mod['id']; } } $notification = new \IPS\Notification( \IPS\Application::load('core'), 'unapproved_content', $this, array( $this, $this->author() ) ); foreach ( \IPS\Db::i()->select( '*', 'core_members', ( count( $moderators['m'] ) ? \IPS\Db::i()->in( 'member_id', $moderators['m'] ) . ' OR ' : '' ) . \IPS\Db::i()->in( 'member_group_id', $moderators['g'] ) . ' OR ' . \IPS\Db::i()->findInSet( 'mgroup_others', $moderators['g'] ) ) as $member ) { /* We don't need to notify the author of the content */ if( $this->author()->member_id != $member['member_id'] ) { $notification->recipients->attach(\IPS\Member::constructFromData($member)); } } $notification->send(); } /** * Send the notifications after the content has been edited (for any new quotes or mentiones) * * @param string $oldContent The content before the edit * @return void */ public function sendAfterEditNotifications( $oldContent ) { $existingData = static::_getQuoteAndMentionIdsFromContent( $oldContent ); $this->sendQuoteAndMentionNotifications( array_unique( array_merge( $existingData['quotes'], $existingData['mentions'] ) ) ); } /** * Send quote and mention notifications * * @param array $exclude An array of member IDs *not* to send notifications to * @return array Member IDs sent to */ protected function sendQuoteAndMentionNotifications( $exclude=array() ) { return $this->_sendQuoteAndMentionNotifications( static::_getQuoteAndMentionIdsFromContent( $this->content() ), $exclude ); } /** * Send quote and mention notifications from data * * @param array array( 'quotes' => array( ... member IDs ... ), 'mentions' => array( ... member IDs ... ) ) * @param array $exclude An array of member IDs *not* to send notifications to * @return array Member IDs sent to */ protected function _sendQuoteAndMentionNotifications( $data, $exclude=array() ) { /* Init */ $sentTo = array(); /* Quotes */ $data['quotes'] = array_filter( $data['quotes'], function( $v ) use ( $exclude ) { return !in_array( $v, $exclude ); } ); if ( !empty( $data['quotes'] ) ) { $notification = new \IPS\Notification( \IPS\Application::load( 'core' ), 'quote', ( $this instanceof \IPS\Content\Item ) ? $this : $this->item(), array( $this ), array( $this->author()->member_id ) ); foreach ( $data['quotes'] as $quote ) { $member = \IPS\Member::load( $quote ); if ( $member != $this->author() and $this->canView( $member ) ) { $notification->recipients->attach( $member ); } } $sentTo = array_merge( $notification->send(), $sentTo ); } /* Mentions */ $data['mentions'] = array_filter( $data['mentions'], function( $v ) use ( $exclude ) { return !in_array( $v, $exclude ); } ); if ( !empty( $data['mentions'] ) ) { $notification = new \IPS\Notification( \IPS\Application::load( 'core' ), 'mention', ( $this instanceof \IPS\Content\Item ) ? $this : $this->item(), array( $this ), array( $this->author()->member_id ) ); foreach ( $data['mentions'] as $mention ) { $member = \IPS\Member::load( $mention ); if ( $member != $this->author() and $this->canView( $member ) ) { $notification->recipients->attach( $member ); } } $sentTo = array_merge( $notification->send(), $sentTo ); } /* Return */ return $sentTo; } /** * Get quote and mention notifications * * @param string $content The content * @return array array( 'quotes' => array( ... member IDs ... ), 'mentions' => array( ... member IDs ... ) ) */ protected static function _getQuoteAndMentionIdsFromContent( $content ) { $return = array( 'quotes' => array(), 'mentions' => array() ); $document = new \DOMDocument; libxml_use_internal_errors(TRUE); if ( @$document->loadHTML( '
' . $content . '
' ) !== FALSE ) { /* Quotes */ foreach( $document->getElementsByTagName('blockquote') as $quote ) { if ( $quote->getAttribute('data-ipsquote-userid') and (int) $quote->getAttribute('data-ipsquote-userid') > 0 ) { $return['quotes'][] = $quote->getAttribute('data-ipsquote-userid'); } } /* Mentions */ foreach( $document->getElementsByTagName('a') as $link ) { if ( $link->getAttribute('data-mentionid') ) { $path = explode( '/', $link->getNodePath() ); if ( !in_array( 'blockquote', $path ) ) { $return['mentions'][] = $link->getAttribute('data-mentionid'); } } } } return $return; } /** * Expire appropriate widget caches automatically * * @return void */ public function expireWidgetCaches() { \IPS\Widget::deleteCaches( NULL, static::$application ); } /** * Fetch classes from content router * * @param bool|\IPS\Member $member Check member access * @param bool $archived Include any supported archive classes * @param bool $onlyItems Only include item classes * @return array */ public static function routedClasses( $member=FALSE, $archived=FALSE, $onlyItems=FALSE ) { $classes = array(); foreach ( \IPS\Application::allExtensions( 'core', 'ContentRouter', $member, NULL, NULL, TRUE ) as $router ) { foreach ( $router->classes as $class ) { $classes[] = $class; if( $onlyItems ) { continue; } if ( !( $member instanceof \IPS\Member ) ) { $member = $member ? \IPS\Member::loggedIn() : NULL; } if ( isset( $class::$commentClass ) and $class::supportsComments( $member ) ) { $classes[] = $class::$commentClass; } if ( isset( $class::$reviewClass ) and $class::supportsReviews( $member ) ) { $classes[] = $class::$reviewClass; } if( $archived === TRUE AND isset( $class::$archiveClass ) ) { $classes[] = $class::$archiveClass; } } } return $classes; } /** * Return any custom multimod actions this content item supports * * @return array */ public function customMultimodActions() { return array(); } /** * Return any available custom multimod actions this content item class supports * * @note Return in format of EITHER * @li array( array( 'action' => ..., 'icon' => ..., 'label' => ... ), ... ) * @li array( array( 'grouplabel' => ..., 'icon' => ..., 'groupaction' => ..., 'action' => array( array( 'action' => ..., 'label' => ... ), ... ) ) ) * @note For an example, look at \IPS\core\Announcements\Announcement * @return array */ public static function availableCustomMultimodActions() { return array(); } /** * Get HTML for search result display * * @return callable */ public function approvalQueueHtml( $ref=NULL, $container, $title ) { return \IPS\Theme::i()->getTemplate( 'modcp', 'core', 'front' )->approvalQueueItem( $this, $ref, $container, $title ); } /** * Indefinite Article * * @param \IPS\Lang|NULL $language The language to use, or NULL for the language of the currently logged in member * @return string */ public function indefiniteArticle( \IPS\Lang $lang = NULL ) { $container = ( $this instanceof \IPS\Content\Comment ) ? $this->item()->containerWrapper() : $this->containerWrapper(); return static::_indefiniteArticle( $container ? $container->_data : array(), $lang ); } /** * Indefinite Article * * @param \IPS\Lang|NULL $language The language to use, or NULL for the language of the currently logged in member * @return string */ public static function _indefiniteArticle( array $containerData = NULL, \IPS\Lang $lang = NULL ) { $lang = $lang ?: \IPS\Member::loggedIn()->language(); return $lang->addToStack( '__indefart_' . static::$title, FALSE ); } }