diff options
Diffstat (limited to 'www/wiki/extensions/AbuseFilter/includes/Views')
10 files changed, 2908 insertions, 0 deletions
diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterView.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterView.php new file mode 100644 index 00000000..11b06bbf --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterView.php @@ -0,0 +1,112 @@ +<?php + +use Wikimedia\Rdbms\IDatabase; + +abstract class AbuseFilterView extends ContextSource { + public $mFilter, $mHistoryID, $mSubmit; + + /** + * @var \MediaWiki\Linker\LinkRenderer + */ + protected $linkRenderer; + + /** + * @param SpecialAbuseFilter $page + * @param array $params + */ + function __construct( $page, $params ) { + $this->mPage = $page; + $this->mParams = $params; + $this->setContext( $this->mPage->getContext() ); + $this->linkRenderer = $page->getLinkRenderer(); + } + + /** + * @param string $subpage + * @return Title + */ + function getTitle( $subpage = '' ) { + return $this->mPage->getPageTitle( $subpage ); + } + + abstract function show(); + + /** + * @return bool + */ + public function canEdit() { + return ( + !$this->getUser()->isBlocked() && + $this->getUser()->isAllowed( 'abusefilter-modify' ) + ); + } + + /** + * @return bool + */ + public function canEditGlobal() { + return $this->getUser()->isAllowed( 'abusefilter-modify-global' ); + } + + /** + * Whether the user can edit the given filter. + * + * @param object $row Filter row + * + * @return bool + */ + public function canEditFilter( $row ) { + return ( + $this->canEdit() && + !( isset( $row->af_global ) && $row->af_global == 1 && !$this->canEditGlobal() ) + ); + } + + /** + * @param IDatabase $db + * @return string + */ + public function buildTestConditions( IDatabase $db ) { + // If one of these is true, we're abusefilter compatible. + return $db->makeList( [ + 'rc_source' => [ + RecentChange::SRC_EDIT, + RecentChange::SRC_NEW, + ], + $db->makeList( [ + 'rc_source' => RecentChange::SRC_LOG, + $db->makeList( [ + $db->makeList( [ + 'rc_log_type' => 'move', + 'rc_log_action' => 'move' + ], LIST_AND ), + $db->makeList( [ + 'rc_log_type' => 'newusers', + 'rc_log_action' => 'create' + ], LIST_AND ), + $db->makeList( [ + 'rc_log_type' => 'delete', + 'rc_log_action' => 'delete' + ], LIST_AND ), + // @todo: add upload + ], LIST_OR ), + ], LIST_AND ), + ], LIST_OR ); + } + + /** + * @static + * @return bool + */ + static function canViewPrivate() { + global $wgUser; + static $canView = null; + + if ( is_null( $canView ) ) { + $canView = $wgUser->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ); + } + + return $canView; + } + +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewDiff.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewDiff.php new file mode 100644 index 00000000..4a3b1881 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewDiff.php @@ -0,0 +1,387 @@ +<?php + +class AbuseFilterViewDiff extends AbuseFilterView { + public $mOldVersion = null; + public $mNewVersion = null; + public $mNextHistoryId = null; + public $mFilter = null; + + function show() { + $show = $this->loadData(); + $out = $this->getOutput(); + + $links = []; + if ( $this->mFilter ) { + $links['abusefilter-history-backedit'] = $this->getTitle( $this->mFilter ); + $links['abusefilter-diff-backhistory'] = $this->getTitle( 'history/' . $this->mFilter ); + } + + foreach ( $links as $msg => $title ) { + $links[$msg] = $this->linkRenderer->makeLink( $title, $this->msg( $msg )->text() ); + } + + $backlinks = $this->getLanguage()->pipeList( $links ); + $out->addHTML( Xml::tags( 'p', null, $backlinks ) ); + + if ( $show ) { + $out->addHTML( $this->formatDiff() ); + + // Next and previous change links + $links = []; + if ( AbuseFilter::getFirstFilterChange( $this->mFilter ) != + $this->mOldVersion['meta']['history_id'] + ) { + // Create a "previous change" link if this isn't the first change of the given filter + $links[] = $this->linkRenderer->makeLink( + $this->getTitle( + 'history/' . $this->mFilter . '/diff/prev/' . $this->mOldVersion['meta']['history_id'] + ), + $this->getLanguage()->getArrow( 'backwards' ) . + ' ' . $this->msg( 'abusefilter-diff-prev' )->text() + ); + } + + if ( !is_null( $this->mNextHistoryId ) ) { + // Create a "next change" link if this isn't the last change of the given filter + $links[] = $this->linkRenderer->makeLink( + $this->getTitle( + 'history/' . $this->mFilter . '/diff/prev/' . $this->mNextHistoryId + ), + $this->msg( 'abusefilter-diff-next' )->text() . + ' ' . $this->getLanguage()->getArrow( 'forwards' ) + ); + } + + if ( count( $links ) > 0 ) { + $backlinks = $this->getLanguage()->pipeList( $links ); + $out->addHTML( Xml::tags( 'p', null, $backlinks ) ); + } + } + } + + function loadData() { + $oldSpec = $this->mParams[3]; + $newSpec = $this->mParams[4]; + $this->mFilter = $this->mParams[1]; + + if ( AbuseFilter::filterHidden( $this->mFilter ) + && !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) + ) { + $this->getOutput()->addWikiMsg( 'abusefilter-history-error-hidden' ); + return false; + } + + $this->mOldVersion = $this->loadSpec( $oldSpec, $newSpec ); + $this->mNewVersion = $this->loadSpec( $newSpec, $oldSpec ); + + if ( is_null( $this->mOldVersion ) || is_null( $this->mNewVersion ) ) { + $this->getOutput()->addWikiMsg( 'abusefilter-diff-invalid' ); + return false; + } + + $this->mNextHistoryId = $this->getNextHistoryId( + $this->mNewVersion['meta']['history_id'] + ); + + return true; + } + + /** + * Get the history ID of the next change + * + * @param int $historyId History id to find next change of + * @return int|null Id of the next change or null if there isn't one + */ + function getNextHistoryId( $historyId ) { + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + 'abuse_filter_history', + 'afh_id', + [ + 'afh_filter' => $this->mFilter, + 'afh_id > ' . $dbr->addQuotes( $historyId ), + ], + __METHOD__, + [ 'ORDER BY' => 'afh_timestamp ASC' ] + ); + if ( $row ) { + return $row->afh_id; + } + return null; + } + + function loadSpec( $spec, $otherSpec ) { + static $dependentSpecs = [ 'prev', 'next' ]; + static $cache = []; + + if ( isset( $cache[$spec] ) ) { + return $cache[$spec]; + } + + $dbr = wfGetDB( DB_REPLICA ); + $row = null; + if ( is_numeric( $spec ) ) { + $row = $dbr->selectRow( + 'abuse_filter_history', + '*', + [ 'afh_id' => $spec, 'afh_filter' => $this->mFilter ], + __METHOD__ + ); + } elseif ( $spec == 'cur' ) { + $row = $dbr->selectRow( + 'abuse_filter_history', + '*', + [ 'afh_filter' => $this->mFilter ], + __METHOD__, + [ 'ORDER BY' => 'afh_timestamp desc' ] + ); + } elseif ( $spec == 'prev' && !in_array( $otherSpec, $dependentSpecs ) ) { + // cached + $other = $this->loadSpec( $otherSpec, $spec ); + + $row = $dbr->selectRow( + 'abuse_filter_history', + '*', + [ + 'afh_filter' => $this->mFilter, + 'afh_id<' . $dbr->addQuotes( $other['meta']['history_id'] ), + ], + __METHOD__, + [ 'ORDER BY' => 'afh_timestamp desc' ] + ); + if ( $other && !$row ) { + $t = $this->getTitle( + 'history/' . $this->mFilter . '/item/' . $other['meta']['history_id'] ); + $this->getOutput()->redirect( $t->getFullURL() ); + return null; + } + } elseif ( $spec == 'next' && !in_array( $otherSpec, $dependentSpecs ) ) { + // cached + $other = $this->loadSpec( $otherSpec, $spec ); + + $row = $dbr->selectRow( + 'abuse_filter_history', + '*', + [ + 'afh_filter' => $this->mFilter, + 'afh_id>' . $dbr->addQuotes( $other['meta']['history_id'] ), + ], + __METHOD__, + [ 'ORDER BY' => 'afh_timestamp ASC' ] + ); + + if ( $other && !$row ) { + $t = $this->getTitle( + 'history/' . $this->mFilter . '/item/' . $other['meta']['history_id'] ); + $this->getOutput()->redirect( $t->getFullURL() ); + return null; + } + } + + if ( !$row ) { + return null; + } + + $data = $this->loadFromHistoryRow( $row ); + $cache[$spec] = $data; + return $data; + } + + function loadFromHistoryRow( $row ) { + return [ + 'meta' => [ + 'history_id' => $row->afh_id, + 'modified_by' => $row->afh_user, + 'modified_by_text' => $row->afh_user_text, + 'modified' => $row->afh_timestamp, + ], + 'info' => [ + 'description' => $row->afh_public_comments, + 'flags' => $row->afh_flags, + 'notes' => $row->afh_comments, + 'group' => $row->afh_group, + ], + 'pattern' => $row->afh_pattern, + 'actions' => unserialize( $row->afh_actions ), + ]; + } + + /** + * @param string $timestamp + * @param int $history_id + * @return string + */ + function formatVersionLink( $timestamp, $history_id ) { + $filter = $this->mFilter; + $text = $this->getLanguage()->timeanddate( $timestamp, true ); + $title = $this->getTitle( "history/$filter/item/$history_id" ); + + $link = $this->linkRenderer->makeLink( $title, $text ); + + return $link; + } + + /** + * @return string + */ + function formatDiff() { + $oldVersion = $this->mOldVersion; + $newVersion = $this->mNewVersion; + + // headings + $oldLink = $this->formatVersionLink( + $oldVersion['meta']['modified'], + $oldVersion['meta']['history_id'] + ); + $newLink = $this->formatVersionLink( + $newVersion['meta']['modified'], + $newVersion['meta']['history_id'] + ); + + $oldUserLink = Linker::userLink( + $oldVersion['meta']['modified_by'], + $oldVersion['meta']['modified_by_text'] + ); + $newUserLink = Linker::userLink( + $newVersion['meta']['modified_by'], + $newVersion['meta']['modified_by_text'] + ); + + $headings = ''; + $headings .= Xml::tags( 'th', null, + $this->msg( 'abusefilter-diff-item' )->parse() ); + $headings .= Xml::tags( 'th', null, + $this->msg( 'abusefilter-diff-version' ) + ->rawParams( $oldLink, $oldUserLink ) + ->params( $newVersion['meta']['modified_by_text'] ) + ->parse() + ); + $headings .= Xml::tags( 'th', null, + $this->msg( 'abusefilter-diff-version' ) + ->rawParams( $newLink, $newUserLink ) + ->params( $newVersion['meta']['modified_by_text'] ) + ->parse() + ); + + $headings = Xml::tags( 'tr', null, $headings ); + + // Basic info + $info = ''; + $info .= $this->getHeaderRow( 'abusefilter-diff-info' ); + $info .= $this->getDiffRow( + 'abusefilter-edit-description', + $oldVersion['info']['description'], + $newVersion['info']['description'] + ); + global $wgAbuseFilterValidGroups; + if ( + count( $wgAbuseFilterValidGroups ) > 1 || + $oldVersion['info']['group'] != $newVersion['info']['group'] + ) { + $info .= $this->getDiffRow( + 'abusefilter-edit-group', + AbuseFilter::nameGroup( $oldVersion['info']['group'] ), + AbuseFilter::nameGroup( $newVersion['info']['group'] ) + ); + } + $info .= $this->getDiffRow( + 'abusefilter-edit-flags', + AbuseFilter::formatFlags( $oldVersion['info']['flags'] ), + AbuseFilter::formatFlags( $newVersion['info']['flags'] ) + ); + + $info .= $this->getDiffRow( + 'abusefilter-edit-notes', + $oldVersion['info']['notes'], + $newVersion['info']['notes'] + ); + + // Pattern + $info .= $this->getHeaderRow( 'abusefilter-diff-pattern' ); + $info .= $this->getDiffRow( + 'abusefilter-edit-rules', + $oldVersion['pattern'], + $newVersion['pattern'], + 'text' + ); + + // Actions + $oldActions = $this->stringifyActions( $oldVersion['actions'] ); + $newActions = $this->stringifyActions( $newVersion['actions'] ); + + $info .= $this->getHeaderRow( 'abusefilter-edit-consequences' ); + $info .= $this->getDiffRow( + 'abusefilter-edit-consequences', + $oldActions, + $newActions + ); + + $html = "<table class='wikitable'> + <thead>$headings</thead> + <tbody>$info</tbody> + </table>"; + + $html = Xml::tags( 'h2', null, $this->msg( 'abusefilter-diff-title' )->parse() ) . $html; + + return $html; + } + + /** + * @param array $actions + * @return array + */ + function stringifyActions( $actions ) { + $lines = []; + + ksort( $actions ); + foreach ( $actions as $action => $parameters ) { + $lines[] = AbuseFilter::formatAction( $action, $parameters ); + } + + if ( !count( $lines ) ) { + $lines[] = ''; + } + + return $lines; + } + + /** + * @param string $msg + * @return string + */ + function getHeaderRow( $msg ) { + $html = $this->msg( $msg )->parse(); + $html = Xml::tags( 'th', [ 'colspan' => 3 ], $html ); + $html = Xml::tags( 'tr', [ 'class' => 'mw-abusefilter-diff-header' ], $html ); + + return $html; + } + + /** + * @param string $msg + * @param array|string $old + * @param array|string $new + * @return string + */ + function getDiffRow( $msg, $old, $new ) { + if ( !is_array( $old ) ) { + $old = explode( "\n", preg_replace( "/\\\r\\\n?/", "\n", $old ) ); + } + if ( !is_array( $new ) ) { + $new = explode( "\n", preg_replace( "/\\\r\\\n?/", "\n", $new ) ); + } + + $diffEngine = new DifferenceEngine( $this->getContext() ); + + $diffEngine->showDiffStyle(); + + $diff = new Diff( $old, $new ); + $formatter = new TableDiffFormatterFullContext(); + $formattedDiff = $diffEngine->addHeader( $formatter->format( $diff ), '', '' ); + + return Xml::tags( 'tr', null, + Xml::tags( 'th', null, $this->msg( $msg )->parse() ) . + Xml::tags( 'td', [ 'colspan' => 2 ], $formattedDiff ) + ) . "\n"; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewEdit.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewEdit.php new file mode 100644 index 00000000..9f4db06e --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewEdit.php @@ -0,0 +1,1252 @@ +<?php + +class AbuseFilterViewEdit extends AbuseFilterView { + /** + * @param SpecialAbuseFilter $page + * @param array $params + */ + function __construct( $page, $params ) { + parent::__construct( $page, $params ); + $this->mFilter = $page->mFilter; + $this->mHistoryID = $page->mHistoryID; + } + + /** + * Check whether a filter is allowed to use a tag + * + * @param string $tag Tag name + * @return Status + */ + protected function isAllowedTag( $tag ) { + $tagNameStatus = ChangeTags::isTagNameValid( $tag ); + + if ( !$tagNameStatus->isGood() ) { + return $tagNameStatus; + } + + $finalStatus = Status::newGood(); + + $canAddStatus = + ChangeTags::canAddTagsAccompanyingChange( + [ $tag ] + ); + + if ( $canAddStatus->isGood() ) { + return $finalStatus; + } + + $alreadyDefinedTags = []; + AbuseFilterHooks::onListDefinedTags( $alreadyDefinedTags ); + + if ( in_array( $tag, $alreadyDefinedTags, true ) ) { + return $finalStatus; + } + + $canCreateTagStatus = ChangeTags::canCreateTag( $tag ); + if ( $canCreateTagStatus->isGood() ) { + return $finalStatus; + } + + $finalStatus->fatal( 'abusefilter-edit-bad-tags' ); + return $finalStatus; + } + + function show() { + $user = $this->getUser(); + $out = $this->getOutput(); + $request = $this->getRequest(); + $out->setPageTitle( $this->msg( 'abusefilter-edit' ) ); + $out->addHelpLink( 'Extension:AbuseFilter/Rules format' ); + + $filter = $this->mFilter; + $history_id = $this->mHistoryID; + if ( $this->mHistoryID ) { + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + 'abuse_filter_history', + 'afh_id', + [ + 'afh_filter' => $filter, + ], + __METHOD__, + [ 'ORDER BY' => 'afh_timestamp DESC' ] + ); + // change $history_id to null if it's current version id + if ( $row->afh_id === $this->mHistoryID ) { + $history_id = null; + } + } + + // Add default warning messages + $this->exposeWarningMessages(); + + if ( $filter == 'new' && !$this->canEdit() ) { + $out->addWikiMsg( 'abusefilter-edit-notallowed' ); + return; + } + + $editToken = $request->getVal( 'wpEditToken' ); + $tokenMatches = $user->matchEditToken( + $editToken, [ 'abusefilter', $filter ], $request ); + + if ( $tokenMatches && $this->canEdit() ) { + // Check syntax + $syntaxerr = AbuseFilter::checkSyntax( $request->getVal( 'wpFilterRules' ) ); + if ( $syntaxerr !== true ) { + $out->addHTML( + $this->buildFilterEditor( + $this->msg( + 'abusefilter-edit-badsyntax', + [ $syntaxerr[0] ] + )->parseAsBlock(), + $filter, $history_id + ) + ); + return; + } + // Check for missing required fields (title and pattern) + $missing = []; + if ( !$request->getVal( 'wpFilterRules' ) || + trim( $request->getVal( 'wpFilterRules' ) ) === '' ) { + $missing[] = $this->msg( 'abusefilter-edit-field-conditions' )->escaped(); + } + if ( !$request->getVal( 'wpFilterDescription' ) ) { + $missing[] = $this->msg( 'abusefilter-edit-field-description' )->escaped(); + } + if ( count( $missing ) !== 0 ) { + $missing = $this->getLanguage()->commaList( $missing ); + $out->addHTML( + $this->buildFilterEditor( + $this->msg( + 'abusefilter-edit-missingfields', + $missing + )->parseAsBlock(), + $filter, $history_id + ) + ); + return; + } + + $dbw = wfGetDB( DB_MASTER ); + + list( $newRow, $actions ) = $this->loadRequest( $filter ); + + $differences = AbuseFilter::compareVersions( + [ $newRow, $actions ], + [ $newRow->mOriginalRow, $newRow->mOriginalActions ] + ); + + // Don't allow adding a new global rule, or updating a + // rule that is currently global, without permissions. + if ( !$this->canEditFilter( $newRow ) || !$this->canEditFilter( $newRow->mOriginalRow ) ) { + $out->addWikiMsg( 'abusefilter-edit-notallowed-global' ); + return; + } + + // Don't allow custom messages on global rules + if ( $newRow->af_global == 1 && + $request->getVal( 'wpFilterWarnMessage' ) !== 'abusefilter-warning' + ) { + $out->addWikiMsg( 'abusefilter-edit-notallowed-global-custom-msg' ); + return; + } + + $origActions = $newRow->mOriginalActions; + $wasGlobal = (bool)$newRow->mOriginalRow->af_global; + + unset( $newRow->mOriginalRow ); + unset( $newRow->mOriginalActions ); + + // Check for non-changes + if ( !count( $differences ) ) { + $out->redirect( $this->getTitle()->getLocalURL() ); + return; + } + + // Check for restricted actions + global $wgAbuseFilterRestrictions; + if ( count( array_intersect_key( + array_filter( $wgAbuseFilterRestrictions ), + array_merge( + array_filter( $actions ), + array_filter( $origActions ) + ) + ) ) + && !$user->isAllowed( 'abusefilter-modify-restricted' ) + ) { + $out->addHTML( + $this->buildFilterEditor( + $this->msg( 'abusefilter-edit-restricted' )->parseAsBlock(), + $this->mFilter, + $history_id + ) + ); + return; + } + + // If we've activated the 'tag' option, check the arguments for validity. + if ( !empty( $actions['tag'] ) ) { + foreach ( $actions['tag']['parameters'] as $tag ) { + $status = $this->isAllowedTag( $tag ); + + if ( !$status->isGood() ) { + $out->addHTML( + $this->buildFilterEditor( + $status->getMessage()->parseAsBlock(), + $this->mFilter, + $history_id + ) + ); + return; + } + } + } + + $newRow = get_object_vars( $newRow ); // Convert from object to array + + // Set last modifier. + $newRow['af_timestamp'] = $dbw->timestamp( wfTimestampNow() ); + $newRow['af_user'] = $user->getId(); + $newRow['af_user_text'] = $user->getName(); + + $dbw->startAtomic( __METHOD__ ); + + // Insert MAIN row. + if ( $filter == 'new' ) { + $new_id = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' ); + $is_new = true; + } else { + $new_id = $this->mFilter; + $is_new = false; + } + + // Reset throttled marker, if we're re-enabling it. + $newRow['af_throttled'] = $newRow['af_throttled'] && !$newRow['af_enabled']; + $newRow['af_id'] = $new_id; // ID. + + // T67807 + // integer 1's & 0's might be better understood than booleans + $newRow['af_enabled'] = (int)$newRow['af_enabled']; + $newRow['af_hidden'] = (int)$newRow['af_hidden']; + $newRow['af_throttled'] = (int)$newRow['af_throttled']; + $newRow['af_deleted'] = (int)$newRow['af_deleted']; + $newRow['af_global'] = (int)$newRow['af_global']; + + $dbw->replace( 'abuse_filter', [ 'af_id' ], $newRow, __METHOD__ ); + + if ( $is_new ) { + $new_id = $dbw->insertId(); + } + + // Actions + global $wgAbuseFilterActions; + $deadActions = []; + $actionsRows = []; + foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { + // Check if it's set + $enabled = isset( $actions[$action] ) && (bool)$actions[$action]; + + if ( $enabled ) { + $parameters = $actions[$action]['parameters']; + + $thisRow = [ + 'afa_filter' => $new_id, + 'afa_consequence' => $action, + 'afa_parameters' => implode( "\n", $parameters ) + ]; + $actionsRows[] = $thisRow; + } else { + $deadActions[] = $action; + } + } + + // Create a history row + $afh_row = []; + + foreach ( AbuseFilter::$history_mappings as $af_col => $afh_col ) { + $afh_row[$afh_col] = $newRow[$af_col]; + } + + // Actions + $displayActions = []; + foreach ( $actions as $action ) { + $displayActions[$action['action']] = $action['parameters']; + } + $afh_row['afh_actions'] = serialize( $displayActions ); + + $afh_row['afh_changed_fields'] = implode( ',', $differences ); + + // Flags + $flags = []; + if ( $newRow['af_hidden'] ) { + $flags[] = 'hidden'; + } + if ( $newRow['af_enabled'] ) { + $flags[] = 'enabled'; + } + if ( $newRow['af_deleted'] ) { + $flags[] = 'deleted'; + } + if ( $newRow['af_global'] ) { + $flags[] = 'global'; + } + + $afh_row['afh_flags'] = implode( ',', $flags ); + + $afh_row['afh_filter'] = $new_id; + $afh_row['afh_id'] = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' ); + + // Do the update + $dbw->insert( 'abuse_filter_history', $afh_row, __METHOD__ ); + $history_id = $dbw->insertId(); + if ( $filter != 'new' ) { + $dbw->delete( + 'abuse_filter_action', + [ 'afa_filter' => $filter ], + __METHOD__ + ); + } + $dbw->insert( 'abuse_filter_action', $actionsRows, __METHOD__ ); + + $dbw->endAtomic( __METHOD__ ); + + // Invalidate cache if this was a global rule + if ( $wasGlobal || $newRow['af_global'] ) { + $group = 'default'; + if ( isset( $newRow['af_group'] ) && $newRow['af_group'] != '' ) { + $group = $newRow['af_group']; + } + + $globalRulesKey = AbuseFilter::getGlobalRulesKey( $group ); + ObjectCache::getMainWANInstance()->touchCheckKey( $globalRulesKey ); + } + + // Logging + $subtype = $filter === 'new' ? 'create' : 'modify'; + $logEntry = new ManualLogEntry( 'abusefilter', $subtype ); + $logEntry->setPerformer( $user ); + $logEntry->setTarget( $this->getTitle( $new_id ) ); + $logEntry->setParameters( [ + 'historyId' => $history_id, + 'newId' => $new_id + ] ); + $logid = $logEntry->insert(); + $logEntry->publish( $logid ); + + // Purge the tag list cache so the fetchAllTags hook applies tag changes + if ( isset( $actions['tag'] ) ) { + AbuseFilterHooks::purgeTagCache(); + } + + AbuseFilter::resetFilterProfile( $new_id ); + + $out->redirect( + $this->getTitle()->getLocalURL( + [ + 'result' => 'success', + 'changedfilter' => $new_id, + 'changeid' => $history_id, + ] + ) + ); + } else { + if ( $tokenMatches ) { + // lost rights meanwhile + $out->addWikiMsg( 'abusefilter-edit-notallowed' ); + } + + if ( $history_id ) { + $out->addWikiMsg( + 'abusefilter-edit-oldwarning', $history_id, $filter ); + } + + $out->addHTML( $this->buildFilterEditor( null, $filter, $history_id ) ); + + if ( $history_id ) { + $out->addWikiMsg( + 'abusefilter-edit-oldwarning', $history_id, $filter ); + } + } + } + + /** + * Builds the full form for edit filters. + * Loads data either from the database or from the HTTP request. + * The request takes precedence over the database + * @param string $error An error message to show above the filter box. + * @param int $filter The filter ID + * @param int $history_id The history ID of the filter, if applicable. Otherwise null + * @return bool|string False if there is a failure building the editor, + * otherwise the HTML text for the editor. + */ + function buildFilterEditor( $error, $filter, $history_id = null ) { + if ( $filter === null ) { + return false; + } + + // Build the edit form + $out = $this->getOutput(); + $lang = $this->getLanguage(); + $user = $this->getUser(); + + // Load from request OR database. + list( $row, $actions ) = $this->loadRequest( $filter, $history_id ); + + if ( !$row ) { + $out->addWikiMsg( 'abusefilter-edit-badfilter' ); + $out->addHTML( $this->linkRenderer->makeLink( $this->getTitle(), + $this->msg( 'abusefilter-return' )->text() ) ); + return false; + } + + $out->addSubtitle( $this->msg( + $filter === 'new' ? 'abusefilter-edit-subtitle-new' : 'abusefilter-edit-subtitle', + $this->getLanguage()->formatNum( $filter ), $history_id + )->parse() ); + + // Hide hidden filters. + if ( ( ( isset( $row->af_hidden ) && $row->af_hidden ) || + AbuseFilter::filterHidden( $filter ) ) + && !$this->canViewPrivate() ) { + return $this->msg( 'abusefilter-edit-denied' )->escaped(); + } + + $output = ''; + if ( $error ) { + $out->addHTML( "<span class=\"error\">$error</span>" ); + } + + // Read-only attribute + $readOnlyAttrib = []; + $cbReadOnlyAttrib = []; // For checkboxes + + $styleAttrib = [ 'style' => 'width:95%' ]; + + if ( !$this->canEditFilter( $row ) ) { + $readOnlyAttrib['readonly'] = 'readonly'; + $cbReadOnlyAttrib['disabled'] = 'disabled'; + } + + $fields = []; + + $fields['abusefilter-edit-id'] = + $this->mFilter == 'new' ? + $this->msg( 'abusefilter-edit-new' )->escaped() : + $lang->formatNum( $filter ); + $fields['abusefilter-edit-description'] = + Xml::input( + 'wpFilterDescription', + 45, + isset( $row->af_public_comments ) ? $row->af_public_comments : '', + array_merge( $readOnlyAttrib, $styleAttrib ) + ); + + global $wgAbuseFilterValidGroups; + if ( count( $wgAbuseFilterValidGroups ) > 1 ) { + $groupSelector = new XmlSelect( + 'wpFilterGroup', + 'mw-abusefilter-edit-group-input', + 'default' + ); + + if ( isset( $row->af_group ) && $row->af_group ) { + $groupSelector->setDefault( $row->af_group ); + } + + foreach ( $wgAbuseFilterValidGroups as $group ) { + $groupSelector->addOption( AbuseFilter::nameGroup( $group ), $group ); + } + + if ( !empty( $readOnlyAttrib ) ) { + $groupSelector->setAttribute( 'disabled', 'disabled' ); + } + + $fields['abusefilter-edit-group'] = $groupSelector->getHTML(); + } + + // Hit count display + if ( !empty( $row->af_hit_count ) && $user->isAllowed( 'abusefilter-log-detail' ) ) { + $count_display = $this->msg( 'abusefilter-hitcount' ) + ->numParams( (int)$row->af_hit_count )->text(); + $hitCount = $this->linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'AbuseLog' ), + $count_display, + [], + [ 'wpSearchFilter' => $row->af_id ] + ); + + $fields['abusefilter-edit-hitcount'] = $hitCount; + } + + if ( $filter !== 'new' ) { + // Statistics + global $wgAbuseFilterProfile; + $stash = ObjectCache::getMainStashInstance(); + $matches_count = (int)$stash->get( AbuseFilter::filterMatchesKey( $filter ) ); + $total = (int)$stash->get( AbuseFilter::filterUsedKey( $row->af_group ) ); + + if ( $total > 0 ) { + $matches_percent = sprintf( '%.2f', 100 * $matches_count / $total ); + if ( $wgAbuseFilterProfile ) { + list( $timeProfile, $condProfile ) = AbuseFilter::getFilterProfile( $filter ); + $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status-profile' ) + ->numParams( $total, $matches_count, $matches_percent, $timeProfile, $condProfile ) + ->escaped(); + } else { + $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status' ) + ->numParams( $total, $matches_count, $matches_percent ) + ->parse(); + } + } + } + + $fields['abusefilter-edit-rules'] = AbuseFilter::buildEditBox( + $row->af_pattern, + 'wpFilterRules', + true, + $this->canEditFilter( $row ) + ); + $fields['abusefilter-edit-notes'] = Xml::textarea( + 'wpFilterNotes', + ( isset( $row->af_comments ) ? $row->af_comments . "\n" : "\n" ), + 40, 15, + $readOnlyAttrib + ); + + // Build checkboxes + $checkboxes = [ 'hidden', 'enabled', 'deleted' ]; + $flags = ''; + + global $wgAbuseFilterIsCentral; + if ( $wgAbuseFilterIsCentral ) { + $checkboxes[] = 'global'; + } + + if ( isset( $row->af_throttled ) && $row->af_throttled ) { + global $wgAbuseFilterRestrictions; + + $filterActions = explode( ',', $row->af_actions ); + $throttledActions = array_intersect_key( + array_flip( $filterActions ), + array_filter( $wgAbuseFilterRestrictions ) + ); + + if ( $throttledActions ) { + $throttledActions = array_map( + function ( $filterAction ) { + return $this->msg( 'abusefilter-action-' . $filterAction )->text(); + }, + array_keys( $throttledActions ) + ); + + $flags .= $out->parse( + Html::warningBox( + $this->msg( 'abusefilter-edit-throttled-warning' ) + ->plaintextParams( $lang->commaList( $throttledActions ) ) + ->escaped() + ) + ); + } + } + + foreach ( $checkboxes as $checkboxId ) { + // Messages that can be used here: + // * abusefilter-edit-enabled + // * abusefilter-edit-deleted + // * abusefilter-edit-hidden + // * abusefilter-edit-global + $message = "abusefilter-edit-$checkboxId"; + $dbField = "af_$checkboxId"; + $postVar = 'wpFilter' . ucfirst( $checkboxId ); + + if ( $checkboxId == 'global' && !$this->canEditGlobal() ) { + $cbReadOnlyAttrib['disabled'] = 'disabled'; + } + + $checkbox = Xml::checkLabel( + $this->msg( $message )->text(), + $postVar, + $postVar, + isset( $row->$dbField ) ? $row->$dbField : false, + $cbReadOnlyAttrib + ); + $checkbox = Xml::tags( 'p', null, $checkbox ); + $flags .= $checkbox; + } + + $fields['abusefilter-edit-flags'] = $flags; + $tools = ''; + + if ( $filter != 'new' ) { + if ( $user->isAllowed( 'abusefilter-revert' ) ) { + $tools .= Xml::tags( + 'p', null, + $this->linkRenderer->makeLink( + $this->getTitle( "revert/$filter" ), + new HtmlArmor( $this->msg( 'abusefilter-edit-revert' )->parse() ) + ) + ); + } + + if ( $this->canEdit() ) { + // Test link + $tools .= Xml::tags( + 'p', null, + $this->linkRenderer->makeLink( + $this->getTitle( "test/$filter" ), + new HtmlArmor( $this->msg( 'abusefilter-edit-test-link' )->parse() ) + ) + ); + } + // Last modification details + $userLink = + Linker::userLink( $row->af_user, $row->af_user_text ) . + Linker::userToolLinks( $row->af_user, $row->af_user_text ); + $userName = $row->af_user_text; + $fields['abusefilter-edit-lastmod'] = + $this->msg( 'abusefilter-edit-lastmod-text' ) + ->rawParams( + $lang->timeanddate( $row->af_timestamp, true ), + $userLink, + $lang->date( $row->af_timestamp, true ), + $lang->time( $row->af_timestamp, true ), + $userName + )->parse(); + $history_display = new HtmlArmor( $this->msg( 'abusefilter-edit-viewhistory' )->parse() ); + $fields['abusefilter-edit-history'] = + $this->linkRenderer->makeKnownLink( $this->getTitle( 'history/' . $filter ), $history_display ); + } + + // Add export + $exportText = FormatJson::encode( [ 'row' => $row, 'actions' => $actions ] ); + $tools .= Xml::tags( 'a', [ 'href' => '#', 'id' => 'mw-abusefilter-export-link' ], + $this->msg( 'abusefilter-edit-export' )->parse() ); + $tools .= Xml::element( 'textarea', + [ 'readonly' => 'readonly', 'id' => 'mw-abusefilter-export' ], + $exportText + ); + + $fields['abusefilter-edit-tools'] = $tools; + + $form = Xml::buildForm( $fields ); + $form = Xml::fieldset( $this->msg( 'abusefilter-edit-main' )->text(), $form ); + $form .= Xml::fieldset( + $this->msg( 'abusefilter-edit-consequences' )->text(), + $this->buildConsequenceEditor( $row, $actions ) + ); + + if ( $this->canEditFilter( $row ) ) { + $form .= Xml::submitButton( + $this->msg( 'abusefilter-edit-save' )->text(), + [ 'accesskey' => 's' ] + ); + $form .= Html::hidden( + 'wpEditToken', + $user->getEditToken( [ 'abusefilter', $filter ] ) + ); + } + + $form = Xml::tags( 'form', + [ + 'action' => $this->getTitle( $filter )->getFullURL(), + 'method' => 'post' + ], + $form + ); + + $output .= $form; + + return $output; + } + + /** + * Builds the "actions" editor for a given filter. + * @param stdClass $row A row from the abuse_filter table. + * @param array $actions Array of rows from the abuse_filter_action table + * corresponding to the abuse filter held in $row. + * @return HTML text for an action editor. + */ + function buildConsequenceEditor( $row, $actions ) { + global $wgAbuseFilterActions; + + $enabledActions = array_filter( $wgAbuseFilterActions ); + + $setActions = []; + foreach ( $enabledActions as $action => $_ ) { + $setActions[$action] = array_key_exists( $action, $actions ); + } + + $output = ''; + + foreach ( $enabledActions as $action => $_ ) { + MediaWiki\suppressWarnings(); + $params = $actions[$action]['parameters']; + MediaWiki\restoreWarnings(); + $output .= $this->buildConsequenceSelector( + $action, $setActions[$action], $params, $row ); + } + + return $output; + } + + /** + * @param string $action The action to build an editor for + * @param bool $set Whether or not the action is activated + * @param array $parameters Action parameters + * @param stdClass $row abuse_filter row object + * @return string + */ + function buildConsequenceSelector( $action, $set, $parameters, $row ) { + global $wgAbuseFilterActions, $wgMainCacheType; + + if ( empty( $wgAbuseFilterActions[$action] ) ) { + return ''; + } + + $readOnlyAttrib = []; + $cbReadOnlyAttrib = []; // For checkboxes + + if ( !$this->canEditFilter( $row ) ) { + $readOnlyAttrib['readonly'] = 'readonly'; + $cbReadOnlyAttrib['disabled'] = 'disabled'; + } + + switch ( $action ) { + case 'throttle': + // Throttling is only available via object caching + if ( $wgMainCacheType === CACHE_NONE ) { + return ''; + } + $throttleSettings = Xml::checkLabel( + $this->msg( 'abusefilter-edit-action-throttle' )->text(), + 'wpFilterActionThrottle', + "mw-abusefilter-action-checkbox-$action", + $set, + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib ); + $throttleFields = []; + + if ( $set ) { + array_shift( $parameters ); + $throttleRate = explode( ',', $parameters[0] ); + $throttleCount = $throttleRate[0]; + $throttlePeriod = $throttleRate[1]; + + $throttleGroups = implode( "\n", array_slice( $parameters, 1 ) ); + } else { + $throttleCount = 3; + $throttlePeriod = 60; + + $throttleGroups = "user\n"; + } + + $throttleFields['abusefilter-edit-throttle-count'] = + Xml::input( 'wpFilterThrottleCount', 20, $throttleCount, $readOnlyAttrib ); + $throttleFields['abusefilter-edit-throttle-period'] = + $this->msg( 'abusefilter-edit-throttle-seconds' ) + ->rawParams( Xml::input( 'wpFilterThrottlePeriod', 20, $throttlePeriod, + $readOnlyAttrib ) + )->parse(); + $throttleFields['abusefilter-edit-throttle-groups'] = + Xml::textarea( 'wpFilterThrottleGroups', $throttleGroups . "\n", + 40, 5, $readOnlyAttrib ); + $throttleSettings .= + Xml::tags( + 'div', + [ 'id' => 'mw-abusefilter-throttle-parameters' ], + Xml::buildForm( $throttleFields ) + ); + return $throttleSettings; + case 'warn': + global $wgAbuseFilterDefaultWarningMessage; + $output = ''; + $checkbox = Xml::checkLabel( + $this->msg( 'abusefilter-edit-action-warn' )->text(), + 'wpFilterActionWarn', + "mw-abusefilter-action-checkbox-$action", + $set, + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib ); + $output .= Xml::tags( 'p', null, $checkbox ); + if ( $set ) { + $warnMsg = $parameters[0]; + } elseif ( + $row && + isset( $row->af_group ) && $row->af_group && + isset( $wgAbuseFilterDefaultWarningMessage[$row->af_group] ) + ) { + $warnMsg = $wgAbuseFilterDefaultWarningMessage[$row->af_group]; + } else { + $warnMsg = 'abusefilter-warning'; + } + + $warnFields['abusefilter-edit-warn-message'] = + $this->getExistingSelector( $warnMsg, !empty( $readOnlyAttrib ) ); + $warnFields['abusefilter-edit-warn-other-label'] = + Xml::input( + 'wpFilterWarnMessageOther', + 45, + $warnMsg, + [ 'id' => 'mw-abusefilter-warn-message-other' ] + $cbReadOnlyAttrib + ); + + $previewButton = Xml::element( + 'input', + [ + 'type' => 'button', + 'id' => 'mw-abusefilter-warn-preview-button', + 'value' => $this->msg( 'abusefilter-edit-warn-preview' )->text() + ] + ); + $editButton = ''; + if ( $this->getUser()->isAllowed( 'editinterface' ) ) { + $editButton .= ' ' . Xml::element( + 'input', + [ + 'type' => 'button', + 'id' => 'mw-abusefilter-warn-edit-button', + 'value' => $this->msg( 'abusefilter-edit-warn-edit' )->text() + ] + ); + } + $previewHolder = Xml::element( + 'div', + [ 'id' => 'mw-abusefilter-warn-preview' ], '' + ); + $warnFields['abusefilter-edit-warn-actions'] = + Xml::tags( 'p', null, $previewButton . $editButton ) . "\n$previewHolder"; + $output .= + Xml::tags( + 'div', + [ 'id' => 'mw-abusefilter-warn-parameters' ], + Xml::buildForm( $warnFields ) + ); + return $output; + case 'tag': + if ( $set ) { + $tags = $parameters; + } else { + $tags = []; + } + $output = ''; + + $checkbox = Xml::checkLabel( + $this->msg( 'abusefilter-edit-action-tag' )->text(), + 'wpFilterActionTag', + "mw-abusefilter-action-checkbox-$action", + $set, + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib + ); + $output .= Xml::tags( 'p', null, $checkbox ); + + $tagFields['abusefilter-edit-tag-tag'] = + Xml::textarea( 'wpFilterTags', implode( "\n", $tags ), 40, 5, $readOnlyAttrib ); + $output .= + Xml::tags( 'div', + [ 'id' => 'mw-abusefilter-tag-parameters' ], + Xml::buildForm( $tagFields ) + ); + return $output; + case 'block': + global $wgBlockAllowsUTEdit, $wgAbuseFilterBlockDuration, + $wgAbuseFilterAnonBlockDuration; + + if ( $set && count( $parameters ) === 3 ) { + // Both blocktalk and custom block durations available + $blockTalk = $parameters[0]; + $defaultAnonDuration = $parameters[1]; + $defaultUserDuration = $parameters[2]; + } else { + if ( $set && count( $parameters ) === 1 ) { + // Only blocktalk available + $blockTalk = $parameters[0]; + } + if ( $wgAbuseFilterAnonBlockDuration ) { + $defaultAnonDuration = $wgAbuseFilterAnonBlockDuration; + } else { + $defaultAnonDuration = $wgAbuseFilterBlockDuration; + } + $defaultUserDuration = $wgAbuseFilterBlockDuration; + } + $suggestedBlocks = SpecialBlock::getSuggestedDurations(); + $suggestedBlocks = self::normalizeBlocks( $suggestedBlocks ); + + $output = ''; + $checkbox = Xml::checkLabel( + $this->msg( 'abusefilter-edit-action-block' )->text(), + 'wpFilterActionBlock', + "mw-abusefilter-action-checkbox-block", + $set, + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib ); + $output .= Xml::tags( 'p', null, $checkbox ); + if ( $wgBlockAllowsUTEdit === true ) { + $talkCheckbox = + Xml::checkLabel( + $this->msg( 'abusefilter-edit-action-blocktalk' )->text(), + 'wpFilterBlockTalk', + 'mw-abusefilter-action-checkbox-blocktalk', + isset( $blockTalk ) && $blockTalk == 'blocktalk', + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib + ); + } + + $anonDuration = new XmlSelect( + 'wpBlockAnonDuration', + false, + 'default' + ); + $anonDuration->addOptions( $suggestedBlocks ); + + $userDuration = new XmlSelect( + 'wpBlockUserDuration', + false, + 'default' + ); + $userDuration->addOptions( $suggestedBlocks ); + + // Set defaults + $anonDuration->setDefault( $defaultAnonDuration ); + $userDuration->setDefault( $defaultUserDuration ); + + if ( !$this->canEditFilter( $row ) ) { + $anonDuration->setAttribute( 'disabled', 'disabled' ); + $userDuration->setAttribute( 'disabled', 'disabled' ); + } + + if ( $wgBlockAllowsUTEdit === true ) { + $durations['abusefilter-edit-block-options'] = $talkCheckbox; + } + $durations['abusefilter-edit-block-anon-durations'] = $anonDuration->getHTML(); + $durations['abusefilter-edit-block-user-durations'] = $userDuration->getHTML(); + + $rawOutput = Xml::buildForm( $durations ); + + $output .= Xml::tags( + 'div', + [ 'id' => 'mw-abusefilter-block-parameters' ], + $rawOutput + ); + + return $output; + + default: + // Give grep a chance to find the usages: + // abusefilter-edit-action-warn, abusefilter-edit-action-disallow + // abusefilter-edit-action-blockautopromote + // abusefilter-edit-action-degroup, abusefilter-edit-action-throttle + // abusefilter-edit-action-rangeblock, abusefilter-edit-action-tag + $message = 'abusefilter-edit-action-' . $action; + $form_field = 'wpFilterAction' . ucfirst( $action ); + $status = $set; + + $thisAction = Xml::checkLabel( + $this->msg( $message )->text(), + $form_field, + "mw-abusefilter-action-checkbox-$action", + $status, + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib + ); + $thisAction = Xml::tags( 'p', null, $thisAction ); + return $thisAction; + } + } + + /** + * @param string $warnMsg + * @param bool $readOnly + * @return string + */ + function getExistingSelector( $warnMsg, $readOnly = false ) { + $existingSelector = new XmlSelect( + 'wpFilterWarnMessage', + 'mw-abusefilter-warn-message-existing', + $warnMsg == 'abusefilter-warning' ? 'abusefilter-warning' : 'other' + ); + + $existingSelector->addOption( 'abusefilter-warning' ); + + if ( $readOnly ) { + $existingSelector->setAttribute( 'disabled', 'disabled' ); + } else { + // Find other messages. + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( + 'page', + [ 'page_title' ], + [ + 'page_namespace' => 8, + 'page_title LIKE ' . $dbr->addQuotes( 'Abusefilter-warning%' ) + ], + __METHOD__ + ); + + $lang = $this->getLanguage(); + foreach ( $res as $row ) { + if ( $lang->lcfirst( $row->page_title ) == $lang->lcfirst( $warnMsg ) ) { + $existingSelector->setDefault( $lang->lcfirst( $warnMsg ) ); + } + + if ( $row->page_title != 'Abusefilter-warning' ) { + $existingSelector->addOption( $lang->lcfirst( $row->page_title ) ); + } + } + } + + $existingSelector->addOption( $this->msg( 'abusefilter-edit-warn-other' )->text(), 'other' ); + + return $existingSelector->getHTML(); + } + + /** + * @ToDo: Maybe we should also check if global values belong to $durations + * and determine the right point to add them if missing. + * + * @param array $durations + * @return array + */ + protected static function normalizeBlocks( $durations ) { + global $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration; + // We need to have same values since it may happen that ipblocklist + // and one (or both) of the global variables use different wording + // for the same duration. In such case, when setting the default of + // the dropdowns it would fail. + foreach ( $durations as &$duration ) { + $currentDuration = SpecialBlock::parseExpiryInput( $duration ); + $anonDuration = SpecialBlock::parseExpiryInput( $wgAbuseFilterAnonBlockDuration ); + $userDuration = SpecialBlock::parseExpiryInput( $wgAbuseFilterBlockDuration ); + + if ( $duration !== $wgAbuseFilterBlockDuration && + $currentDuration === $userDuration ) { + $duration = $wgAbuseFilterBlockDuration; + + } elseif ( $duration !== $wgAbuseFilterAnonBlockDuration && + $currentDuration === $anonDuration ) { + $duration = $wgAbuseFilterAnonBlockDuration; + } + } + + return $durations; + } + + /** + * Loads filter data from the database by ID. + * @param int $id The filter's ID number + * @return array|null Either an associative array representing the filter, + * or NULL if the filter does not exist. + */ + function loadFilterData( $id ) { + if ( $id == 'new' ) { + $obj = new stdClass; + $obj->af_pattern = ''; + $obj->af_enabled = 1; + $obj->af_hidden = 0; + $obj->af_global = 0; + $obj->af_throttled = 0; + return [ $obj, [] ]; + } + + // Load from master to avoid unintended reversions where there's replication lag. + $dbr = $this->getRequest()->wasPosted() + ? wfGetDB( DB_MASTER ) + : wfGetDB( DB_REPLICA ); + + // Load certain fields only. This prevents a condition seen on Wikimedia where + // a schema change adding a new field caused that extra field to be selected. + // Since the selected row may be inserted back into the database, this will cause + // an SQL error if, say, one server has the updated schema but another does not. + $loadFields = [ + 'af_id', + 'af_pattern', + 'af_user', + 'af_user_text', + 'af_timestamp', + 'af_enabled', + 'af_comments', + 'af_public_comments', + 'af_hidden', + 'af_hit_count', + 'af_throttled', + 'af_deleted', + 'af_actions', + 'af_global', + 'af_group', + ]; + + // Load the main row + $row = $dbr->selectRow( 'abuse_filter', $loadFields, [ 'af_id' => $id ], __METHOD__ ); + + if ( !isset( $row ) || !isset( $row->af_id ) || !$row->af_id ) { + return null; + } + + // Load the actions + $actions = []; + $res = $dbr->select( 'abuse_filter_action', + '*', + [ 'afa_filter' => $id ], + __METHOD__ + ); + + foreach ( $res as $actionRow ) { + $thisAction = []; + $thisAction['action'] = $actionRow->afa_consequence; + $thisAction['parameters'] = array_filter( explode( "\n", $actionRow->afa_parameters ) ); + + $actions[$actionRow->afa_consequence] = $thisAction; + } + + return [ $row, $actions ]; + } + + /** + * Load filter data to show in the edit view. + * Either from the HTTP request or from the filter/history_id given. + * The HTTP request always takes precedence. + * Includes caching. + * @param int $filter The filter ID being requested. + * @param int $history_id If any, the history ID being requested. + * @return Array with filter data if available, otherwise null. + * The first element contains the abuse_filter database row, + * the second element is an array of related abuse_filter_action rows. + */ + function loadRequest( $filter, $history_id = null ) { + static $row = null; + static $actions = null; + $request = $this->getRequest(); + + if ( !is_null( $actions ) && !is_null( $row ) ) { + return [ $row, $actions ]; + } elseif ( $request->wasPosted() ) { + # Nothing, we do it all later + } elseif ( $history_id ) { + return $this->loadHistoryItem( $history_id ); + } else { + return $this->loadFilterData( $filter ); + } + + // We need some details like last editor + list( $row, $origActions ) = $this->loadFilterData( $filter ); + + $row->mOriginalRow = clone $row; + $row->mOriginalActions = $origActions; + + // Check for importing + $import = $request->getVal( 'wpImportText' ); + if ( $import ) { + $data = FormatJson::decode( $import ); + + $importRow = $data->row; + $actions = wfObjectToArray( $data->actions ); + + $copy = [ + 'af_public_comments', + 'af_pattern', + 'af_comments', + 'af_deleted', + 'af_enabled', + 'af_hidden', + ]; + + foreach ( $copy as $name ) { + $row->$name = $importRow->$name; + } + } else { + $textLoads = [ + 'af_public_comments' => 'wpFilterDescription', + 'af_pattern' => 'wpFilterRules', + 'af_comments' => 'wpFilterNotes', + ]; + + foreach ( $textLoads as $col => $field ) { + $row->$col = trim( $request->getVal( $field ) ); + } + + $row->af_group = $request->getVal( 'wpFilterGroup', 'default' ); + + $row->af_deleted = $request->getBool( 'wpFilterDeleted' ); + $row->af_enabled = $request->getBool( 'wpFilterEnabled' ) && !$row->af_deleted; + $row->af_hidden = $request->getBool( 'wpFilterHidden' ); + global $wgAbuseFilterIsCentral; + $row->af_global = $request->getBool( 'wpFilterGlobal' ) && $wgAbuseFilterIsCentral; + + // Actions + global $wgAbuseFilterActions; + $actions = []; + foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { + // Check if it's set + $enabled = $request->getBool( 'wpFilterAction' . ucfirst( $action ) ); + + if ( $enabled ) { + $parameters = []; + + if ( $action == 'throttle' ) { + // We need to load the parameters + $throttleCount = $request->getIntOrNull( 'wpFilterThrottleCount' ); + $throttlePeriod = $request->getIntOrNull( 'wpFilterThrottlePeriod' ); + $throttleGroups = explode( "\n", + trim( $request->getText( 'wpFilterThrottleGroups' ) ) ); + + $parameters[0] = $this->mFilter; // For now, anyway + $parameters[1] = "$throttleCount,$throttlePeriod"; + $parameters = array_merge( $parameters, $throttleGroups ); + } elseif ( $action == 'warn' ) { + $specMsg = $request->getVal( 'wpFilterWarnMessage' ); + + if ( $specMsg == 'other' ) { + $specMsg = $request->getVal( 'wpFilterWarnMessageOther' ); + } + + $parameters[0] = $specMsg; + } elseif ( $action == 'block' ) { + $parameters[0] = $request->getCheck( 'wpFilterBlockTalk' ) ? + 'blocktalk' : 'noTalkBlockSet'; + $parameters[1] = $request->getVal( 'wpBlockAnonDuration' ); + $parameters[2] = $request->getVal( 'wpBlockUserDuration' ); + } elseif ( $action == 'tag' ) { + $parameters = explode( "\n", trim( $request->getText( 'wpFilterTags' ) ) ); + } + + $thisAction = [ 'action' => $action, 'parameters' => $parameters ]; + $actions[$action] = $thisAction; + } + } + } + + $row->af_actions = implode( ',', array_keys( array_filter( $actions ) ) ); + + return [ $row, $actions ]; + } + + /** + * Loads historical data in a form that the editor can understand. + * @param int $id History ID + * @return array|bool False if the history ID is not valid, otherwise array in the usual format: + * First element contains the abuse_filter row (as it was). + * Second element contains an array of abuse_filter_action rows. + */ + function loadHistoryItem( $id ) { + $dbr = wfGetDB( DB_REPLICA ); + + // Load the row. + $row = $dbr->selectRow( 'abuse_filter_history', + '*', + [ 'afh_id' => $id ], + __METHOD__ + ); + + if ( !$row ) { + return false; + } + + return AbuseFilter::translateFromHistory( $row ); + } + + /** + * @return null + */ + protected function exposeWarningMessages() { + global $wgOut, $wgAbuseFilterDefaultWarningMessage; + $wgOut->addJsConfigVars( + 'wgAbuseFilterDefaultWarningMessage', + $wgAbuseFilterDefaultWarningMessage + ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewExamine.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewExamine.php new file mode 100644 index 00000000..73cb2d10 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewExamine.php @@ -0,0 +1,219 @@ +<?php + +class AbuseFilterViewExamine extends AbuseFilterView { + public static $examineType = null; + public static $examineId = null; + + public $mCounter, $mSearchUser, $mSearchPeriodStart, $mSearchPeriodEnd; + public $mTestFilter; + + function show() { + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'abusefilter-examine' ) ); + $out->addWikiMsg( 'abusefilter-examine-intro' ); + + $this->loadParameters(); + + // Check if we've got a subpage + if ( count( $this->mParams ) > 1 && is_numeric( $this->mParams[1] ) ) { + $this->showExaminerForRC( $this->mParams[1] ); + } elseif ( count( $this->mParams ) > 2 + && $this->mParams[1] == 'log' + && is_numeric( $this->mParams[2] ) + ) { + $this->showExaminerForLogEntry( $this->mParams[2] ); + } else { + $this->showSearch(); + } + } + + function showSearch() { + $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' ); + $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge ); + $max = wfTimestampNow(); + $formDescriptor = [ + 'SearchUser' => [ + 'label-message' => 'abusefilter-test-user', + 'type' => 'user', + 'default' => $this->mSearchUser, + ], + 'SearchPeriodStart' => [ + 'label-message' => 'abusefilter-test-period-start', + 'type' => 'datetime', + 'default' => $this->mSearchPeriodStart, + 'min' => $min, + 'max' => $max, + ], + 'SearchPeriodEnd' => [ + 'label-message' => 'abusefilter-test-period-end', + 'type' => 'datetime', + 'default' => $this->mSearchPeriodEnd, + 'min' => $min, + 'max' => $max, + ], + ]; + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm->setWrapperLegendMsg( 'abusefilter-examine-legend' ) + ->addHiddenField( 'submit', 1 ) + ->setSubmitTextMsg( 'abusefilter-examine-submit' ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + + if ( $this->mSubmit ) { + $this->showResults(); + } + } + + function showResults() { + $changesList = new AbuseFilterChangesList( $this->getSkin(), $this->mTestFilter ); + $output = $changesList->beginRecentChangesList(); + $this->mCounter = 1; + + $pager = new AbuseFilterExaminePager( $this, $changesList ); + + $output .= $pager->getNavigationBar() . + $pager->getBody() . + $pager->getNavigationBar(); + + $output .= $changesList->endRecentChangesList(); + + $this->getOutput()->addHTML( $output ); + } + + function showExaminerForRC( $rcid ) { + // Get data + $dbr = wfGetDB( DB_REPLICA ); + $rcQuery = RecentChange::getQueryInfo(); + $row = $dbr->selectRow( + $rcQuery['tables'], + $rcQuery['fields'], + [ 'rc_id' => $rcid ], + __METHOD__, + [], + $rcQuery['joins'] + ); + $out = $this->getOutput(); + if ( !$row ) { + $out->addWikiMsg( 'abusefilter-examine-notfound' ); + return; + } + + if ( !ChangesList::userCan( RecentChange::newFromRow( $row ), Revision::SUPPRESSED_ALL ) ) { + $out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' ); + return; + } + + self::$examineType = 'rc'; + self::$examineId = $rcid; + + $vars = AbuseFilter::getVarsFromRCRow( $row ); + $out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) ); + $this->showExaminer( $vars ); + } + + function showExaminerForLogEntry( $logid ) { + // Get data + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + 'abuse_filter_log', + [ 'afl_filter', 'afl_deleted', 'afl_var_dump' ], + [ 'afl_id' => $logid ], + __METHOD__ + ); + $out = $this->getOutput(); + + if ( !$row ) { + $out->addWikiMsg( 'abusefilter-examine-notfound' ); + return; + } + + self::$examineType = 'log'; + self::$examineId = $logid; + + if ( !SpecialAbuseLog::canSeeDetails( $row->afl_filter ) ) { + $out->addWikiMsg( 'abusefilter-log-cannot-see-details' ); + return; + } + + if ( $row->afl_deleted && !SpecialAbuseLog::canSeeHidden() ) { + $out->addWikiMsg( 'abusefilter-log-details-hidden' ); + return; + } + + if ( SpecialAbuseLog::isHidden( $row ) === 'implicit' ) { + $rev = Revision::newFromId( $row->afl_rev_id ); + if ( !$rev->userCan( Revision::SUPPRESSED_ALL, $this->getUser() ) ) { + $out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' ); + return; + } + } + $vars = AbuseFilter::loadVarDump( $row->afl_var_dump ); + $out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) ); + $this->showExaminer( $vars ); + } + + function showExaminer( $vars ) { + $output = $this->getOutput(); + $output->enableOOUI(); + + if ( !$vars ) { + $output->addWikiMsg( 'abusefilter-examine-incompatible' ); + return; + } + + if ( $vars instanceof AbuseFilterVariableHolder ) { + $vars = $vars->exportAllVars(); + } + + $html = ''; + + $output->addModules( 'ext.abuseFilter.examine' ); + + // Add test bit + if ( $this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $tester = Xml::tags( 'h2', null, $this->msg( 'abusefilter-examine-test' )->parse() ); + $tester .= AbuseFilter::buildEditBox( $this->mTestFilter, 'wpTestFilter', false ); + $tester .= AbuseFilter::buildFilterLoader(); + $html .= Xml::tags( 'div', [ 'id' => 'mw-abusefilter-examine-editor' ], $tester ); + $html .= Xml::tags( 'p', + null, + new OOUI\ButtonInputWidget( + [ + 'label' => $this->msg( 'abusefilter-examine-test-button' )->text(), + 'id' => 'mw-abusefilter-examine-test' + ] + ) . + Xml::element( 'div', + [ + 'id' => 'mw-abusefilter-syntaxresult', + 'style' => 'display: none;' + ], ' ' + ) + ); + } + + // Variable dump + $html .= Xml::tags( + 'h2', + null, + $this->msg( 'abusefilter-examine-vars' )->parse() + ); + $html .= AbuseFilter::buildVarDumpTable( $vars, $this->getContext() ); + + $output->addHTML( $html ); + } + + function loadParameters() { + $request = $this->getRequest(); + $this->mSearchPeriodStart = $request->getText( 'wpSearchPeriodStart' ); + $this->mSearchPeriodEnd = $request->getText( 'wpSearchPeriodEnd' ); + $this->mSubmit = $request->getCheck( 'submit' ); + $this->mTestFilter = $request->getText( 'testfilter' ); + + // Normalise username + $searchUsername = $request->getText( 'wpSearchUser' ); + $userTitle = Title::newFromText( $searchUsername, NS_USER ); + $this->mSearchUser = $userTitle ? $userTitle->getText() : ''; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewHistory.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewHistory.php new file mode 100644 index 00000000..f98241c0 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewHistory.php @@ -0,0 +1,85 @@ +<?php + +class AbuseFilterViewHistory extends AbuseFilterView { + function __construct( $page, $params ) { + parent::__construct( $page, $params ); + $this->mFilter = $page->mFilter; + } + + function show() { + $out = $this->getOutput(); + $filter = $this->getRequest()->getText( 'filter' ) ?: $this->mFilter; + + if ( $filter ) { + $out->setPageTitle( $this->msg( 'abusefilter-history' )->numParams( $filter ) ); + } else { + $out->setPageTitle( $this->msg( 'abusefilter-filter-log' ) ); + } + + # Check perms. abusefilter-modify is a superset of abusefilter-view-private + if ( $filter && AbuseFilter::filterHidden( $filter ) + && !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) + ) { + $out->addWikiMsg( 'abusefilter-history-error-hidden' ); + return; + } + + # Useful links + $links = []; + if ( $filter ) { + $links['abusefilter-history-backedit'] = $this->getTitle( $filter ); + } + + foreach ( $links as $msg => $title ) { + $links[$msg] = $this->linkRenderer->makeLink( + $title, + new HtmlArmor( $this->msg( $msg )->parse() ) + ); + } + + $backlinks = $this->getLanguage()->pipeList( $links ); + $out->addHTML( Xml::tags( 'p', null, $backlinks ) ); + + # For user + $user = User::getCanonicalName( $this->getRequest()->getText( 'user' ), 'valid' ); + if ( $user ) { + $out->addSubtitle( + $this->msg( + 'abusefilter-history-foruser', + Linker::userLink( 1 /* We don't really need to get a user ID */, $user ), + $user // For GENDER + )->text() + ); + } + + $formDescriptor = [ + 'user' => [ + 'type' => 'user', + 'name' => 'user', + 'default' => $user, + 'size' => '45', + 'label-message' => 'abusefilter-history-select-user' + ], + 'filter' => [ + 'type' => 'text', + 'name' => 'filter', + 'default' => $filter, + 'size' => '45', + 'label-message' => 'abusefilter-history-select-filter' + ], + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm->setSubmitTextMsg( 'abusefilter-history-select-submit' ) + ->setWrapperLegendMsg( 'abusefilter-history-select-legend' ) + ->setAction( $this->getTitle( 'history' )->getLocalURL() ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + + $pager = new AbuseFilterHistoryPager( $filter, $this, $user, $this->linkRenderer ); + $table = $pager->getBody(); + + $out->addHTML( $pager->getNavigationBar() . $table . $pager->getNavigationBar() ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewImport.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewImport.php new file mode 100644 index 00000000..6bd4c269 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewImport.php @@ -0,0 +1,24 @@ +<?php + +class AbuseFilterViewImport extends AbuseFilterView { + function show() { + $out = $this->getOutput(); + if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $out->addWikiMsg( 'abusefilter-edit-notallowed' ); + return; + } + $url = SpecialPage::getTitleFor( 'AbuseFilter', 'new' )->getFullURL(); + + $out->addWikiMsg( 'abusefilter-import-intro' ); + + $formDescriptor = [ + 'ImportText' => [ + 'type' => 'textarea', + ] + ]; + $htmlform = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + ->setSubmitTextMsg( 'abusefilter-import-submit' ) + ->setAction( $url ) + ->show(); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewList.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewList.php new file mode 100644 index 00000000..715fa536 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewList.php @@ -0,0 +1,267 @@ +<?php + +/** + * The default view used in Special:AbuseFilter + */ +class AbuseFilterViewList extends AbuseFilterView { + function show() { + global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; + + $out = $this->getOutput(); + $request = $this->getRequest(); + + // Status info... + $this->showStatus(); + + $out->addWikiMsg( 'abusefilter-intro' ); + + // New filter button + if ( $this->canEdit() ) { + $out->enableOOUI(); + $link = new OOUI\ButtonWidget( [ + 'label' => $this->msg( 'abusefilter-new' )->text(), + 'href' => $this->getTitle( 'new' )->getFullURL(), + ] ); + $out->addHTML( $link ); + } + + // Options. + $conds = []; + $deleted = $request->getVal( 'deletedfilters' ); + $hidedisabled = $request->getBool( 'hidedisabled' ); + $defaultscope = 'all'; + if ( isset( $wgAbuseFilterCentralDB ) && !$wgAbuseFilterIsCentral ) { + // Show on remote wikis as default only local filters + $defaultscope = 'local'; + } + $scope = $request->getVal( 'rulescope', $defaultscope ); + + $searchEnabled = $this->canViewPrivate() && !( isset( $wgAbuseFilterCentralDB ) && + !$wgAbuseFilterIsCentral && $scope == 'global' ); + + if ( $searchEnabled ) { + $querypattern = $request->getVal( 'querypattern' ); + $searchmode = $request->getVal( 'searchoption', 'LIKE' ); + } else { + $querypattern = ''; + $searchmode = ''; + } + + if ( $deleted == 'show' ) { + # Nothing + } elseif ( $deleted == 'only' ) { + $conds['af_deleted'] = 1; + } else { # hide, or anything else. + $conds['af_deleted'] = 0; + $deleted = 'hide'; + } + if ( $hidedisabled ) { + $conds['af_deleted'] = 0; + $conds['af_enabled'] = 1; + } + + if ( $scope == 'local' ) { + $conds['af_global'] = 0; + } elseif ( $scope == 'global' ) { + $conds['af_global'] = 1; + } + + $dbr = wfGetDB( DB_REPLICA ); + + if ( $querypattern !== '' ) { + if ( $searchmode !== 'LIKE' ) { + // Check regex pattern validity + Wikimedia\suppressWarnings(); + $validreg = preg_match( '/' . $querypattern . '/', null ); + Wikimedia\restoreWarnings(); + + if ( $validreg === false ) { + $out->wrapWikiMsg( + '<div class="errorbox">$1</div>', + 'abusefilter-list-regexerror' + ); + $this->showList( + [ 'af_deleted' => 0 ], + compact( 'deleted', 'hidedisabled', 'querypattern', 'searchmode', 'scope', 'searchEnabled' ) + ); + return; + } + if ( $searchmode === 'RLIKE' ) { + $conds[] = 'af_pattern RLIKE ' . + $dbr->addQuotes( $querypattern ); + } else { + $conds[] = 'LOWER( CAST( af_pattern AS char ) ) RLIKE ' . + strtolower( $dbr->addQuotes( $querypattern ) ); + } + } else { + // Build like query escaping tokens and encapsulating in % to search everywhere + $conds[] = 'LOWER( CAST( af_pattern AS char ) ) ' . + $dbr->buildLike( + $dbr->anyString(), + strtolower( $querypattern ), + $dbr->anyString() + ); + } + } + + $this->showList( + $conds, + compact( 'deleted', 'hidedisabled', 'querypattern', 'searchmode', 'scope', 'searchEnabled' ) + ); + } + + function showList( $conds = [ 'af_deleted' => 0 ], $optarray = [] ) { + global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; + + $this->getOutput()->addHTML( + Xml::element( 'h2', null, $this->msg( 'abusefilter-list' )->parse() ) + ); + + $deleted = $optarray['deleted']; + $hidedisabled = $optarray['hidedisabled']; + $scope = $optarray['scope']; + $searchEnabled = $optarray['searchEnabled']; + $querypattern = $optarray['querypattern']; + $searchmode = $optarray['searchmode']; + + if ( isset( $wgAbuseFilterCentralDB ) && !$wgAbuseFilterIsCentral && $scope == 'global' ) { + $pager = new GlobalAbuseFilterPager( + $this, + $conds, + $this->linkRenderer + ); + } else { + $pager = new AbuseFilterPager( + $this, + $conds, + $this->linkRenderer, + [ $querypattern, $searchmode ] + ); + } + + # Options form + $formDescriptor = []; + $formDescriptor['deletedfilters'] = [ + 'name' => 'deletedfilters', + 'type' => 'radio', + 'flatlist' => true, + 'label-message' => 'abusefilter-list-options-deleted', + 'options-messages' => [ + 'abusefilter-list-options-deleted-show' => 'show', + 'abusefilter-list-options-deleted-hide' => 'hide', + 'abusefilter-list-options-deleted-only' => 'only', + ], + 'default' => $deleted, + ]; + + if ( isset( $wgAbuseFilterCentralDB ) ) { + $optionsMsg = [ + 'abusefilter-list-options-scope-local' => 'local', + 'abusefilter-list-options-scope-global' => 'global', + ]; + if ( $wgAbuseFilterIsCentral ) { + // For central wiki: add third scope option + $optionsMsg['abusefilter-list-options-scope-all'] = 'all'; + } + $formDescriptor['rulescope'] = [ + 'name' => 'rulescope', + 'type' => 'radio', + 'flatlist' => true, + 'label-message' => 'abusefilter-list-options-scope', + 'options-messages' => $optionsMsg, + 'default' => $scope, + ]; + } + + $formDescriptor['info'] = [ + 'type' => 'info', + 'default' => $this->msg( 'abusefilter-list-options-disabled' )->parse(), + ]; + + $formDescriptor['hidedisabled'] = [ + 'name' => 'hidedisabled', + 'type' => 'check', + 'label-message' => 'abusefilter-list-options-hidedisabled', + 'selected' => $hidedisabled, + ]; + + // ToDo: Since this is only for saving space, we should convert it + // to use a 'hide-if' + if ( $searchEnabled ) { + $formDescriptor['querypattern'] = [ + 'name' => 'querypattern', + 'type' => 'text', + 'label-message' => 'abusefilter-list-options-searchfield', + 'placeholder' => $this->msg( 'abusefilter-list-options-searchpattern' )->text(), + 'default' => $querypattern + ]; + + $formDescriptor['searchoption'] = [ + 'name' => 'searchoption', + 'type' => 'radio', + 'flatlist' => true, + 'label-message' => 'abusefilter-list-options-searchoptions', + 'options-messages' => [ + 'abusefilter-list-options-search-like' => 'LIKE', + 'abusefilter-list-options-search-rlike' => 'RLIKE', + 'abusefilter-list-options-search-irlike' => 'IRLIKE', + ], + 'default' => $searchmode + ]; + } + + $formDescriptor['limit'] = [ + 'name' => 'limit', + 'type' => 'select', + 'label-message' => 'abusefilter-list-limit', + 'options' => $pager->getLimitSelectList(), + 'default' => $pager->getLimit(), + ]; + + HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + ->addHiddenField( 'title', $this->getTitle()->getPrefixedDBkey() ) + ->setAction( $this->getTitle()->getFullURL() ) + ->setWrapperLegendMsg( 'abusefilter-list-options' ) + ->setSubmitTextMsg( 'abusefilter-list-options-submit' ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + + $output = + $pager->getNavigationBar() . + $pager->getBody() . + $pager->getNavigationBar(); + + $this->getOutput()->addHTML( $output ); + } + + function showStatus() { + global $wgAbuseFilterConditionLimit, $wgAbuseFilterValidGroups; + + $stash = ObjectCache::getMainStashInstance(); + $overflow_count = (int)$stash->get( AbuseFilter::filterLimitReachedKey() ); + $match_count = (int)$stash->get( AbuseFilter::filterMatchesKey() ); + $total_count = 0; + foreach ( $wgAbuseFilterValidGroups as $group ) { + $total_count += (int)$stash->get( AbuseFilter::filterUsedKey( $group ) ); + } + + if ( $total_count > 0 ) { + $overflow_percent = sprintf( "%.2f", 100 * $overflow_count / $total_count ); + $match_percent = sprintf( "%.2f", 100 * $match_count / $total_count ); + + $status = $this->msg( 'abusefilter-status' ) + ->numParams( + $total_count, + $overflow_count, + $overflow_percent, + $wgAbuseFilterConditionLimit, + $match_count, + $match_percent + )->parse(); + + $status = Xml::tags( 'div', [ 'class' => 'mw-abusefilter-status' ], $status ); + $this->getOutput()->addHTML( $status ); + } + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewRevert.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewRevert.php new file mode 100644 index 00000000..ef3773d7 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewRevert.php @@ -0,0 +1,299 @@ +<?php + +class AbuseFilterViewRevert extends AbuseFilterView { + public $origPeriodStart, $origPeriodEnd, $mPeriodStart, $mPeriodEnd; + public $mReason; + + function show() { + $lang = $this->getLanguage(); + $filter = $this->mPage->mFilter; + + $user = $this->getUser(); + $out = $this->getOutput(); + + if ( !$user->isAllowed( 'abusefilter-revert' ) ) { + throw new PermissionsError( 'abusefilter-revert' ); + } + + $this->loadParameters(); + + if ( $this->attemptRevert() ) { + return; + } + + $out->addWikiMsg( 'abusefilter-revert-intro', Message::numParam( $filter ) ); + $out->setPageTitle( $this->msg( 'abusefilter-revert-title' )->numParams( $filter ) ); + + // First, the search form. Limit dates to avoid huge queries + $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' ); + $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge ); + $max = wfTimestampNow(); + $filterLink = + $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', intval( $filter ) ), + $lang->formatNum( intval( $filter ) ) + ); + $searchFields = []; + $searchFields['filterid'] = [ + 'type' => 'info', + 'default' => $filterLink, + 'raw' => true, + 'label-message' => 'abusefilter-revert-filter' + ]; + $searchFields['periodstart'] = [ + 'type' => 'datetime', + 'name' => 'wpPeriodStart', + 'default' => $this->origPeriodStart, + 'label-message' => 'abusefilter-revert-periodstart', + 'min' => $min, + 'max' => $max + ]; + $searchFields['periodend'] = [ + 'type' => 'datetime', + 'name' => 'wpPeriodEnd', + 'default' => $this->origPeriodEnd, + 'label-message' => 'abusefilter-revert-periodend', + 'min' => $min, + 'max' => $max + ]; + + HTMLForm::factory( 'ooui', $searchFields, $this->getContext() ) + ->addHiddenField( 'submit', 1 ) + ->setAction( $this->getTitle( "revert/$filter" )->getLocalURL() ) + ->setWrapperLegendMsg( 'abusefilter-revert-search-legend' ) + ->setSubmitTextMsg( 'abusefilter-revert-search' ) + ->setMethod( 'post' ) + ->prepareForm() + ->displayForm( false ); + + if ( $this->mSubmit ) { + // Add a summary of everything that will be reversed. + $out->addWikiMsg( 'abusefilter-revert-preview-intro' ); + + // Look up all of them. + $results = $this->doLookup(); + $list = []; + + foreach ( $results as $result ) { + $displayActions = array_map( + [ 'AbuseFilter', 'getActionDisplay' ], + $result['actions'] ); + + $msg = $this->msg( 'abusefilter-revert-preview-item' ) + ->rawParams( + $lang->timeanddate( $result['timestamp'], true ), + Linker::userLink( $result['userid'], $result['user'] ), + $result['action'], + $this->linkRenderer->makeLink( $result['title'] ), + $lang->commaList( $displayActions ), + $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseLog' ), + $this->msg( 'abusefilter-log-detailslink' )->text(), + [], + [ 'details' => $result['id'] ] + ) + )->params( $result['user'] )->parse(); + $list[] = Xml::tags( 'li', null, $msg ); + } + + $out->addHTML( Xml::tags( 'ul', null, implode( "\n", $list ) ) ); + + // Add a button down the bottom. + $confirmForm = []; + $confirmForm['edittoken'] = [ + 'type' => 'hidden', + 'name' => 'editToken', + 'default' => $user->getEditToken( "abusefilter-revert-$filter" ) + ]; + $confirmForm['title'] = [ + 'type' => 'hidden', + 'name' => 'title', + 'default' => $this->getTitle( "revert/$filter" )->getPrefixedDBkey() + ]; + $confirmForm['wpPeriodStart'] = [ + 'type' => 'hidden', + 'name' => 'wpPeriodStart', + 'default' => $this->origPeriodStart + ]; + $confirmForm['wpPeriodEnd'] = [ + 'type' => 'hidden', + 'name' => 'wpPeriodEnd', + 'default' => $this->origPeriodEnd + ]; + $confirmForm['reason'] = [ + 'type' => 'text', + 'label-message' => 'abusefilter-revert-reasonfield', + 'name' => 'wpReason', + 'id' => 'wpReason', + ]; + HTMLForm::factory( 'ooui', $confirmForm, $this->getContext() ) + ->setAction( $this->getTitle( "revert/$filter" )->getLocalURL() ) + ->setWrapperLegendMsg( 'abusefilter-revert-confirm-legend' ) + ->setSubmitTextMsg( 'abusefilter-revert-confirm' ) + ->setMethod( 'post' ) + ->prepareForm() + ->displayForm( false ); + + } + } + + function doLookup() { + $periodStart = $this->mPeriodStart; + $periodEnd = $this->mPeriodEnd; + $filter = $this->mPage->mFilter; + + $conds = [ 'afl_filter' => $filter ]; + + $dbr = wfGetDB( DB_REPLICA ); + + if ( $periodStart ) { + $conds[] = 'afl_timestamp>' . $dbr->addQuotes( $dbr->timestamp( $periodStart ) ); + } + if ( $periodEnd ) { + $conds[] = 'afl_timestamp<' . $dbr->addQuotes( $dbr->timestamp( $periodEnd ) ); + } + + // Database query. + $res = $dbr->select( 'abuse_filter_log', '*', $conds, __METHOD__ ); + + $results = []; + foreach ( $res as $row ) { + // Don't revert if there was no action, or the action was global + if ( !$row->afl_actions || $row->afl_wiki != null ) { + continue; + } + + $actions = explode( ',', $row->afl_actions ); + $reversibleActions = [ 'block', 'blockautopromote', 'degroup' ]; + $currentReversibleActions = array_intersect( $actions, $reversibleActions ); + if ( count( $currentReversibleActions ) ) { + $results[] = [ + 'id' => $row->afl_id, + 'actions' => $currentReversibleActions, + 'user' => $row->afl_user_text, + 'userid' => $row->afl_user, + 'vars' => AbuseFilter::loadVarDump( $row->afl_var_dump ), + 'title' => Title::makeTitle( $row->afl_namespace, $row->afl_title ), + 'action' => $row->afl_action, + 'timestamp' => $row->afl_timestamp + ]; + } + } + + return $results; + } + + function loadParameters() { + $request = $this->getRequest(); + + $this->origPeriodStart = $request->getText( 'wpPeriodStart' ); + $this->mPeriodStart = strtotime( $this->origPeriodStart ); + $this->origPeriodEnd = $request->getText( 'wpPeriodEnd' ); + $this->mPeriodEnd = strtotime( $this->origPeriodEnd ); + $this->mSubmit = $request->getVal( 'submit' ); + $this->mReason = $request->getVal( 'wpReason' ); + } + + function attemptRevert() { + $filter = $this->mPage->mFilter; + $token = $this->getRequest()->getVal( 'editToken' ); + if ( !$this->getUser()->matchEditToken( $token, "abusefilter-revert-$filter" ) ) { + return false; + } + + $results = $this->doLookup(); + foreach ( $results as $result ) { + $actions = $result['actions']; + foreach ( $actions as $action ) { + $this->revertAction( $action, $result ); + } + } + $this->getOutput()->wrapWikiMsg( + '<p class="success">$1</p>', + [ + 'abusefilter-revert-success', + $filter, + $this->getLanguage()->formatNum( $filter ) + ] + ); + + return true; + } + + /** + * @param string $action + * @param array $result + * @return bool + * @throws MWException + */ + function revertAction( $action, $result ) { + switch ( $action ) { + case 'block': + $block = Block::newFromTarget( $result['user'] ); + if ( !( $block && $block->getBy() == AbuseFilter::getFilterUser()->getId() ) ) { + // Not blocked by abuse filter + return false; + } + $block->delete(); + $logEntry = new ManualLogEntry( 'block', 'unblock' ); + $logEntry->setTarget( Title::makeTitle( NS_USER, $result['user'] ) ); + $logEntry->setComment( + $this->msg( + 'abusefilter-revert-reason', $this->mPage->mFilter, $this->mReason + )->inContentLanguage()->text() + ); + $logEntry->setPerformer( $this->getUser() ); + $logEntry->publish( $logEntry->insert() ); + return true; + case 'blockautopromote': + ObjectCache::getMainStashInstance()->delete( + AbuseFilter::autoPromoteBlockKey( User::newFromId( $result['userid'] ) ) + ); + return true; + case 'degroup': + // Pull the user's groups from the vars. + $oldGroups = $result['vars']['USER_GROUPS']; + $oldGroups = explode( ',', $oldGroups ); + $oldGroups = array_diff( + $oldGroups, + array_intersect( $oldGroups, User::getImplicitGroups() ) + ); + + $rows = []; + foreach ( $oldGroups as $group ) { + $rows[] = [ + 'ug_user' => $result['userid'], + 'ug_group' => $group + ]; + } + + // Cheat a little bit. User::addGroup repeatedly is too slow. + $user = User::newFromId( $result['userid'] ); + $currentGroups = $user->getGroups(); + $newGroups = array_merge( $oldGroups, $currentGroups ); + + // Don't do anything if there are no groups to add. + if ( !count( array_diff( $newGroups, $currentGroups ) ) ) { + return false; + } + + $dbw = wfGetDB( DB_MASTER ); + $dbw->insert( 'user_groups', $rows, __METHOD__, [ 'IGNORE' ] ); + $user->invalidateCache(); + + $log = new LogPage( 'rights' ); + $log->addEntry( 'rights', $user->getUserPage(), + $this->msg( + 'abusefilter-revert-reason', + $this->mPage->mFilter, + $this->mReason + )->inContentLanguage()->text(), + [ implode( ',', $currentGroups ), implode( ',', $newGroups ) ] + ); + + return true; + } + + throw new MWException( 'Invalid action' . $action ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php new file mode 100644 index 00000000..47c4be84 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php @@ -0,0 +1,207 @@ +<?php + +class AbuseFilterViewTestBatch extends AbuseFilterView { + // Hard-coded for now. + protected static $mChangeLimit = 100; + + public $mShowNegative, $mTestPeriodStart, $mTestPeriodEnd, $mTestPage; + public $mTestUser; + + function show() { + $out = $this->getOutput(); + + AbuseFilter::disableConditionLimit(); + + if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $out->addWikiMsg( 'abusefilter-mustbeeditor' ); + return; + } + + $this->loadParameters(); + + $out->setPageTitle( $this->msg( 'abusefilter-test' ) ); + $out->addWikiMsg( 'abusefilter-test-intro', self::$mChangeLimit ); + $out->enableOOUI(); + + $output = ''; + $output .= + AbuseFilter::buildEditBox( + $this->mFilter, + 'wpTestFilter', + true, + true, + true + ) . "\n"; + + $output .= AbuseFilter::buildFilterLoader(); + $output = Xml::tags( 'div', [ 'id' => 'mw-abusefilter-test-editor' ], $output ); + + $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' ); + $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge ); + $max = wfTimestampNow(); + + // Search form + $formFields = []; + $formFields['wpTestUser'] = [ + 'name' => 'wpTestUser', + 'type' => 'user', + 'ipallowed' => true, + 'label-message' => 'abusefilter-test-user', + 'default' => $this->mTestUser + ]; + $formFields['wpTestPeriodStart'] = [ + 'name' => 'wpTestPeriodStart', + 'type' => 'datetime', + 'label-message' => 'abusefilter-test-period-start', + 'default' => $this->mTestPeriodStart, + 'min' => $min, + 'max' => $max + ]; + $formFields['wpTestPeriodEnd'] = [ + 'name' => 'wpTestPeriodEnd', + 'type' => 'datetime', + 'label-message' => 'abusefilter-test-period-end', + 'default' => $this->mTestPeriodEnd, + 'min' => $min, + 'max' => $max + ]; + $formFields['wpTestPage'] = [ + 'name' => 'wpTestPage', + 'type' => 'title', + 'label-message' => 'abusefilter-test-page', + 'default' => $this->mTestPage, + 'creatable' => true + ]; + $formFields['wpShowNegative'] = [ + 'name' => 'wpShowNegative', + 'type' => 'check', + 'label-message' => 'abusefilter-test-shownegative', + 'selected' => $this->mShowNegative + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formFields, $this->getContext() ) + ->addHiddenField( 'title', $this->getTitle( 'test' )->getPrefixedDBkey() ) + ->setId( 'wpFilterForm' ) + ->setWrapperLegendMsg( 'abusefilter-list-options' ) + ->setAction( $this->getTitle( 'test' )->getLocalURL() ) + ->setSubmitTextMsg( 'abusefilter-test-submit' ) + ->setMethod( 'post' ) + ->prepareForm(); + $htmlForm = $htmlForm->getHTML( $htmlForm ); + + $output = Xml::fieldset( $this->msg( 'abusefilter-test-legend' )->text(), $output . $htmlForm ); + $out->addHTML( $output ); + + if ( $this->getRequest()->wasPosted() ) { + $this->doTest(); + } + } + + /** + * @fixme this is similar to AbuseFilterExaminePager::getQueryInfo + */ + function doTest() { + // Quick syntax check. + $out = $this->getOutput(); + $result = AbuseFilter::checkSyntax( $this->mFilter ); + if ( $result !== true ) { + $out->addWikiMsg( 'abusefilter-test-syntaxerr' ); + return; + } + $dbr = wfGetDB( DB_REPLICA ); + + $conds = []; + + if ( (string)$this->mTestUser !== '' ) { + $conds[] = ActorMigration::newMigration()->getWhere( + $dbr, 'rc_user', User::newFromName( $this->mTestUser, false ) + )['conds']; + } + + if ( $this->mTestPeriodStart ) { + $conds[] = 'rc_timestamp >= ' . + $dbr->addQuotes( $dbr->timestamp( strtotime( $this->mTestPeriodStart ) ) ); + } + if ( $this->mTestPeriodEnd ) { + $conds[] = 'rc_timestamp <= ' . + $dbr->addQuotes( $dbr->timestamp( strtotime( $this->mTestPeriodEnd ) ) ); + } + if ( $this->mTestPage ) { + $title = Title::newFromText( $this->mTestPage ); + if ( $title instanceof Title ) { + $conds['rc_namespace'] = $title->getNamespace(); + $conds['rc_title'] = $title->getDBkey(); + } else { + $out->addWikiMsg( 'abusefilter-test-badtitle' ); + return; + } + } + + $conds[] = $this->buildTestConditions( $dbr ); + + // Get our ChangesList + $changesList = new AbuseFilterChangesList( $this->getSkin(), $this->mFilter ); + $output = $changesList->beginRecentChangesList(); + + $rcQuery = RecentChange::getQueryInfo(); + $res = $dbr->select( + $rcQuery['tables'], + $rcQuery['fields'], + array_filter( $conds ), + __METHOD__, + [ 'LIMIT' => self::$mChangeLimit, 'ORDER BY' => 'rc_timestamp desc' ], + $rcQuery['joins'] + ); + + $counter = 1; + + foreach ( $res as $row ) { + $vars = AbuseFilter::getVarsFromRCRow( $row ); + + if ( !$vars ) { + continue; + } + + $result = AbuseFilter::checkConditions( $this->mFilter, $vars ); + + if ( $result || $this->mShowNegative ) { + // Stash result in RC item + $rc = RecentChange::newFromRow( $row ); + $rc->filterResult = $result; + $rc->counter = $counter++; + $output .= $changesList->recentChangesLine( $rc, false ); + } + } + + $output .= $changesList->endRecentChangesList(); + + $out->addHTML( $output ); + } + + function loadParameters() { + $request = $this->getRequest(); + + $this->mFilter = $request->getText( 'wpTestFilter' ); + $this->mShowNegative = $request->getBool( 'wpShowNegative' ); + $testUsername = $request->getText( 'wpTestUser' ); + $this->mTestPeriodEnd = $request->getText( 'wpTestPeriodEnd' ); + $this->mTestPeriodStart = $request->getText( 'wpTestPeriodStart' ); + $this->mTestPage = $request->getText( 'wpTestPage' ); + + if ( !$this->mFilter + && count( $this->mParams ) > 1 + && is_numeric( $this->mParams[1] ) + ) { + $dbr = wfGetDB( DB_REPLICA ); + $this->mFilter = $dbr->selectField( 'abuse_filter', + 'af_pattern', + [ 'af_id' => $this->mParams[1] ], + __METHOD__ + ); + } + + // Normalise username + $userTitle = Title::newFromText( $testUsername, NS_USER ); + $this->mTestUser = $userTitle ? $userTitle->getText() : null; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTools.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTools.php new file mode 100644 index 00000000..cff4b22f --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTools.php @@ -0,0 +1,56 @@ +<?php + +class AbuseFilterViewTools extends AbuseFilterView { + function show() { + $out = $this->getOutput(); + $user = $this->getUser(); + $request = $this->getRequest(); + + if ( !$user->isAllowed( 'abusefilter-modify' ) ) { + $out->addWikiMsg( 'abusefilter-mustbeeditor' ); + return; + } + + // Header + $out->addWikiMsg( 'abusefilter-tools-text' ); + + // Expression evaluator + $eval = ''; + $eval .= AbuseFilter::buildEditBox( $request->getText( 'wpTestExpr' ), 'wpTestExpr' ); + + $eval .= Xml::tags( 'p', null, + Xml::element( 'input', + [ + 'type' => 'button', + 'id' => 'mw-abusefilter-submitexpr', + 'value' => $this->msg( 'abusefilter-tools-submitexpr' )->text() ] + ) + ); + $eval .= Xml::element( 'p', [ 'id' => 'mw-abusefilter-expr-result' ], ' ' ); + + $eval = Xml::fieldset( $this->msg( 'abusefilter-tools-expr' )->text(), $eval ); + $out->addHTML( $eval ); + + $out->addModules( 'ext.abuseFilter.tools' ); + + // Hacky little box to re-enable autoconfirmed if it got disabled + $rac = ''; + $rac .= Xml::inputLabel( + $this->msg( 'abusefilter-tools-reautoconfirm-user' )->text(), + 'wpReAutoconfirmUser', + 'reautoconfirm-user', + 45 + ); + $rac .= ' '; + $rac .= Xml::element( + 'input', + [ + 'type' => 'button', + 'id' => 'mw-abusefilter-reautoconfirmsubmit', + 'value' => $this->msg( 'abusefilter-tools-reautoconfirm-submit' )->text() + ] + ); + $rac = Xml::fieldset( $this->msg( 'abusefilter-tools-reautoconfirm' )->text(), $rac ); + $out->addHTML( $rac ); + } +} |