Page MenuHomeWickedGov Phorge

GlobalContributionsPager.php
No OneTemporary

Size
32 KB
Referenced Files
None
Subscribers
None

GlobalContributionsPager.php

<?php
namespace MediaWiki\CheckUser\GlobalContributions;
use GlobalPreferences\GlobalPreferencesFactory;
use HtmlArmor;
use InvalidArgumentException;
use LogicException;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\CheckUser\CheckUserQueryInterface;
use MediaWiki\CheckUser\Jobs\LogTemporaryAccountAccessJob;
use MediaWiki\CheckUser\Logging\TemporaryAccountLogger;
use MediaWiki\CheckUser\Services\CheckUserLookupUtils;
use MediaWiki\CommentFormatter\CommentFormatter;
use MediaWiki\Context\IContextSource;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Html\Html;
use MediaWiki\Html\TemplateParser;
use MediaWiki\JobQueue\JobQueueGroup;
use MediaWiki\Linker\Linker;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Linker\UserLinkRenderer;
use MediaWiki\Pager\ContributionsPager;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\RecentChanges\ChangesList;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\RevisionStoreFactory;
use MediaWiki\SpecialPage\ContributionsRangeTrait;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use MediaWiki\User\CentralId\CentralIdLookup;
use MediaWiki\User\TempUser\TempUserConfig;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\WikiMap\WikiMap;
use OOUI\HtmlSnippet;
use OOUI\MessageWidget;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\Rdbms\SelectQueryBuilder;
use Wikimedia\Timestamp\ConvertibleTimestamp;
/**
* Query for all edits from one of:
* - an IP, revealing what temporary accounts are using that IP
* - a username (temporary or registered), revealing all edits from the account
*
* This pager uses data from the CheckUser table as it can reveal IP activity
* which only CU should have knowledge of. Therefore, data is limited to 90 days.
*/
class GlobalContributionsPager extends ContributionsPager implements CheckUserQueryInterface {
use ContributionsRangeTrait;
private TempUserConfig $tempUserConfig;
private CheckUserLookupUtils $checkUserLookupUtils;
private CentralIdLookup $centralIdLookup;
private CheckUserGlobalContributionsLookup $globalContributionsLookup;
private CommentFormatter $commentFormatter;
private PermissionManager $permissionManager;
private GlobalPreferencesFactory $globalPreferencesFactory;
private IConnectionProvider $dbProvider;
private JobQueueGroup $jobQueueGroup;
private UserLinkRenderer $userLinkRenderer;
private RevisionStoreFactory $revisionStoreFactory;
private ExternalPermissions $permissions;
private int $wikisWithPermissionsCount;
private string $needsToEnableGlobalPreferenceAtWiki;
private bool $centralUserExists = true;
/**
* Map of revision sizes keyed by wiki ID and revision ID.
* @var int[][]
*/
private array $parentRevisionSizes = [];
/**
* @var int Number of revisions to return per wiki
*/
public const REVISION_COUNT_LIMIT = 20;
public function __construct(
LinkRenderer $linkRenderer,
LinkBatchFactory $linkBatchFactory,
HookContainer $hookContainer,
RevisionStore $revisionStore,
NamespaceInfo $namespaceInfo,
CommentFormatter $commentFormatter,
UserFactory $userFactory,
TempUserConfig $tempUserConfig,
CheckUserLookupUtils $checkUserLookupUtils,
CentralIdLookup $centralIdLookup,
CheckUserGlobalContributionsLookup $globalContributionsLookup,
PermissionManager $permissionManager,
GlobalPreferencesFactory $globalPreferencesFactory,
IConnectionProvider $dbProvider,
JobQueueGroup $jobQueueGroup,
UserLinkRenderer $userLinkRenderer,
RevisionStoreFactory $revisionStoreFactory,
IContextSource $context,
array $options,
?UserIdentity $target = null
) {
$options['runHooks'] = false;
parent::__construct(
$linkRenderer,
$linkBatchFactory,
$hookContainer,
$revisionStore,
$namespaceInfo,
$commentFormatter,
$userFactory,
$context,
$options,
$target
);
$this->tempUserConfig = $tempUserConfig;
$this->checkUserLookupUtils = $checkUserLookupUtils;
$this->centralIdLookup = $centralIdLookup;
$this->commentFormatter = $commentFormatter;
$this->globalContributionsLookup = $globalContributionsLookup;
$this->permissionManager = $permissionManager;
$this->globalPreferencesFactory = $globalPreferencesFactory;
$this->dbProvider = $dbProvider;
$this->jobQueueGroup = $jobQueueGroup;
$this->templateParser = new TemplateParser( __DIR__ . '/../../templates' );
$this->userLinkRenderer = $userLinkRenderer;
$this->revisionStoreFactory = $revisionStoreFactory;
$this->permissions = new ExternalPermissions();
}
/**
* Fetch the permissions needed by this pager from a list of external wikis. Store them
* in the $permissions property.
*
* @param string[] $wikiIds
*/
private function getExternalWikiPermissions( $wikiIds ) {
$centralId = $this->centralIdLookup->centralIdFromLocalUser( $this->getUser() );
if ( !$centralId ) {
// No central permissions to check for, return an empty array without caching it
$this->permissions = new ExternalPermissions();
return;
}
$this->permissions = $this->globalContributionsLookup->getAndUpdateExternalWikiPermissions(
$centralId,
$wikiIds,
$this->getUser(),
$this->getRequest()
);
}
/**
* Query the central index tables to find wikis that have recently been edited
* from by temporary accounts using the target IP.
*
* Also sets some properties:
* - $permissions, which holds information about external wiki permissions
* - $wikisWithPermissionsCount, the number of wikis where the user can reveal IPs
* - $needsToEnableGlobalPreferenceAtWiki, the wiki to enable a global preference at
*
* @return array
*/
private function fetchWikisToQuery() {
$this->needsToEnableGlobalPreferenceAtWiki = '';
try {
$activeWikis = $this->globalContributionsLookup->getActiveWikis(
$this->target,
$this->getAuthority()
);
} catch ( InvalidArgumentException ) {
// No central user found or viewable and target is not an IP, flag it and then return
// an empty array for active wikis which will eventually return an empty results set
$this->centralUserExists = false;
$this->wikisWithPermissionsCount = 0;
return [];
}
$skippedWikis = $this->getConfig()->get( 'CheckUserGlobalContributionsSkippedWikiIds' );
$activeWikis = array_diff( $activeWikis, $skippedWikis );
if ( $this->isValidIPOrQueryableRange( $this->target, $this->getConfig() ) ) {
// Look up external permissions
$this->getExternalWikiPermissions(
array_filter( $activeWikis, static function ( $wikiId ) {
// No need to do an API call to the local wiki
return !WikiMap::isCurrentWikiDbDomain( $wikiId );
} )
);
// Look up local permissions
$isBlocked = $this->getAuthority()->getBlock();
$canRevealIp = !$isBlocked && $this->permissionManager->userHasRight(
$this->getAuthority()->getUser(),
'checkuser-temporary-account'
);
$canRevealIpNoPreference = !$isBlocked && $this->permissionManager->userHasRight(
$this->getAuthority()->getUser(),
'checkuser-temporary-account-no-preference'
);
// Look up the global preference status
$globalPreferences = $this->globalPreferencesFactory->getGlobalPreferencesValues(
$this->getAuthority()->getUser(),
// Load from the database, not the cache, since we're using it for access.
true
);
$hasEnabledGlobalPreference = $globalPreferences &&
isset( $globalPreferences['checkuser-temporary-account-enable'] ) &&
$globalPreferences['checkuser-temporary-account-enable'];
$wikisToQuery = [];
foreach ( $activeWikis as $wikiId ) {
// If an IP is being queried, check against the permissions obtained from the external wikis.
// Otherwise, a temporary account or registered account is being queried, which can be broadly
// accessed without restriction (revision/IP reveal restrictions will still apply).
if ( $this->isValidIPOrQueryableRange( $this->target, $this->getConfig() ) ) {
if (
( WikiMap::isCurrentWikiDbDomain( $wikiId ) && $canRevealIpNoPreference ) ||
$this->permissions->hasPermission( 'checkuser-temporary-account-no-preference', $wikiId )
) {
$wikisToQuery[] = $wikiId;
} elseif (
( WikiMap::isCurrentWikiDbDomain( $wikiId ) && $canRevealIp ) ||
$this->permissions->hasPermission( 'checkuser-temporary-account', $wikiId )
) {
if ( $hasEnabledGlobalPreference ) {
$wikisToQuery[] = $wikiId;
} else {
$this->needsToEnableGlobalPreferenceAtWiki = $wikiId;
}
}
}
}
} else {
$wikisToQuery = $activeWikis;
}
$this->wikisWithPermissionsCount = count( $wikisToQuery );
return $wikisToQuery;
}
public function doQuery() {
parent::doQuery();
// If we reach here query has been made for a valid IP or range or a valid username
// and if there are rows to display they will be displayed.
// Check if the target is an IP or range and only if so, log that the user has globally
// viewed the temporary accounts editing on the target IP/range.
if ( $this->isValidIPOrQueryableRange( $this->target, $this->getConfig() ) ) {
$this->jobQueueGroup->push(
LogTemporaryAccountAccessJob::newSpec(
$this->getAuthority()->getUser(),
$this->target,
TemporaryAccountLogger::ACTION_VIEW_TEMPORARY_ACCOUNTS_ON_IP_GLOBAL,
)
);
}
}
/** @inheritDoc */
public function getTimestampField() {
return 'rev_timestamp';
}
/**
* @inheritDoc
*/
public function reallyDoQuery( $offset, $limit, $order ) {
$wikisToQuery = $this->fetchWikisToQuery();
$results = [];
if ( $offset ) {
$offsets = explode( '|', $offset );
$indexColumns = array_slice( (array)$this->mIndexField, 0, count( $offsets ) );
$offsetConds = array_combine( $indexColumns, $offsets );
} else {
$offsetConds = [];
}
// Apply start and end timestamp limits. Code taken from
// RangeChronologicalPager::buildQueryInfo and ReverseChronologicalPager::buildQueryInfo
$timestampConds = [];
if ( $this->endOffset ) {
$timestampConds[] = $this->mDb->expr( $this->getTimestampField(), '<', $this->endOffset );
}
// Don't display revisions older than the CUDMaxAge to be consistent with what is displayed when
// searching for temporary account contributions on an IP address (T388667).
$checkUserDataCutoff = ConvertibleTimestamp::time() - $this->getConfig()->get( 'CUDMaxAge' );
if ( $this->startOffset ) {
$startOffset = max(
ConvertibleTimestamp::convert( TS_MW, $this->startOffset ),
ConvertibleTimestamp::convert( TS_MW, $checkUserDataCutoff )
);
} else {
$startOffset = $checkUserDataCutoff;
}
$timestampConds[] = $this->mDb->expr(
$this->getTimestampField(), '>=', $this->mDb->timestamp( $startOffset )
);
// Compute a synthetic sequence number for each wiki 0 ... -N,
// where 0 is the most recently edited wiki and -N is the least recently edited wiki.
// This is useful for tie-breaking where two revisions between different wikis
// share the same timestamp.
$wikiSeqNo = 0;
foreach ( $wikisToQuery as $wikiId ) {
$dbr = $this->dbProvider->getReplicaDatabase( $wikiId );
$wikiConds = [];
if ( !WikiMap::isCurrentWikiDbDomain( $wikiId ) ) {
// Filter out results with hidden authors from external wikis, if the user shouldn't see
// them. These filters are added for the local query in ContributionsPager::getQueryInfo.
// Since external permissions are not looked up if the target is a registered user,
// these will not be shown to anyone for a registered user target, until T389187.
if ( !$this->permissions->hasPermission( 'deletedhistory', $wikiId ) ) {
$wikiConds[] = $dbr->bitAnd(
$this->revisionDeletedField, RevisionRecord::DELETED_USER
) . ' = 0';
}
if (
!$this->permissions->hasPermission( 'suppressrevision', $wikiId ) &&
!$this->permissions->hasPermission( 'viewsuppressed', $wikiId )
) {
$wikiConds[] = $dbr->bitAnd(
$this->revisionDeletedField, RevisionRecord::SUPPRESSED_USER
) . ' != ' . RevisionRecord::SUPPRESSED_USER;
}
}
$resultSet = $dbr->newSelectQueryBuilder()
->caller( __METHOD__ )
->queryInfo( $this->getQueryInfo() )
->andWhere( $wikiConds )
->andWhere( $timestampConds )
->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC )
// Use a limit for each wiki (specified in T356292), rather than the page limit.
->limit( self::REVISION_COUNT_LIMIT )
->fetchResultSet();
foreach ( $resultSet as $row ) {
$row->sourcewiki = $wikiId;
$row->wiki_seq_no = $wikiSeqNo;
if ( !$offsetConds ) {
$results[] = $row;
continue;
}
// Check whether this revision fits into the current pagination window.
foreach ( $offsetConds as $field => $pivotValue ) {
if ( $this->mIncludeOffset && $row->$field === $pivotValue ) {
$results[] = $row;
break;
}
if ( $order === self::QUERY_ASCENDING ) {
if ( $row->$field < $pivotValue ) {
break;
}
if ( $row->$field > $pivotValue ) {
$results[] = $row;
break;
}
}
if ( $order === self::QUERY_DESCENDING ) {
if ( $row->$field > $pivotValue ) {
break;
}
if ( $row->$field < $pivotValue ) {
$results[] = $row;
break;
}
}
}
}
$wikiSeqNo--;
}
// Sort the entire results set by timestamp, wiki sequence number
// and finally revision ID as a tie-breaker, then apply the limit.
usort( $results, static function ( $a, $b ) use ( $order ) {
$aTimestamp = $a->rev_timestamp;
$bTimestamp = $b->rev_timestamp;
if ( $aTimestamp !== $bTimestamp ) {
if ( $order === self::QUERY_DESCENDING ) {
return $bTimestamp <=> $aTimestamp;
}
return $aTimestamp <=> $bTimestamp;
}
if ( $a->wiki_seq_no !== $b->wiki_seq_no ) {
if ( $order === self::QUERY_DESCENDING ) {
return $b->wiki_seq_no <=> $a->wiki_seq_no;
}
return $a->wiki_seq_no <=> $b->wiki_seq_no;
}
if ( $order === self::QUERY_DESCENDING ) {
return $b->rev_id <=> $a->rev_id;
}
return $a->rev_id <=> $b->rev_id;
} );
return new FakeResultWrapper( array_slice( $results, 0, $limit ) );
}
/**
* @inheritDoc
*/
protected function doBatchLookups() {
parent::doBatchLookups();
// Fetch parent revision sizes for each revision and wiki (T385377).
$parentIdsByWiki = [];
foreach ( $this->mResult as $row ) {
if ( $row->rev_parent_id &&
!isset( $this->parentRevisionSizes[$row->sourcewiki][$row->rev_parent_id] )
) {
$parentIdsByWiki[$row->sourcewiki][$row->rev_parent_id] = true;
}
$this->parentRevisionSizes[$row->sourcewiki][$row->rev_id] = (int)$row->rev_len;
unset( $parentIdsByWiki[$row->sourcewiki][$row->rev_id] );
}
foreach ( $parentIdsByWiki as $wikiId => $parentIds ) {
$this->parentRevisionSizes[$wikiId] ??= [];
$this->parentRevisionSizes[$wikiId] += $this->globalContributionsLookup->getRevisionSizes(
$wikiId, array_keys( $parentIds )
);
}
}
/**
* @inheritDoc
*/
protected function getRevisionQuery() {
$revQueryBuilder = $this->revisionStore->newSelectQueryBuilder( $this->getDatabase() )
->joinPage()
->joinComment()
->fields( [
'page_is_new',
// Set our synthetic wiki sequence number field to an initial value.
// This will be overridden in reallyDoQuery().
'wiki_seq_no' => '1',
] );
if ( $this->isValidIPOrQueryableRange( $this->target, $this->getConfig() ) ) {
$ipConditions = $this->checkUserLookupUtils->getIPTargetExpr(
$this->target,
false,
self::CHANGES_TABLE
);
if ( $ipConditions === null ) {
// Invalid IPs are treated as usernames so we should only ever reach
// this condition if the IP range is out of limits
throw new LogicException(
"Attempted IP range lookup with a range outside of the limit: $this->target\n
Check if your RangeContributionsCIDRLimit and CheckUserCIDRLimit configs are compatible."
);
}
$tempUserConditions = $this->tempUserConfig->getMatchCondition(
$this->getDatabase(),
'actor_name',
IExpression::LIKE
);
$revQueryBuilder->join( self::CHANGES_TABLE, null, 'cuc_this_oldid=rev_id' )
->where( $ipConditions )
->andWhere( $tempUserConditions );
} else {
$revQueryBuilder->where( [ 'actor_name' => $this->target ] );
}
return $revQueryBuilder->getQueryInfo();
}
/**
* @inheritDoc
*/
public function getIndexField() {
return [
[ 'rev_timestamp', 'wiki_seq_no', 'rev_id' ],
];
}
/**
* Wrapper method for testing. See documentation for WikiMap::getForeignURL.
*
* @param string $wikiID
* @param string $page
* @param string|null $fragmentId
*
* @return string|false
*/
protected function getForeignURL( $wikiID, $page, $fragmentId = null ) {
return WikiMap::getForeignURL( $wikiID, $page, $fragmentId );
}
/**
* @inheritDoc
*/
protected function populateAttributes( $row, &$attributes ) {
if ( !$this->isFromExternalWiki( $row ) ) {
parent::populateAttributes( $row, $attributes );
}
}
/**
* @inheritDoc
*/
protected function getParentRevisionSize( $row ): int {
return $this->parentRevisionSizes[$row->sourcewiki][$row->rev_parent_id] ?? 0;
}
/**
* @inheritDoc
*/
protected function getStartBody() {
$startBody = "<section class=\"mw-pager-body plainlinks\">\n";
if ( $this->needsToEnableGlobalPreferenceAtWiki && $this->getNumRows() > 0 ) {
$startBody .= new MessageWidget( [
'type' => 'info',
'label' => new HtmlSnippet(
$this->msg(
'checkuser-global-contributions-no-global-preference',
$this->getForeignURL(
$this->needsToEnableGlobalPreferenceAtWiki,
'Special:GlobalPreferences'
)
)->parse()
),
] );
}
if ( $this->permissions->hasEncounteredLookupError() ) {
$startBody .= new MessageWidget( [
'type' => 'error',
'label' => new HtmlSnippet(
$this->msg( 'checkuser-global-contributions-api-lookup-error' )->parse()
),
] );
}
return $startBody;
}
/**
* @inheritDoc
*/
protected function getEndBody() {
return "</section>\n";
}
/**
* @inheritDoc
*/
protected function getEmptyBody() {
if ( $this->needsToEnableGlobalPreferenceAtWiki ) {
return new MessageWidget( [
'type' => 'info',
'label' => new HtmlSnippet(
$this->msg(
'checkuser-global-contributions-no-results-no-global-preference',
$this->getForeignURL(
$this->needsToEnableGlobalPreferenceAtWiki,
'Special:GlobalPreferences'
)
)->parse()
),
] );
}
// Username or temporary account queried for and no central id for the user was found
if ( !$this->centralUserExists ) {
return new MessageWidget( [
'type' => 'warning',
'label' => new HtmlSnippet(
$this->msg(
'checkuser-global-contributions-no-results-no-central-user',
$this->target
)->parse()
),
] );
}
// An IP target was searched for and the user doesn't have view permissions
if (
$this->wikisWithPermissionsCount === 0 &&
$this->isValidIPOrQueryableRange( $this->target, $this->getConfig() )
) {
return new MessageWidget( [
'type' => 'info',
'label' => new HtmlSnippet(
$this->msg( 'checkuser-global-contributions-no-results-no-permissions' )
->params( $this->target )
->parse()
),
] );
}
// No results, but filters have been applied which may cause some results to be hidden for the target
// so show a message that suggests removing these filters.
if ( $this->hasAppliedFilters() ) {
return new MessageWidget( [
'type' => 'info',
'label' => new HtmlSnippet( $this->msg(
'checkuser-global-contributions-no-results-when-filters-applied'
)->parse() ),
] );
}
// No visible contributions for an existing user
return new MessageWidget( [
'type' => 'info',
'label' => new HtmlSnippet(
$this->msg(
'checkuser-global-contributions-no-results-no-visible-contribs',
$this->target
)->parse()
),
] );
}
/**
* @inheritDoc
*/
protected function formatArticleLink( $row ) {
if ( !$this->isFromExternalWiki( $row ) ) {
return parent::formatArticleLink( $row );
}
if ( !$this->currentPage ) {
return '';
}
// If the namespace ID is not one of the common namespaces that are the same
// across all wikis then use that. In the other case then we should make the
// namespace be "Namespace<id>" where "id" is the number of the namespace
// to prevent these pages being seen as in the mainspace (which has no namespace prefix).
$namespaceIsKnown = in_array(
$row->{$this->pageNamespaceField},
$this->namespaceInfo->getCommonNamespaces()
);
if ( $namespaceIsKnown ) {
$linkText = $this->currentPage->getPrefixedText();
} else {
$linkText = $this->msg( 'checkuser-global-contributions-page-when-no-namespace-translation-available' )
->numParams( $row->page_namespace )
->params( $this->currentPage->getText() )
->text();
}
$dir = $this->getLanguage()->getDir();
$link = $this->getLinkRenderer()->makeExternalLink(
$this->getForeignURL(
$row->sourcewiki,
'Special:Redirect/page/' . $row->rev_page
),
$linkText,
$this->currentPage,
'',
[ 'class' => 'mw-contributions-title' ],
);
return Html::rawElement( 'bdi', [ 'dir' => $dir ], $link );
}
/**
* @inheritDoc
*/
protected function formatDiffHistLinks( $row ) {
if ( !$this->isFromExternalWiki( $row ) ) {
return parent::formatDiffHistLinks( $row );
}
if ( !$this->currentPage ) {
return '';
}
if (
$row->{$this->revisionParentIdField} != 0 &&
$this->userCanSeeExternalRevision( $row )
) {
$difftext = $this->getLinkRenderer()->makeExternalLink(
wfAppendQuery(
$this->getForeignURL(
$row->sourcewiki,
$row->{$this->pageTitleField}
),
[
'diff' => 'prev',
'oldid' => $row->{$this->revisionIdField},
]
),
new HtmlArmor( $this->messages['diff'] ),
// The page is only used for its title and namespace,
// so this is safe.
$this->currentPage,
'',
[ 'class' => 'mw-changeslist-diff' ]
);
} else {
$difftext = $this->messages['diff'];
}
$histlink = $this->getLinkRenderer()->makeExternalLink(
wfAppendQuery(
$this->getForeignURL(
$row->sourcewiki,
'',
),
[
'curid' => $row->rev_page,
'action' => 'history',
]
),
new HtmlArmor( $this->messages['hist'] ),
// The page is only used for its title and namespace,
// so this is safe.
$this->currentPage,
'',
[ 'class' => 'mw-changeslist-history' ]
);
return Html::rawElement( 'span',
[ 'class' => 'mw-changeslist-links' ],
// The spans are needed to ensure the dividing '|' elements are not
// themselves styled as links.
Html::rawElement( 'span', [], $difftext ) . ' ' .
// Space needed for separating two words.
Html::rawElement( 'span', [], $histlink )
);
}
/**
* @inheritDoc
*/
protected function formatDateLink( $row ) {
if ( !$this->isFromExternalWiki( $row ) ) {
return parent::formatDateLink( $row );
}
if ( !$this->currentPage ) {
return '';
}
// Re-implemented from ChangesList::revDateLink so we can inject
// a foreign URL here instead of a local one.
$ts = $row->{$this->revisionTimestampField};
$time = $this->getLanguage()->userTime( $ts, $this->getAuthority()->getUser() );
$date = $this->getLanguage()->userTimeAndDate( $ts, $this->getAuthority()->getUser() );
$class = 'mw-changeslist-date';
if ( $this->userCanSeeExternalRevision( $row ) ) {
$dateLink = $this->getLinkRenderer()->makeExternalLink(
wfAppendQuery(
$this->getForeignURL(
$row->sourcewiki,
$row->{$this->pageTitleField}
),
[ 'oldid' => $row->{$this->revisionIdField} ]
),
$date,
$this->currentPage,
'',
[ 'class' => $class ]
);
$dateLink = Html::rawElement( 'bdi', [ 'dir' => $this->getLanguage()->getDir() ], $dateLink );
} else {
$dateLink = htmlspecialchars( $date );
}
if ( $this->externalRevisionIsDeleted( $row ) ) {
// Logic from Linker::getRevisionDeletedClass
$class .= ' history-deleted';
if ( (bool)( $row->{$this->revisionDeletedField} & RevisionRecord::DELETED_RESTRICTED ) ) {
$class .= ' mw-history-suppressed';
}
$dateLink = Html::rawElement(
'span',
[ 'class' => $class ],
$dateLink
);
}
return Html::element( 'span', [
'class' => 'mw-changeslist-time',
], $time ) . $dateLink;
}
/**
* @inheritDoc
*/
protected function formatTopMarkText( $row, &$classes ) {
if ( !$this->isFromExternalWiki( $row ) ) {
return parent::formatTopMarkText( $row, $classes );
}
// PagerTools are omitted here because they require cross-wiki
// permission checks.
$topmarktext = '';
if ( $row->{$this->revisionIdField} === $row->page_latest ) {
$topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
$classes[] = 'mw-contributions-current';
}
return $topmarktext;
}
/**
* @inheritDoc
*/
protected function formatComment( $row ) {
if ( !$this->isFromExternalWiki( $row ) ) {
return parent::formatComment( $row );
}
$wikiId = $row->sourcewiki;
$revisionStore = $this->revisionStoreFactory->getRevisionStore( $wikiId );
$record = $this->isArchive ?
$revisionStore->newRevisionFromArchiveRow( $row ) :
$revisionStore->newRevisionFromRow( $row );
// $this->currentRevRecord is null for external revisions, so permission
// checks should be based on data from $row instead.
//
// userCanSeeExternalHistory() checks if the comment is not deleted or,
// if it is, if the current user can see it in the source wiki.
if ( $this->userCanSeeExternalHistory( $row ) ) {
$user = $this->getAuthority();
// Note $comment just serves to check that the comment is not empty.
//
// Permission checks in getComment() would apply to the local wiki
// instead of the source wiki; therefore, the comment is retrieved
// skipping (local) permission checks by using RAW as the target
// audience.
$comment = $record->getComment( RevisionRecord::RAW, $user );
if ( $comment !== null && $comment->text !== '' ) {
return $this->commentFormatter->formatRevision( $record, $user );
}
$defaultComment = $this->messages['changeslist-nocomment'];
return "<span class=\"comment mw-comment-none\">$defaultComment</span>";
}
$additionalCssClasses = Linker::getRevisionDeletedClass( $record );
return Html::element(
'span',
[ 'class' => "{$additionalCssClasses} comment" ],
$this->msg( 'rev-deleted-comment' )->text()
);
}
/**
* @inheritDoc
*/
protected function formatUserLink( $row ) {
if ( $this->isFromExternalWiki( $row ) ) {
$revUser = new UserIdentityValue( $row->rev_user, $row->{$this->userNameField}, $row->sourcewiki );
} else {
if ( !$this->currentRevRecord ) {
$revUser = null;
} else {
$revUser = $this->currentRevRecord->getUser();
}
}
$dir = $this->getLanguage()->getDir();
if ( $revUser === null || $this->revisionUserIsDeleted( $row ) ) {
// T379894 If the username is hidden, return early a message stating
// that the username is unavailable instead of falling through and
// end up show "no username available" twice (once for $userPageLink
// and once for $userTalkLink).
return ' <span class="mw-changeslist-separator"></span> ' .
Html::rawElement(
'bdi',
[ 'dir' => $dir ],
$this->msg( 'empty-username' )->parse()
);
}
$userPageLink = $this->userLinkRenderer->userLink( $revUser, $this->getContext() );
if ( !$this->isFromExternalWiki( $row ) ) {
return ' <span class="mw-changeslist-separator"></span> ' .
Html::rawElement( 'bdi', [ 'dir' => $dir ], $userPageLink ) .
Linker::userToolLinks( $revUser->getId(), $revUser->getName(), false, Linker::TOOL_LINKS_NOBLOCK );
}
// The revision is on another wiki and the user can see the performer of the revision.
// Generate the user links that point to that other wiki instead of the local wiki.
$userTalkTitle = Title::makeTitle( NS_USER_TALK, $row->{$this->userNameField} );
$userToolLinks = [];
$userToolLinks[] = $this->getLinkRenderer()->makeExternalLink(
$this->getForeignURL(
$row->sourcewiki,
$userTalkTitle->getPrefixedText()
),
$this->msg( 'talkpagelinktext' ),
$userTalkTitle,
'',
[ 'class' => 'mw-usertoollinks-talk' ]
);
$userToolLinks[] = $this->getLinkRenderer()->makeExternalLink(
$this->getForeignURL(
$row->sourcewiki,
Title::makeName( NS_SPECIAL, 'Contributions/' . $row->{$this->userNameField}, '', '', true )
),
$this->msg( 'contribslink' ),
$userTalkTitle,
'',
[ 'class' => 'mw-usertoollinks-contribs' ]
);
// Wrap each toollink in a span to be consistent with Linker::userToolLinks
$userToolLinks = array_map( static function ( $toolLink ) {
return Html::rawElement( 'span', [], $toolLink );
}, $userToolLinks );
return ' <span class="mw-changeslist-separator"></span> ' .
Html::rawElement( 'bdi', [ 'dir' => $dir ], $userPageLink ) .
' <span class="mw-usertoollinks mw-changeslist-links">' .
implode( $userToolLinks ) .
'</span>';
}
/**
* @inheritDoc
*/
protected function formatFlags( $row ) {
if ( !$this->isFromExternalWiki( $row ) ) {
return parent::formatFlags( $row );
}
// This is similar to ContributionsPager::formatFlags, but uses the
// row since the RevisionRecord is not available for external rows.
$flags = [];
if ( $row->{$this->revisionParentIdField} == 0 ) {
$flags[] = ChangesList::flag( 'newpage' );
}
if ( $row->{$this->revisionMinorField} ) {
$flags[] = ChangesList::flag( 'minor' );
}
return $flags;
}
/**
* @inheritDoc
*/
protected function formatVisibilityLink( $row ) {
// Don't show visibility links if the row is for an external wiki, since
// determining their usability involves cross-wiki permission checks.
if ( !$this->isFromExternalWiki( $row ) ) {
return parent::formatVisibilityLink( $row );
}
return '';
}
/**
* @inheritDoc
*/
protected function formatTags( $row, &$classes ) {
// Note that for an external tag that is not translated on this wiki,
// the raw tag name will be displayed. This is because external tags
// are not supported by ChangeTags::formatSummaryRow.
return parent::formatTags( $row, $classes );
}
/**
* Format a link to the source wiki.
*
* @param mixed $row
* @return string
*/
protected function formatSourceWiki( $row ) {
$link = $this->getLinkRenderer()->makeExternalLink(
$this->getForeignURL(
$row->sourcewiki,
''
),
WikiMap::getWikiName( $row->sourcewiki ),
Title::newMainPage(),
'',
[ 'class' => 'mw-changeslist-sourcewiki' ],
);
return $link . ' <span class="mw-changeslist-separator"></span> ';
}
/**
* @inheritDoc
*/
public function getTemplateParams( $row, &$classes ) {
$templateParams = parent::getTemplateParams( $row, $classes );
$sourceWiki = $this->formatSourceWiki( $row );
$templateParams['sourceWiki'] = $sourceWiki;
return $templateParams;
}
/**
* @inheritDoc
*/
public function getProcessedTemplate( $templateParams ) {
return $this->templateParser->processTemplate( 'SpecialGlobalContributionsLine', $templateParams );
}
/**
* Check whether the revision author is deleted. This re-implements
* RevisionRecord::isDeleted, since the RevisionRecord is not
* available for external rows.
*
* @param mixed $row
* @return bool
*/
public function revisionUserIsDeleted( $row ) {
return ( $row->{$this->revisionDeletedField} & RevisionRecord::DELETED_USER ) ==
RevisionRecord::DELETED_USER;
}
/**
* @inheritDoc
*/
public function tryCreatingRevisionRecord( $row, $title = null ) {
if ( $this->isFromExternalWiki( $row ) ) {
// RevisionRecord doesn't fully support external revision rows.
return null;
}
return parent::tryCreatingRevisionRecord( $row, $title );
}
/**
* Bool representing whether or not the revision comes from an external wiki
*
* @param mixed $row
* @return bool
*/
protected function isFromExternalWiki( $row ) {
return isset( $row->sourcewiki ) &&
!WikiMap::isCurrentWikiDbDomain( $row->sourcewiki );
}
/**
* @param mixed $row
* @return bool
*/
private function externalRevisionIsDeleted( $row ) {
return (bool)( $row->{$this->revisionDeletedField} & RevisionRecord::DELETED_TEXT );
}
/**
* @param mixed $row
* @return bool
*/
private function externalCommentIsDeleted( $row ) {
return (bool)( $row->{$this->revisionDeletedField} & RevisionRecord::DELETED_COMMENT );
}
/**
* @param mixed $row
* @return bool
*/
private function userCanSeeExternalRevision( $row ) {
if ( !$this->externalRevisionIsDeleted( $row ) ) {
return true;
}
return $this->permissions->hasPermission( 'deletedtext', $row->sourcewiki );
}
/**
* @param mixed $row
* @return bool
*/
private function userCanSeeExternalHistory( $row ) {
if ( !$this->externalCommentIsDeleted( $row ) ) {
return true;
}
return $this->permissions->hasPermission( 'deletedhistory', $row->sourcewiki );
}
/**
* Whether any errors occurred while looking up external wiki permissions during the last query.
* @return bool
*/
public function hasExternalApiLookupError(): bool {
return $this->permissions->hasEncounteredLookupError();
}
}

File Metadata

Mime Type
text/x-php
Expires
Sun, Oct 4, 01:32 (3 w, 5 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
99/a9/422764a63b155c26478fb556738e
Default Alt Text
GlobalContributionsPager.php (32 KB)

Event Timeline