mediawiki-extensions-AbuseF.../includes/Special/SpecialAbuseLog.php
2021-09-25 01:39:08 +00:00

1141 lines
32 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() {
$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;
}
$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 );
}
$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 {
$filterID = $row->afl_filter_id;
$global = $row->afl_global;
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 ) {
$afPermManager = AbuseFilterServices::getPermissionManager();
$dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
[ 'abuse_filter_log', 'abuse_filter' ],
[ 'afl_id', 'afl_user_text', 'afl_filter_id', 'afl_global', 'afl_timestamp', 'afl_ip',
'af_id', 'af_public_comments', 'af_hidden' ],
[ 'afl_id' => $id ],
__METHOD__,
[],
[ 'abuse_filter' => [ 'LEFT JOIN', [ 'af_id=afl_filter_id', 'afl_global' => 0 ] ] ]
);
$status = Status::newGood();
if ( !$row ) {
$status->fatal( 'abusefilter-log-nonexistent' );
return $status;
}
$filterID = $row->afl_filter_id;
$global = $row->afl_global;
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;
}
}