mediawiki-extensions-AbuseF.../includes/Special/SpecialAbuseLog.php
Daimona Eaytoy e8471a717c Add method to properly check visibility of AbuseLog entries
This replaces the previous pattern of callers having to use
RevisionLookup if the result was 'implicit'. Also, in some cases where
we were just hiding things if the visibility was !== true, properly
handle the implicit case by using the new method. Make the new method
return string constants rather than bool|string.

The new method also fixes some potential info leaks which happened when
the row was hidden, the user could view suppressed AbuseLog entries, but
the associated revision was also deleted and the user couldn't see it
(this shouldn't be relevant for WMF wikis since AF deletion is
oversight-level).

Also add a bunch of tests for the various cases to ensure we don't
regress again.

Bug: T261532
Change-Id: I929f865acf5d207b739cb3af043f70cb59243ee0
2021-09-25 00:08:33 +00:00

1170 lines
33 KiB
PHP

<?php
namespace MediaWiki\Extension\AbuseFilter\Special;
use DifferenceEngine;
use ExtensionRegistry;
use Html;
use HTMLForm;
use InvalidArgumentException;
use Linker;
use ManualLogEntry;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
use MediaWiki\Extension\AbuseFilter\Pager\AbuseLogPager;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Extension\AbuseFilter\Variables\UnsetVariableException;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Extension\AbuseFilter\View\HideAbuseLog;
use MediaWiki\MediaWikiServices;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Revision\RevisionRecord;
use OOUI\ButtonInputWidget;
use SpecialPage;
use Status;
use stdClass;
use Title;
use User;
use WikiMap;
use Xml;
class SpecialAbuseLog extends AbuseFilterSpecialPage {
public const PAGE_NAME = 'AbuseLog';
/** Visible entry */
public const VISIBILITY_VISIBLE = 'visible';
/** Explicitly hidden entry */
public const VISIBILITY_HIDDEN = 'hidden';
/** Visible entry but the associated revision is hidden */
public const VISIBILITY_HIDDEN_IMPLICIT = 'implicit';
/**
* @var User The user whose AbuseLog entries are being searched
*/
protected $mSearchUser;
/**
* @var string The start time of the search period
*/
protected $mSearchPeriodStart;
/**
* @var string The end time of the search period
*/
protected $mSearchPeriodEnd;
/**
* @var Title The page of which AbuseLog entries are being searched
*/
protected $mSearchTitle;
/**
* @var string The action performed by the user
*/
protected $mSearchAction;
/**
* @var string The action taken by AbuseFilter
*/
protected $mSearchActionTaken;
/**
* @var string The wiki name where we're performing the search
*/
protected $mSearchWiki;
/**
* @var string|null The filter IDs we're looking for. Either a single one, or a pipe-separated list
*/
protected $mSearchFilter;
/**
* @var string The visibility of entries we're interested in
*/
protected $mSearchEntries;
/**
* @var string The impact of the user action, i.e. if the change has been saved
*/
protected $mSearchImpact;
/** @var string|null The filter group to search, as defined in $wgAbuseFilterValidGroups */
protected $mSearchGroup;
/** @var LinkBatchFactory */
private $linkBatchFactory;
/** @var PermissionManager */
private $permissionManager;
/** @var ConsequencesRegistry */
private $consequencesRegistry;
/** @var VariablesBlobStore */
private $varBlobStore;
/** @var SpecsFormatter */
private $specsFormatter;
/** @var VariablesFormatter */
private $variablesFormatter;
/** @var VariablesManager */
private $varManager;
/**
* @param LinkBatchFactory $linkBatchFactory
* @param PermissionManager $permissionManager
* @param AbuseFilterPermissionManager $afPermissionManager
* @param ConsequencesRegistry $consequencesRegistry
* @param VariablesBlobStore $varBlobStore
* @param SpecsFormatter $specsFormatter
* @param VariablesFormatter $variablesFormatter
* @param VariablesManager $varManager
*/
public function __construct(
LinkBatchFactory $linkBatchFactory,
PermissionManager $permissionManager,
AbuseFilterPermissionManager $afPermissionManager,
ConsequencesRegistry $consequencesRegistry,
VariablesBlobStore $varBlobStore,
SpecsFormatter $specsFormatter,
VariablesFormatter $variablesFormatter,
VariablesManager $varManager
) {
parent::__construct( self::PAGE_NAME, 'abusefilter-log', $afPermissionManager );
$this->linkBatchFactory = $linkBatchFactory;
$this->permissionManager = $permissionManager;
$this->consequencesRegistry = $consequencesRegistry;
$this->varBlobStore = $varBlobStore;
$this->specsFormatter = $specsFormatter;
$this->specsFormatter->setMessageLocalizer( $this->getContext() );
$this->variablesFormatter = $variablesFormatter;
$this->variablesFormatter->setMessageLocalizer( $this->getContext() );
$this->varManager = $varManager;
}
/**
* @codeCoverageIgnore Merely declarative
* @inheritDoc
*/
public function doesWrites() {
return true;
}
/**
* @codeCoverageIgnore Merely declarative
* @inheritDoc
*/
protected function getGroupName() {
return 'changes';
}
/**
* Main routine
*
* $parameter string is converted into the $args array, which can come in
* three shapes:
*
* An array of size 2: only if the URL is like Special:AbuseLog/private/id
* where id is the log identifier. In this case, the private details of the
* log (e.g. IP address) will be shown.
*
* An array of size 1: either the URL is like Special:AbuseLog/id where
* the id is log identifier, in which case the details of the log except for
* private bits (e.g. IP address) are shown, or Special:AbuseLog/hide for hiding entries,
* or the URL is incomplete as in Special:AbuseLog/private (without speciying id),
* in which case a warning is shown to the user
*
* An array of size 0 when URL is like Special:AbuseLog or an array of size
* 1 when the URL is like Special:AbuseFilter/ (i.e. without anything after
* the slash). Otherwise, the abuse logs are shown as a list, with a search form above the list.
*
* @param string|null $parameter URL parameters
*/
public function execute( $parameter ) {
$out = $this->getOutput();
$this->addNavigationLinks( 'log' );
$this->setHeaders();
$this->addHelpLink( 'Extension:AbuseFilter' );
$this->loadParameters();
$out->enableClientCache( false );
$out->addModuleStyles( 'ext.abuseFilter' );
$this->checkPermissions();
$args = explode( '/', $parameter );
if ( count( $args ) === 2 && $args[0] === 'private' ) {
$this->showPrivateDetails( (int)$args[1] );
} elseif ( count( $args ) === 1 && $args[0] !== '' ) {
if ( $args[0] === 'private' ) {
$out->addWikiMsg( 'abusefilter-invalid-request-noid' );
} elseif ( $args[0] === 'hide' ) {
$this->showHideView();
} else {
$this->showDetails( $args[0] );
}
} else {
$this->outputHeader( 'abusefilter-log-summary' );
$this->searchForm();
$this->showList();
}
}
/**
* Loads parameters from request
*/
public function loadParameters() {
$request = $this->getRequest();
$searchUsername = trim( $request->getText( 'wpSearchUser' ) );
$userTitle = Title::newFromText( $searchUsername, NS_USER );
$this->mSearchUser = $userTitle ? $userTitle->getText() : null;
if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
$this->mSearchWiki = $request->getText( 'wpSearchWiki' );
}
$this->mSearchPeriodStart = $request->getText( 'wpSearchPeriodStart' );
$this->mSearchPeriodEnd = $request->getText( 'wpSearchPeriodEnd' );
$this->mSearchTitle = $request->getText( 'wpSearchTitle' );
$this->mSearchFilter = null;
$this->mSearchGroup = null;
if ( $this->afPermissionManager->canSeeLogDetails( $this->getUser() ) ) {
$this->mSearchFilter = $request->getText( 'wpSearchFilter' );
if ( count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 ) {
$this->mSearchGroup = $request->getText( 'wpSearchGroup' );
}
}
$this->mSearchAction = $request->getText( 'wpSearchAction' );
$this->mSearchActionTaken = $request->getText( 'wpSearchActionTaken' );
$this->mSearchEntries = $request->getText( 'wpSearchEntries' );
$this->mSearchImpact = $request->getText( 'wpSearchImpact' );
}
/**
* @return string[]
*/
private function getAllFilterableActions() {
return [
'edit',
'move',
'upload',
'stashupload',
'delete',
'createaccount',
'autocreateaccount',
];
}
/**
* Builds the search form
*/
public function searchForm() {
$user = $this->getUser();
$formDescriptor = [
'SearchUser' => [
'label-message' => 'abusefilter-log-search-user',
'type' => 'user',
'ipallowed' => true,
'default' => $this->mSearchUser,
],
'SearchPeriodStart' => [
'label-message' => 'abusefilter-test-period-start',
'type' => 'datetime',
'default' => $this->mSearchPeriodStart
],
'SearchPeriodEnd' => [
'label-message' => 'abusefilter-test-period-end',
'type' => 'datetime',
'default' => $this->mSearchPeriodEnd
],
'SearchTitle' => [
'label-message' => 'abusefilter-log-search-title',
'type' => 'title',
'interwiki' => false,
'default' => $this->mSearchTitle,
'required' => false
],
'SearchImpact' => [
'label-message' => 'abusefilter-log-search-impact',
'type' => 'select',
'options' => [
$this->msg( 'abusefilter-log-search-impact-all' )->text() => 0,
$this->msg( 'abusefilter-log-search-impact-saved' )->text() => 1,
$this->msg( 'abusefilter-log-search-impact-not-saved' )->text() => 2,
],
],
];
$filterableActions = $this->getAllFilterableActions();
$actions = array_combine( $filterableActions, $filterableActions );
ksort( $actions );
$actions = array_merge(
[ $this->msg( 'abusefilter-log-search-action-any' )->text() => 'any' ],
$actions,
[ $this->msg( 'abusefilter-log-search-action-other' )->text() => 'other' ]
);
$formDescriptor['SearchAction'] = [
'label-message' => 'abusefilter-log-search-action-label',
'type' => 'select',
'options' => $actions,
'default' => 'any',
];
$options = [];
foreach ( $this->consequencesRegistry->getAllActionNames() as $action ) {
$key = $this->specsFormatter->getActionDisplay( $action );
$options[$key] = $action;
}
ksort( $options );
$options = array_merge(
[ $this->msg( 'abusefilter-log-search-action-taken-any' )->text() => '' ],
$options,
[ $this->msg( 'abusefilter-log-noactions-filter' )->text() => 'noactions' ]
);
$formDescriptor['SearchActionTaken'] = [
'label-message' => 'abusefilter-log-search-action-taken-label',
'type' => 'select',
'options' => $options,
];
if ( $this->afPermissionManager->canSeeHiddenLogEntries( $user ) ) {
$formDescriptor['SearchEntries'] = [
'type' => 'select',
'label-message' => 'abusefilter-log-search-entries-label',
'options' => [
$this->msg( 'abusefilter-log-search-entries-all' )->text() => 0,
$this->msg( 'abusefilter-log-search-entries-hidden' )->text() => 1,
$this->msg( 'abusefilter-log-search-entries-visible' )->text() => 2,
],
];
}
if ( $this->afPermissionManager->canSeeLogDetails( $user ) ) {
$groups = $this->getConfig()->get( 'AbuseFilterValidGroups' );
if ( count( $groups ) > 1 ) {
$options = array_merge(
[ $this->msg( 'abusefilter-log-search-group-any' )->text() => 0 ],
array_combine( $groups, $groups )
);
$formDescriptor['SearchGroup'] = [
'label-message' => 'abusefilter-log-search-group',
'type' => 'select',
'options' => $options
];
}
$helpmsg = $this->getConfig()->get( 'AbuseFilterIsCentral' )
? $this->msg( 'abusefilter-log-search-filter-help-central' )->escaped()
: $this->msg( 'abusefilter-log-search-filter-help' )
->params( GlobalNameUtils::GLOBAL_FILTER_PREFIX )->escaped();
$formDescriptor['SearchFilter'] = [
'label-message' => 'abusefilter-log-search-filter',
'type' => 'text',
'default' => $this->mSearchFilter,
'help' => $helpmsg
];
}
if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
// @todo Add free form input for wiki name. Would be nice to generate
// a select with unique names in the db at some point.
$formDescriptor['SearchWiki'] = [
'label-message' => 'abusefilter-log-search-wiki',
'type' => 'text',
'default' => $this->mSearchWiki,
];
}
HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
->setWrapperLegendMsg( 'abusefilter-log-search' )
->setSubmitTextMsg( 'abusefilter-log-search-submit' )
->setMethod( 'get' )
->setCollapsibleOptions( true )
->prepareForm()
->displayForm( false );
}
private function showHideView() {
$view = new HideAbuseLog(
$this->afPermissionManager,
$this->getContext(),
$this->getLinkRenderer(),
self::PAGE_NAME
);
$view->show();
}
/**
* Shows the results list
*/
public function showList() {
$aflFilterMigrationStage = $this->getConfig()->get( 'AbuseFilterAflFilterMigrationStage' );
$out = $this->getOutput();
$user = $this->getUser();
// Generate conditions list.
$conds = [];
if ( $this->mSearchUser ) {
$searchedUser = User::newFromName( $this->mSearchUser );
if ( !$searchedUser ) {
$conds['afl_user'] = 0;
$conds['afl_user_text'] = $this->mSearchUser;
} else {
$conds['afl_user'] = $searchedUser->getId();
$conds['afl_user_text'] = $searchedUser->getName();
}
}
$dbr = wfGetDB( DB_REPLICA );
if ( $this->mSearchPeriodStart ) {
$conds[] = 'afl_timestamp >= ' .
$dbr->addQuotes( $dbr->timestamp( strtotime( $this->mSearchPeriodStart ) ) );
}
if ( $this->mSearchPeriodEnd ) {
$conds[] = 'afl_timestamp <= ' .
$dbr->addQuotes( $dbr->timestamp( strtotime( $this->mSearchPeriodEnd ) ) );
}
if ( $this->mSearchWiki ) {
if ( $this->mSearchWiki === WikiMap::getCurrentWikiDbDomain()->getId() ) {
$conds['afl_wiki'] = null;
} else {
$conds['afl_wiki'] = $this->mSearchWiki;
}
}
$groupFilters = [];
if ( $this->mSearchGroup ) {
$groupFilters = $dbr->selectFieldValues(
'abuse_filter',
'af_id',
[ 'af_group' => $this->mSearchGroup ],
__METHOD__
);
}
$searchFilters = [];
if ( $this->mSearchFilter ) {
$rawFilters = array_map( 'trim', explode( '|', $this->mSearchFilter ) );
// Map of [ [ id, global ], ... ]
$filtersList = [];
$foundInvalid = false;
foreach ( $rawFilters as $filter ) {
try {
$filtersList[] = GlobalNameUtils::splitGlobalName( $filter );
} catch ( InvalidArgumentException $e ) {
$foundInvalid = true;
continue;
}
}
// if a filter is hidden, users who can't view private filters should
// not be able to find log entries generated by it.
if ( !$this->afPermissionManager->canViewPrivateFiltersLogs( $user ) ) {
$searchedForPrivate = false;
foreach ( $filtersList as $index => $filterData ) {
try {
$filter = AbuseFilterServices::getFilterLookup()->getFilter( ...$filterData );
} catch ( FilterNotFoundException $_ ) {
unset( $filtersList[$index] );
$foundInvalid = true;
continue;
}
if ( $filter->isHidden() ) {
unset( $filtersList[$index] );
$searchedForPrivate = true;
}
}
if ( $searchedForPrivate ) {
$out->addWikiMsg( 'abusefilter-log-private-not-included' );
}
}
// @phan-suppress-next-line PhanImpossibleCondition
if ( $foundInvalid ) {
// @todo Tell what the invalid IDs are
$out->addHTML(
Html::rawElement(
'p',
[],
Html::warningBox( $this->msg( 'abusefilter-log-invalid-filter' )->escaped() )
)
);
}
foreach ( $filtersList as $filterData ) {
$searchFilters[] = GlobalNameUtils::buildGlobalName( ...$filterData );
}
}
$searchIDs = null;
if ( $this->mSearchGroup && !$this->mSearchFilter ) {
$searchIDs = $groupFilters;
} elseif ( !$this->mSearchGroup && $this->mSearchFilter ) {
$searchIDs = $searchFilters;
} elseif ( $this->mSearchGroup && $this->mSearchFilter ) {
$searchIDs = array_intersect( $groupFilters, $searchFilters );
}
if ( $searchIDs !== null ) {
if ( !count( $searchIDs ) ) {
$out->addWikiMsg( 'abusefilter-log-noresults' );
return;
}
if ( $aflFilterMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
$filterConds = [ 'local' => [], 'global' => [] ];
foreach ( $searchIDs as $filter ) {
list( $filterID, $isGlobal ) = GlobalNameUtils::splitGlobalName( $filter );
$key = $isGlobal ? 'global' : 'local';
$filterConds[$key][] = $filterID;
}
$filterWhere = [];
if ( $filterConds['local'] ) {
$filterWhere[] = $dbr->makeList(
[ 'afl_global' => 0, 'afl_filter_id' => $filterConds['local'] ],
LIST_AND
);
}
if ( $filterConds['global'] ) {
$filterWhere[] = $dbr->makeList(
[ 'afl_global' => 1, 'afl_filter_id' => $filterConds['global'] ],
LIST_AND
);
}
$conds[] = $dbr->makeList( $filterWhere, LIST_OR );
} else {
// SCHEMA_COMPAT_READ_OLD
$conds[ 'afl_filter' ] = $searchIDs;
}
}
$searchTitle = Title::newFromText( $this->mSearchTitle );
if ( $this->mSearchTitle && $searchTitle ) {
$conds['afl_namespace'] = $searchTitle->getNamespace();
$conds['afl_title'] = $searchTitle->getDBkey();
}
if ( $this->afPermissionManager->canSeeHiddenLogEntries( $user ) ) {
if ( $this->mSearchEntries === '1' ) {
$conds['afl_deleted'] = 1;
} elseif ( $this->mSearchEntries === '2' ) {
$conds['afl_deleted'] = 0;
}
}
if ( in_array( $this->mSearchImpact, [ '1', '2' ] ) ) {
$unsuccessfulActionConds = 'afl_rev_id IS NULL';
if ( $this->mSearchImpact === '1' ) {
$conds[] = "NOT ( $unsuccessfulActionConds )";
} else {
$conds[] = $unsuccessfulActionConds;
}
}
if ( $this->mSearchActionTaken ) {
if ( in_array( $this->mSearchActionTaken, $this->consequencesRegistry->getAllActionNames() ) ) {
$list = [ 'afl_actions' => $this->mSearchActionTaken ];
$list[] = 'afl_actions' . $dbr->buildLike(
$this->mSearchActionTaken, ',', $dbr->anyString() );
$list[] = 'afl_actions' . $dbr->buildLike(
$dbr->anyString(), ',', $this->mSearchActionTaken );
$list[] = 'afl_actions' . $dbr->buildLike(
$dbr->anyString(),
',', $this->mSearchActionTaken, ',',
$dbr->anyString()
);
$conds[] = $dbr->makeList( $list, LIST_OR );
} elseif ( $this->mSearchActionTaken === 'noactions' ) {
$conds['afl_actions'] = '';
}
}
if ( $this->mSearchAction ) {
$filterableActions = $this->getAllFilterableActions();
if ( in_array( $this->mSearchAction, $filterableActions ) ) {
$conds['afl_action'] = $this->mSearchAction;
} elseif ( $this->mSearchAction === 'other' ) {
$list = $dbr->makeList( [ 'afl_action' => $filterableActions ], LIST_OR );
$conds[] = "NOT ( $list )";
}
}
$pager = new AbuseLogPager(
$this->getContext(),
$this->getLinkRenderer(),
$conds,
$this->linkBatchFactory,
$this->permissionManager,
$this->afPermissionManager,
$this->getName()
);
$pager->doQuery();
$result = $pager->getResult();
$form = Xml::tags(
'form',
[
'method' => 'GET',
'action' => $this->getPageTitle( 'hide' )->getLocalURL()
],
$this->getDeleteButton() .
Xml::tags( 'ul', [ 'class' => 'plainlinks' ], $pager->getBody() ) .
$this->getDeleteButton()
);
if ( $result && $result->numRows() !== 0 ) {
$out->addHTML( $pager->getNavigationBar() . $form . $pager->getNavigationBar() );
} else {
$out->addWikiMsg( 'abusefilter-log-noresults' );
}
}
/**
* Returns the HTML for a button to hide selected entries
*
* @return string|ButtonInputWidget
*/
private function getDeleteButton() {
if ( !$this->afPermissionManager->canHideAbuseLog( $this->getUser() ) ) {
return '';
}
return new ButtonInputWidget( [
'label' => $this->msg( 'abusefilter-log-hide-entries' )->text(),
'type' => 'submit'
] );
}
/**
* @param string|int $id
* @suppress SecurityCheck-SQLInjection
*/
public function showDetails( $id ) {
$out = $this->getOutput();
$user = $this->getUser();
$aflFilterMigrationStage = $this->getConfig()->get( 'AbuseFilterAflFilterMigrationStage' );
$pager = new AbuseLogPager(
$this->getContext(),
$this->getLinkRenderer(),
[],
$this->linkBatchFactory,
$this->permissionManager,
$this->afPermissionManager,
$this->getName()
);
[
'tables' => $tables,
'fields' => $fields,
'join_conds' => $join_conds,
] = $pager->getQueryInfo();
$dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
$tables,
$fields,
[ 'afl_id' => $id ],
__METHOD__,
[],
$join_conds
);
$error = null;
if ( !$row ) {
$error = 'abusefilter-log-nonexistent';
} else {
if ( $aflFilterMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
$filterID = $row->afl_filter_id;
$global = $row->afl_global;
} else {
// SCHEMA_COMPAT_READ_OLD
list( $filterID, $global ) = GlobalNameUtils::splitGlobalName( $row->afl_filter );
}
if ( $global ) {
try {
$filter_hidden = AbuseFilterServices::getFilterLookup()->getFilter( $filterID, $global )
->isHidden();
} catch ( CentralDBNotAvailableException $_ ) {
// Conservatively assume that it's hidden, like in formatRow
$filter_hidden = true;
}
} else {
$filter_hidden = $row->af_hidden;
}
if ( !$this->afPermissionManager->canSeeLogDetailsForFilter( $user, $filter_hidden ) ) {
$error = 'abusefilter-log-cannot-see-details';
} else {
$visibility = self::getEntryVisibilityForUser( $row, $user, $this->afPermissionManager );
if ( $visibility === self::VISIBILITY_HIDDEN ) {
$error = 'abusefilter-log-details-hidden';
} elseif ( $visibility === self::VISIBILITY_HIDDEN_IMPLICIT ) {
$error = 'abusefilter-log-details-hidden-implicit';
}
}
}
if ( $error ) {
$out->addWikiMsg( $error );
return;
}
$output = Xml::element(
'legend',
null,
$this->msg( 'abusefilter-log-details-legend' )
->numParams( $id )
->text()
);
$output .= Xml::tags( 'p', null, $pager->doFormatRow( $row, false ) );
// Load data
$vars = $this->varBlobStore->loadVarDump( $row->afl_var_dump );
$out->addJsConfigVars( 'wgAbuseFilterVariables', $this->varManager->dumpAllVars( $vars, true ) );
// Diff, if available
if ( $row->afl_action === 'edit' ) {
// Guard for exception because these variables may be unset in case of data corruption (T264513)
// No need to lazy-load as these come from a DB dump.
try {
$old_wikitext = $vars->getComputedVariable( 'old_wikitext' )->toString();
} catch ( UnsetVariableException $_ ) {
$old_wikitext = '';
}
try {
$new_wikitext = $vars->getComputedVariable( 'new_wikitext' )->toString();
} catch ( UnsetVariableException $_ ) {
$new_wikitext = '';
}
$diffEngine = new DifferenceEngine( $this->getContext() );
$diffEngine->showDiffStyle();
$formattedDiff = $diffEngine->addHeader(
$diffEngine->generateTextDiffBody( $old_wikitext, $new_wikitext ),
'', ''
);
$output .=
Xml::tags(
'h3',
null,
$this->msg( 'abusefilter-log-details-diff' )->parse()
);
$output .= $formattedDiff;
}
$output .= Xml::element( 'h3', null, $this->msg( 'abusefilter-log-details-vars' )->text() );
// Build a table.
$output .= $this->variablesFormatter->buildVarDumpTable( $vars );
if ( $this->afPermissionManager->canSeePrivateDetails( $user ) ) {
$formDescriptor = [
'Reason' => [
'label-message' => 'abusefilter-view-privatedetails-reason',
'type' => 'text',
'size' => 45,
],
];
$htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
$htmlForm->setWrapperLegendMsg( 'abusefilter-view-privatedetails-legend' )
->setAction( $this->getPageTitle( 'private/' . $id )->getLocalURL() )
->setSubmitTextMsg( 'abusefilter-view-privatedetails-submit' )
->setMethod( 'post' )
->prepareForm();
$output .= $htmlForm->getHTML( false );
}
// TODO Clarify whether TextSlotDiffRenderer::getTextDiff can return unsafe stuff
// @phan-suppress-next-line SecurityCheck-XSS
$out->addHTML( Xml::tags( 'fieldset', null, $output ) );
}
/**
* Helper function to select a row with private details and some more context
* for an AbuseLog entry.
*
* @param User $user The user who's trying to view the row
* @param int $id The ID of the log entry
* @return Status A status object with the requested row stored in the value property,
* or an error and no row.
*/
public static function getPrivateDetailsRow( User $user, $id ) {
global $wgAbuseFilterAflFilterMigrationStage;
$afPermManager = AbuseFilterServices::getPermissionManager();
$dbr = wfGetDB( DB_REPLICA );
$fields = [ 'afl_id', 'afl_user_text', 'afl_timestamp', 'afl_ip', 'af_id',
'af_public_comments', 'af_hidden' ];
if ( $wgAbuseFilterAflFilterMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
$fields[] = 'afl_filter_id';
$fields[] = 'afl_global';
$join = [ 'af_id=afl_filter_id', 'afl_global' => 0 ];
} else {
// SCHEMA_COMPAT_READ_OLD
$fields[] = 'afl_filter';
$join = 'af_id=afl_filter';
}
$row = $dbr->selectRow(
[ 'abuse_filter_log', 'abuse_filter' ],
$fields,
[ 'afl_id' => $id ],
__METHOD__,
[],
[ 'abuse_filter' => [ 'LEFT JOIN', $join ] ]
);
$status = Status::newGood();
if ( !$row ) {
$status->fatal( 'abusefilter-log-nonexistent' );
return $status;
}
if ( $wgAbuseFilterAflFilterMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
$filterID = $row->afl_filter_id;
$global = $row->afl_global;
} else {
// SCHEMA_COMPAT_READ_OLD
list( $filterID, $global ) = GlobalNameUtils::splitGlobalName( $row->afl_filter );
}
if ( $global ) {
$lookup = AbuseFilterServices::getFilterLookup();
$filterHidden = $lookup->getFilter( $filterID, $global )->isHidden();
} else {
$filterHidden = $row->af_hidden;
}
if ( !$afPermManager->canSeeLogDetailsForFilter( $user, $filterHidden ) ) {
$status->fatal( 'abusefilter-log-cannot-see-details' );
return $status;
}
$status->setResult( true, $row );
return $status;
}
/**
* Builds an HTML table with the private details for a given abuseLog entry.
*
* @param stdClass $row The row, as returned by self::getPrivateDetailsRow()
* @return string The HTML output
*/
protected function buildPrivateDetailsTable( $row ) {
$output = Xml::element(
'legend',
null,
$this->msg( 'abusefilter-log-details-privatedetails' )->text()
);
$header =
Xml::element( 'th', null, $this->msg( 'abusefilter-log-details-var' )->text() ) .
Xml::element( 'th', null, $this->msg( 'abusefilter-log-details-val' )->text() );
$output .=
Xml::openElement( 'table',
[
'class' => 'wikitable mw-abuselog-private',
'style' => 'width: 80%;'
]
) .
Xml::openElement( 'tbody' );
$output .= $header;
// Log ID
$linkRenderer = $this->getLinkRenderer();
$output .=
Xml::tags( 'tr', null,
Xml::element( 'td',
[ 'style' => 'width: 30%;' ],
$this->msg( 'abusefilter-log-details-id' )->text()
) .
Xml::openElement( 'td' ) .
$linkRenderer->makeKnownLink(
$this->getPageTitle( $row->afl_id ),
$this->getLanguage()->formatNum( $row->afl_id )
) .
Xml::closeElement( 'td' )
);
// Timestamp
$output .=
Xml::tags( 'tr', null,
Xml::element( 'td',
[ 'style' => 'width: 30%;' ],
$this->msg( 'abusefilter-edit-builder-vars-timestamp-expanded' )->text()
) .
Xml::element( 'td',
null,
$this->getLanguage()->userTimeAndDate( $row->afl_timestamp, $this->getUser() )
)
);
// User
$output .=
Xml::tags( 'tr', null,
Xml::element( 'td',
[ 'style' => 'width: 30%;' ],
$this->msg( 'abusefilter-edit-builder-vars-user-name' )->text()
) .
Xml::element( 'td',
null,
$row->afl_user_text
)
);
// Filter ID
$output .=
Xml::tags( 'tr', null,
Xml::element( 'td',
[ 'style' => 'width: 30%;' ],
$this->msg( 'abusefilter-list-id' )->text()
) .
Xml::openElement( 'td' ) .
$linkRenderer->makeKnownLink(
SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ),
$this->getLanguage()->formatNum( $row->af_id )
) .
Xml::closeElement( 'td' )
);
// Filter description
$output .=
Xml::tags( 'tr', null,
Xml::element( 'td',
[ 'style' => 'width: 30%;' ],
$this->msg( 'abusefilter-list-public' )->text()
) .
Xml::element( 'td',
null,
$row->af_public_comments
)
);
// IP address
if ( $row->afl_ip !== '' ) {
if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) &&
$this->permissionManager->userHasRight( $this->getUser(), 'checkuser' )
) {
$CULink = '&nbsp;&middot;&nbsp;' . $linkRenderer->makeKnownLink(
SpecialPage::getTitleFor(
'CheckUser',
$row->afl_ip
),
$this->msg( 'abusefilter-log-details-checkuser' )->text()
);
} else {
$CULink = '';
}
$output .=
Xml::tags( 'tr', null,
Xml::element( 'td',
[ 'style' => 'width: 30%;' ],
$this->msg( 'abusefilter-log-details-ip' )->text()
) .
Xml::tags(
'td',
null,
self::getUserLinks( 0, $row->afl_ip ) . $CULink
)
);
} else {
$output .=
Xml::tags( 'tr', null,
Xml::element( 'td',
[ 'style' => 'width: 30%;' ],
$this->msg( 'abusefilter-log-details-ip' )->text()
) .
Xml::element(
'td',
null,
$this->msg( 'abusefilter-log-ip-not-available' )->text()
)
);
}
$output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' );
$output = Xml::tags( 'fieldset', null, $output );
return $output;
}
/**
* @param int $id
* @return void
*/
public function showPrivateDetails( $id ) {
$out = $this->getOutput();
$user = $this->getUser();
if ( !$this->afPermissionManager->canSeePrivateDetails( $user ) ) {
$out->addWikiMsg( 'abusefilter-log-cannot-see-privatedetails' );
return;
}
$request = $this->getRequest();
// Make sure it is a valid request
$token = $request->getVal( 'wpEditToken' );
if ( !$request->wasPosted() || !$user->matchEditToken( $token ) ) {
$out->addHTML(
Xml::tags(
'p',
null,
Html::errorBox( $this->msg( 'abusefilter-invalid-request' )->params( $id )->parse() )
)
);
return;
}
$reason = $request->getText( 'wpReason' );
if ( !self::checkPrivateDetailsAccessReason( $reason ) ) {
$out->addWikiMsg( 'abusefilter-noreason' );
$this->showDetails( $id );
return;
}
$status = self::getPrivateDetailsRow( $user, $id );
if ( !$status->isGood() ) {
$out->addWikiMsg( $status->getErrors()[0] );
return;
}
$row = $status->getValue();
// Log accessing private details
if ( $this->getConfig()->get( 'AbuseFilterLogPrivateDetailsAccess' ) ) {
self::addPrivateDetailsAccessLogEntry( $id, $reason, $user );
}
// Show private details (IP).
$table = $this->buildPrivateDetailsTable( $row );
$out->addHTML( $table );
}
/**
* If specifying a reason for viewing private details of abuse log is required
* then it makes sure that a reason is provided.
*
* @param string $reason
* @return bool
*/
public static function checkPrivateDetailsAccessReason( $reason ) {
global $wgAbuseFilterPrivateDetailsForceReason;
return ( !$wgAbuseFilterPrivateDetailsForceReason || strlen( $reason ) > 0 );
}
/**
* @param int $logID int The ID of the AbuseFilter log that was accessed
* @param string $reason The reason provided for accessing private details
* @param User $user The user who accessed the private details
* @return void
*/
public static function addPrivateDetailsAccessLogEntry( $logID, $reason, User $user ) {
$target = self::getTitleFor( self::PAGE_NAME, (string)$logID );
$logEntry = new ManualLogEntry( 'abusefilterprivatedetails', 'access' );
$logEntry->setPerformer( $user );
$logEntry->setTarget( $target );
$logEntry->setParameters( [
'4::logid' => $logID,
] );
$logEntry->setComment( $reason );
$logEntry->insert();
}
/**
* @param int $userId
* @param string $userName
* @return string
*/
public static function getUserLinks( $userId, $userName ) {
static $cache = [];
if ( !isset( $cache[$userName][$userId] ) ) {
$cache[$userName][$userId] = Linker::userLink( $userId, $userName ) .
Linker::userToolLinks( $userId, $userName, true );
}
return $cache[$userName][$userId];
}
/**
* Given a log entry row, decides whether or not it can be viewed by the public.
*
* @param stdClass $row The abuse_filter_log row object.
*
* @return bool|string true if the item is explicitly hidden, false if it is not.
* The string 'implicit' if it is hidden because the corresponding revision is hidden.
* @todo Stop using this method.
*/
public static function isHidden( $row ) {
// First, check if the entry is hidden. Since this is an oversight-level deletion,
// it's more important than the associated revision being deleted.
if ( $row->afl_deleted ) {
return true;
}
if ( $row->afl_rev_id ) {
$revision = MediaWikiServices::getInstance()
->getRevisionLookup()
->getRevisionById( $row->afl_rev_id );
if ( $revision && $revision->getVisibility() !== 0 ) {
return 'implicit';
}
}
return false;
}
/**
* @param stdClass $row
* @param Authority $authority
* @param AbuseFilterPermissionManager $afPermManager
* @return string One of the self::VISIBILITY_* constants
*/
public static function getEntryVisibilityForUser(
stdClass $row,
Authority $authority,
AbuseFilterPermissionManager $afPermManager
): string {
if ( $row->afl_deleted && !$afPermManager->canSeeHiddenLogEntries( $authority->getUser() ) ) {
return self::VISIBILITY_HIDDEN;
}
if ( !$row->afl_rev_id ) {
return self::VISIBILITY_VISIBLE;
}
$revRec = MediaWikiServices::getInstance()
->getRevisionLookup()
->getRevisionById( (int)$row->afl_rev_id );
if ( !$revRec || $revRec->getVisibility() === 0 ) {
return self::VISIBILITY_VISIBLE;
}
return $revRec->audienceCan( RevisionRecord::SUPPRESSED_ALL, RevisionRecord::FOR_THIS_USER, $authority )
? self::VISIBILITY_VISIBLE
: self::VISIBILITY_HIDDEN_IMPLICIT;
}
}