stdClass|null] as retrieved from self::getFilter. ID could be either * an integer or "" */ private static $filterCache = []; /** @var string The prefix to use for global filters */ public const GLOBAL_FILTER_PREFIX = 'global-'; /** * @var array Map of (action ID => string[]) * @fixme avoid global state here */ public static $tagsToSet = []; /** * @var array IDs of logged filters like [ page title => [ 'local' => [ids], 'global' => [ids] ] ]. * @fixme avoid global state */ public static $logIds = []; /** * @var string[] The FULL list of fields in the abuse_filter table * @internal */ public const ALL_ABUSE_FILTER_FIELDS = [ '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' ]; public const HISTORY_MAPPINGS = [ 'af_pattern' => 'afh_pattern', 'af_user' => 'afh_user', 'af_user_text' => 'afh_user_text', 'af_timestamp' => 'afh_timestamp', 'af_comments' => 'afh_comments', 'af_public_comments' => 'afh_public_comments', 'af_deleted' => 'afh_deleted', 'af_id' => 'afh_filter', 'af_group' => 'afh_group', ]; /** * @deprecated Since 1.35 Use VariableGenerator::addUserVars() * @param User $user * @param RecentChange|null $entry * @return AbuseFilterVariableHolder */ public static function generateUserVars( User $user, RecentChange $entry = null ) { wfDeprecated( __METHOD__, '1.35' ); $vars = new AbuseFilterVariableHolder(); $generator = new VariableGenerator( $vars ); return $generator->addUserVars( $user, $entry )->getVariableHolder(); } /** * @param int $filterID The ID of the filter * @param bool|int $global Whether the filter is global * @return bool */ public static function filterHidden( $filterID, $global = false ) { global $wgAbuseFilterCentralDB; if ( $global ) { if ( !$wgAbuseFilterCentralDB ) { return false; } $dbr = self::getCentralDB( DB_REPLICA ); } else { $dbr = wfGetDB( DB_REPLICA ); } $hidden = $dbr->selectField( 'abuse_filter', 'af_hidden', [ 'af_id' => $filterID ], __METHOD__ ); return (bool)$hidden; } /** * @deprecated Since 1.35 Use VariableGenerator::addTitleVars * @param Title|null $title * @param string $prefix * @param RecentChange|null $entry * @return AbuseFilterVariableHolder */ public static function generateTitleVars( $title, $prefix, RecentChange $entry = null ) { wfDeprecated( __METHOD__, '1.35' ); $vars = new AbuseFilterVariableHolder(); if ( !( $title instanceof Title ) ) { return $vars; } $generator = new VariableGenerator( $vars ); return $generator->addTitleVars( $title, $prefix, $entry )->getVariableHolder(); } /** * @deprecated Since 1.35 Use VariableGenerator::addGenericVars * @return AbuseFilterVariableHolder */ public static function generateGenericVars() { wfDeprecated( __METHOD__, '1.35' ); $vars = new AbuseFilterVariableHolder(); $generator = new VariableGenerator( $vars ); return $generator->addGenericVars()->getVariableHolder(); } /** * Returns an associative array of filters which were tripped * * @param AbuseFilterVariableHolder $vars * @param Title $title * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) * @param string $mode 'execute' for edits and logs, 'stash' for cached matches * @return bool[] Map of (integer filter ID => bool) * @deprecated Since 1.34 See comment on AbuseFilterRunner::checkAllFilters */ public static function checkAllFilters( AbuseFilterVariableHolder $vars, Title $title, $group = 'default', $mode = 'execute' ) { $parser = self::getDefaultParser( $vars ); $user = RequestContext::getMain()->getUser(); $runner = new AbuseFilterRunner( $user, $title, $vars, $group ); $runner->parser = $parser; return $runner->checkAllFilters(); } /** * @param int|string $filter * @internal */ public static function resetFilterProfile( $filter ) { $stash = MediaWikiServices::getInstance()->getMainObjectStash(); $profileKey = self::filterProfileKey( $filter ); $stash->delete( $profileKey ); } /** * Retrieve per-filter statistics. * * @param string $filter * @return array */ public static function getFilterProfile( $filter ) { $stash = MediaWikiServices::getInstance()->getMainObjectStash(); $profile = $stash->get( self::filterProfileKey( $filter ) ); if ( $profile !== false ) { $curCount = $profile['count']; $curTotalTime = $profile['total-time']; $curTotalConds = $profile['total-cond']; } else { return [ 0, 0, 0, 0 ]; } // Return in milliseconds, rounded to 2dp $avgTime = round( $curTotalTime / $curCount, 2 ); $avgCond = round( $curTotalConds / $curCount, 1 ); return [ $curCount, $profile['matches'], $avgTime, $avgCond ]; } /** * Utility function to split "$index" to an array [ $id, $global ], where * $id is $index casted to int, and $global is a boolean: true if the filter is global, * false otherwise (i.e. if the $filter === $index). Note that the $index * is always casted to int. Passing anything which isn't an integer-like value or a string * in the shape "integer" will throw. * This reverses self::buildGlobalName * * @param string|int $filter * @return array * @throws InvalidArgumentException */ public static function splitGlobalName( $filter ) { if ( preg_match( '/^' . self::GLOBAL_FILTER_PREFIX . '\d+$/', $filter ) === 1 ) { $id = intval( substr( $filter, strlen( self::GLOBAL_FILTER_PREFIX ) ) ); return [ $id, true ]; } elseif ( is_numeric( $filter ) ) { return [ (int)$filter, false ]; } else { throw new InvalidArgumentException( "Invalid filter name: $filter" ); } } /** * Given a filter ID and a boolean indicating whether it's global, build a string like * "$ID". Note that, with global = false, $id is casted to string. * This reverses self::splitGlobalName. * * @param int $id The filter ID * @param bool $global Whether the filter is global * @return string * @todo Calling this method should be avoided wherever possible */ public static function buildGlobalName( $id, $global = true ) { $prefix = $global ? self::GLOBAL_FILTER_PREFIX : ''; return "$prefix$id"; } /** * @param string[] $filters * @return (string|array)[][][] * @phan-return array> */ public static function getConsequencesForFilters( $filters ) { $globalFilters = []; $localFilters = []; foreach ( $filters as $filter ) { list( $filterID, $global ) = self::splitGlobalName( $filter ); if ( $global ) { $globalFilters[] = $filterID; } else { $localFilters[] = $filter; } } // Load local filter info $dbr = wfGetDB( DB_REPLICA ); // Retrieve the consequences. $consequences = []; if ( count( $localFilters ) ) { $consequences = self::loadConsequencesFromDB( $dbr, $localFilters ); } if ( count( $globalFilters ) ) { $consequences += self::loadConsequencesFromDB( self::getCentralDB( DB_REPLICA ), $globalFilters, self::GLOBAL_FILTER_PREFIX ); } return $consequences; } /** * @param IDatabase $dbr * @param string[] $filters * @param string $prefix * @return (string|array)[][][] * @phan-return array> */ public static function loadConsequencesFromDB( IDatabase $dbr, $filters, $prefix = '' ) { $actionsByFilter = []; foreach ( $filters as $filter ) { $actionsByFilter[$prefix . $filter] = []; } $res = $dbr->select( [ 'abuse_filter_action', 'abuse_filter' ], '*', [ 'af_id' => $filters ], __METHOD__, [], [ 'abuse_filter_action' => [ 'LEFT JOIN', 'afa_filter=af_id' ] ] ); // Categorise consequences by filter. global $wgAbuseFilterRestrictions; foreach ( $res as $row ) { if ( $row->af_throttled && !empty( $wgAbuseFilterRestrictions[$row->afa_consequence] ) ) { // Don't do the action, just log $logger = LoggerFactory::getInstance( 'AbuseFilter' ); $logger->info( 'Filter {filter_id} is throttled, skipping action: {action}', [ 'filter_id' => $row->af_id, 'action' => $row->afa_consequence ] ); } elseif ( $row->afa_filter !== $row->af_id ) { // We probably got a NULL, as it's a LEFT JOIN. Don't add it. } else { $actionsByFilter[$prefix . $row->afa_filter][$row->afa_consequence] = [ 'action' => $row->afa_consequence, 'parameters' => array_filter( explode( "\n", $row->afa_parameters ) ) ]; } } return $actionsByFilter; } /** * @param AbuseFilterVariableHolder $vars * @param Title $title * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) * @param User $user The user performing the action * @return Status * @deprecated Since 1.34 Build an AbuseFilterRunner instance and call run() on that. */ public static function filterAction( AbuseFilterVariableHolder $vars, Title $title, $group, User $user ) { $runner = new AbuseFilterRunner( $user, $title, $vars, $group ); return $runner->run(); } /** * @param string $filter Filter ID (integer or "") * @return stdClass|null DB row on success, null on failure */ public static function getFilter( $filter ) { global $wgAbuseFilterCentralDB; if ( !isset( self::$filterCache[$filter] ) ) { list( $filterID, $global ) = self::splitGlobalName( $filter ); if ( $global ) { // Global wiki filter if ( !$wgAbuseFilterCentralDB ) { return null; } $dbr = self::getCentralDB( DB_REPLICA ); } else { // Local wiki filter $dbr = wfGetDB( DB_REPLICA ); } $row = $dbr->selectRow( 'abuse_filter', self::ALL_ABUSE_FILTER_FIELDS, [ 'af_id' => $filterID ], __METHOD__ ); self::$filterCache[$filter] = $row ?: null; } return self::$filterCache[$filter]; } /** * Checks whether the given object represents a full abuse_filter DB row * @param stdClass $row * @return bool */ public static function isFullAbuseFilterRow( stdClass $row ) { $actual = array_keys( get_object_vars( $row ) ); if ( count( $actual ) !== count( self::ALL_ABUSE_FILTER_FIELDS ) || array_diff( self::ALL_ABUSE_FILTER_FIELDS, $actual ) ) { return false; } return true; } /** * Saves an abuse_filter row in cache * @param string $id Filter ID (integer or "") * @param stdClass $row A full abuse_filter row to save * @throws UnexpectedValueException if the row is not full */ public static function cacheFilter( $id, $row ) { // Check that all fields have been passed, otherwise using self::getFilter for this // row will return partial data. if ( !self::isFullAbuseFilterRow( $row ) ) { throw new UnexpectedValueException( 'The specified row must be a full abuse_filter row.' ); } self::$filterCache[$id] = $row; } /** * Store a var dump to External Storage or the text table * Some of this code is stolen from Revision::insertOn and friends * * @param AbuseFilterVariableHolder $vars * @param bool $global * * @return int The insert ID. */ public static function storeVarDump( AbuseFilterVariableHolder $vars, $global = false ) { global $wgCompressRevisions; // Get all variables yet set and compute old and new wikitext if not yet done // as those are needed for the diff view on top of the abuse log pages $vars = $vars->dumpAllVars( [ 'old_wikitext', 'new_wikitext' ] ); // Vars is an array with native PHP data types (non-objects) now $text = FormatJson::encode( $vars ); $flags = [ 'utf-8' ]; if ( $wgCompressRevisions && function_exists( 'gzdeflate' ) ) { $text = gzdeflate( $text ); $flags[] = 'gzip'; } // Store to ExternalStore if applicable global $wgDefaultExternalStore, $wgAbuseFilterCentralDB; if ( $wgDefaultExternalStore ) { if ( $global ) { $text = ExternalStore::insertToForeignDefault( $text, $wgAbuseFilterCentralDB ); } else { $text = ExternalStore::insertToDefault( $text ); } $flags[] = 'external'; } // Store to text table if ( $global ) { $dbw = self::getCentralDB( DB_MASTER ); } else { $dbw = wfGetDB( DB_MASTER ); } $dbw->insert( 'text', [ 'old_text' => $text, 'old_flags' => implode( ',', $flags ), ], __METHOD__ ); return $dbw->insertId(); } /** * Retrieve a var dump from External Storage or the text table * Some of this code is stolen from Revision::loadText et al * * @param string $stored_dump * * @return AbuseFilterVariableHolder */ public static function loadVarDump( $stored_dump ) : AbuseFilterVariableHolder { // Backward compatibility for (old) blobs stored in the abuse_filter_log table if ( !is_numeric( $stored_dump ) && substr( $stored_dump, 0, strlen( 'stored-text:' ) ) !== 'stored-text:' && substr( $stored_dump, 0, strlen( 'tt:' ) ) !== 'tt:' ) { $data = unserialize( $stored_dump ); return is_array( $data ) ? AbuseFilterVariableHolder::newFromArray( $data ) : $data; } if ( is_numeric( $stored_dump ) ) { $text_id = (int)$stored_dump; } elseif ( strpos( $stored_dump, 'stored-text:' ) !== false ) { $text_id = (int)str_replace( 'stored-text:', '', $stored_dump ); } elseif ( strpos( $stored_dump, 'tt:' ) !== false ) { $text_id = (int)str_replace( 'tt:', '', $stored_dump ); } else { throw new LogicException( "Cannot understand format: $stored_dump" ); } $dbr = wfGetDB( DB_REPLICA ); $text_row = $dbr->selectRow( 'text', [ 'old_text', 'old_flags' ], [ 'old_id' => $text_id ], __METHOD__ ); if ( !$text_row ) { $logger = LoggerFactory::getInstance( 'AbuseFilter' ); $logger->warning( __METHOD__ . ": no text row found for input $stored_dump." ); return new AbuseFilterVariableHolder; } $flags = $text_row->old_flags === '' ? [] : explode( ',', $text_row->old_flags ); $text = $text_row->old_text; if ( in_array( 'external', $flags ) ) { $text = ExternalStore::fetchFromURL( $text ); } if ( in_array( 'gzip', $flags ) ) { $text = gzinflate( $text ); } $obj = FormatJson::decode( $text, true ); if ( $obj === null ) { // Temporary code until all rows will be JSON-encoded $obj = unserialize( $text ); } if ( in_array( 'nativeDataArray', $flags ) || // Temporary condition: we don't add the flag anymore, but the updateVarDump // script could be still running and we cannot assume that this branch is the default. ( is_array( $obj ) && array_key_exists( 'action', $obj ) ) ) { $vars = $obj; $obj = AbuseFilterVariableHolder::newFromArray( $vars ); $deprecatedVars = AbuseFilterServices::getKeywordsManager()->getDeprecatedVariables(); // If old variable names are used, make sure to keep them if ( array_intersect_key( $deprecatedVars, $obj->getVars() ) ) { $obj->mVarsVersion = 1; } } return $obj; } /** * Get an identifier for the given action to be used in self::$tagsToSet * * @param string $action The name of the current action, as used by AbuseFilter (e.g. 'edit' * or 'createaccount') * @param Title $title The title where the current action is executed on. This is the user page * for account creations. * @param string $username Of the user executing the action (as returned by User::getName()). * For account creation, this is the name of the new account. * @return string */ public static function getTaggingActionId( $action, Title $title, $username ) { return implode( '-', [ $title->getPrefixedText(), $username, $action ] ); } /** * @param array[] $tagsByAction Map of (integer => string[]) */ public static function bufferTagsToSetByAction( array $tagsByAction ) { foreach ( $tagsByAction as $actionID => $tags ) { if ( !isset( self::$tagsToSet[ $actionID ] ) ) { self::$tagsToSet[ $actionID ] = $tags; } else { self::$tagsToSet[ $actionID ] = array_unique( array_merge( self::$tagsToSet[ $actionID ], $tags ) ); } } } /** * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) * @return string */ public static function getGlobalRulesKey( $group ) { global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB; $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); if ( !$wgAbuseFilterIsCentral ) { return $cache->makeGlobalKey( 'abusefilter', 'rules', $wgAbuseFilterCentralDB, $group ); } return $cache->makeKey( 'abusefilter', 'rules', $group ); } /** * Gets the autopromotion block status for the given user * * @param User $target * @return int */ public static function getAutoPromoteBlockStatus( User $target ) { $store = ObjectCache::getInstance( 'db-replicated' ); return (int)$store->get( self::autoPromoteBlockKey( $store, $target ) ); } /** * Blocks autopromotion for the given user * * @param User $target * @param string $msg The message to show in the log * @param int $duration Duration for which autopromotion is blocked, in seconds * @return bool True on success, false on failure */ public static function blockAutoPromote( User $target, $msg, int $duration ) { $store = ObjectCache::getInstance( 'db-replicated' ); if ( !$store->set( self::autoPromoteBlockKey( $store, $target ), 1, $duration ) ) { // Failed to set key $logger = LoggerFactory::getInstance( 'AbuseFilter' ); $logger->warning( "Failed to block autopromotion for $target. Error: " . $store->getLastError() ); return false; } $logEntry = new ManualLogEntry( 'rights', 'blockautopromote' ); $performer = self::getFilterUser(); $logEntry->setPerformer( $performer ); $logEntry->setTarget( $target->getUserPage() ); $logEntry->setParameters( [ '7::duration' => $duration, // These parameters are unused in our message, but some parts of the code check for them '4::oldgroups' => [], '5::newgroups' => [] ] ); $logEntry->setComment( $msg ); $logEntry->publish( $logEntry->insert() ); return true; } /** * Unblocks autopromotion for the given user * * @param User $target * @param User $performer * @param string $msg The message to show in the log * @return bool True on success, false on failure */ public static function unblockAutopromote( User $target, User $performer, $msg ) { // Immediately expire (delete) the key, failing if it does not exist $store = ObjectCache::getInstance( 'db-replicated' ); $expireAt = time() - $store::TTL_HOUR; if ( !$store->changeTTL( self::autoPromoteBlockKey( $store, $target ), $expireAt ) ) { // Key did not exist to begin with; nothing to do return false; } $logEntry = new ManualLogEntry( 'rights', 'restoreautopromote' ); $logEntry->setTarget( Title::makeTitle( NS_USER, $target->getName() ) ); $logEntry->setComment( $msg ); // These parameters are unused in our message, but some parts of the code check for them $logEntry->setParameters( [ '4::oldgroups' => [], '5::newgroups' => [] ] ); $logEntry->setPerformer( $performer ); $logEntry->publish( $logEntry->insert() ); return true; } /** * @param BagOStuff $store * @param User $target * @return string * @internal */ public static function autoPromoteBlockKey( BagOStuff $store, User $target ) { return $store->makeKey( 'abusefilter', 'block-autopromote', $target->getId() ); } /** * @param string $type The value to get, either "threshold", "count" or "age" * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) * @return mixed */ public static function getEmergencyValue( $type, $group ) { switch ( $type ) { case 'threshold': global $wgAbuseFilterEmergencyDisableThreshold; $value = $wgAbuseFilterEmergencyDisableThreshold; break; case 'count': global $wgAbuseFilterEmergencyDisableCount; $value = $wgAbuseFilterEmergencyDisableCount; break; case 'age': global $wgAbuseFilterEmergencyDisableAge; $value = $wgAbuseFilterEmergencyDisableAge; break; default: throw new InvalidArgumentException( '$type must be either "threshold", "count" or "age"' ); } return $value[$group] ?? $value['default']; } /** * Get the memcache access key used to store per-filter profiling data. * * @param string|int $filter * @return string */ public static function filterProfileKey( $filter ) { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); return $cache->makeKey( 'abusefilter-profile', 'v3', $filter ); } /** * Memcache access key used to store overall profiling data for rule groups * * @param string $group * @return string */ public static function filterProfileGroupKey( $group ) { $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); return $cache->makeKey( 'abusefilter-profile', 'group', $group ); } /** * @return User */ public static function getFilterUser() : User { $username = wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text(); $user = User::newSystemUser( $username, [ 'steal' => true ] ); if ( !$user ) { // User name is invalid. Don't throw because this is a system message, easy // to change and make wrong either by mistake or intentionally to break the site. wfWarn( 'The AbuseFilter user\'s name is invalid. Please change it in ' . 'MediaWiki:abusefilter-blocker' ); // Use the default name to avoid breaking other stuff. This should have no harm, // aside from blocks temporarily attributed to another user. $defaultName = wfMessage( 'abusefilter-blocker' )->inLanguage( 'en' )->text(); $user = User::newSystemUser( $defaultName, [ 'steal' => true ] ); } '@phan-var User $user'; // Promote user to 'sysop' so it doesn't look // like an unprivileged account is blocking users if ( !in_array( 'sysop', $user->getGroups() ) ) { $user->addGroup( 'sysop' ); } return $user; } /** * Extract values for syntax highlight * * @param bool $canEdit * @return array */ public static function getAceConfig( bool $canEdit ): array { $keywordsManager = AbuseFilterServices::getKeywordsManager(); $values = $keywordsManager->getBuilderValues(); $deprecatedVars = $keywordsManager->getDeprecatedVariables(); $builderVariables = implode( '|', array_keys( $values['vars'] ) ); $builderFunctions = implode( '|', array_keys( AbuseFilterParser::FUNCTIONS ) ); // AbuseFilterTokenizer::KEYWORDS also includes constants (true, false and null), // but Ace redefines these constants afterwards so this will not be an issue $builderKeywords = implode( '|', AbuseFilterTokenizer::KEYWORDS ); // Extract operators from tokenizer like we do in AbuseFilterParserTest $operators = implode( '|', array_map( function ( $op ) { return preg_quote( $op, '/' ); }, AbuseFilterTokenizer::OPERATORS ) ); $deprecatedVariables = implode( '|', array_keys( $deprecatedVars ) ); $disabledVariables = implode( '|', array_keys( $keywordsManager->getDisabledVariables() ) ); return [ 'variables' => $builderVariables, 'functions' => $builderFunctions, 'keywords' => $builderKeywords, 'operators' => $operators, 'deprecated' => $deprecatedVariables, 'disabled' => $disabledVariables, 'aceReadOnly' => !$canEdit ]; } /** * Check whether a filter is allowed to use a tag * * @param string $tag Tag name * @return Status */ public static function isAllowedTag( $tag ) { $tagNameStatus = ChangeTags::isTagNameValid( $tag ); if ( !$tagNameStatus->isGood() ) { return $tagNameStatus; } $finalStatus = Status::newGood(); $canAddStatus = ChangeTags::canAddTagsAccompanyingChange( [ $tag ] ); if ( $canAddStatus->isGood() ) { return $finalStatus; } if ( $tag === 'abusefilter-condition-limit' ) { $finalStatus->fatal( 'abusefilter-tag-reserved' ); 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; } /** * Validate throttle parameters * * @param array $params Throttle parameters * @return null|string Null on success, a string with the error message on failure */ public static function checkThrottleParameters( $params ) { list( $throttleCount, $throttlePeriod ) = explode( ',', $params[1], 2 ); $throttleGroups = array_slice( $params, 2 ); $validGroups = [ 'ip', 'user', 'range', 'creationdate', 'editcount', 'site', 'page' ]; $error = null; if ( preg_match( '/^[1-9][0-9]*$/', $throttleCount ) === 0 ) { $error = 'abusefilter-edit-invalid-throttlecount'; } elseif ( preg_match( '/^[1-9][0-9]*$/', $throttlePeriod ) === 0 ) { $error = 'abusefilter-edit-invalid-throttleperiod'; } elseif ( !$throttleGroups ) { $error = 'abusefilter-edit-empty-throttlegroups'; } else { $valid = true; // Groups should be unique in three ways: no direct duplicates like 'user' and 'user', // no duplicated subgroups, not even shuffled ('ip,user' and 'user,ip') and no duplicates // within subgroups ('user,ip,user') $uniqueGroups = []; $uniqueSubGroups = true; // Every group should be valid, and subgroups should have valid groups inside foreach ( $throttleGroups as $group ) { if ( strpos( $group, ',' ) !== false ) { $subGroups = explode( ',', $group ); if ( $subGroups !== array_unique( $subGroups ) ) { $uniqueSubGroups = false; break; } foreach ( $subGroups as $subGroup ) { if ( !in_array( $subGroup, $validGroups ) ) { $valid = false; break 2; } } sort( $subGroups ); $uniqueGroups[] = implode( ',', $subGroups ); } else { if ( !in_array( $group, $validGroups ) ) { $valid = false; break; } $uniqueGroups[] = $group; } } if ( !$valid ) { $error = 'abusefilter-edit-invalid-throttlegroups'; } elseif ( !$uniqueSubGroups || $uniqueGroups !== array_unique( $uniqueGroups ) ) { $error = 'abusefilter-edit-duplicated-throttlegroups'; } } return $error; } /** * Checks whether user input for the filter editing form is valid and if so saves the filter. * Returns a Status object which can be: * - Good with [ new_filter_id, history_id ] as value if the filter was successfully saved * - Good with value = false if everything went fine but the filter is unchanged * - OK with errors if a validation error occurred * - Fatal in case of a permission-related error * * @param ContextSource $context * @param int|string $filter * @param stdClass $newRow * @param array $actions * @param IDatabase $dbw DB_MASTER Where the filter should be saved * @return Status */ public static function saveFilter( ContextSource $context, $filter, $newRow, $actions, IDatabase $dbw ) { $validationStatus = Status::newGood(); $request = $context->getRequest(); $user = $context->getUser(); // Check the syntax $syntaxerr = self::getDefaultParser()->checkSyntax( $request->getVal( 'wpFilterRules' ) ); if ( $syntaxerr !== true ) { $validationStatus->error( 'abusefilter-edit-badsyntax', $syntaxerr[0] ); return $validationStatus; } // Check for missing required fields (title and pattern) $missing = []; if ( !$request->getVal( 'wpFilterRules' ) || trim( $request->getVal( 'wpFilterRules' ) ) === '' ) { $missing[] = $context->msg( 'abusefilter-edit-field-conditions' )->escaped(); } if ( !$request->getVal( 'wpFilterDescription' ) ) { $missing[] = $context->msg( 'abusefilter-edit-field-description' )->escaped(); } if ( count( $missing ) !== 0 ) { $missing = $context->getLanguage()->commaList( $missing ); $validationStatus->error( 'abusefilter-edit-missingfields', $missing ); return $validationStatus; } // Don't allow setting as deleted an active filter if ( $request->getCheck( 'wpFilterEnabled' ) && $request->getCheck( 'wpFilterDeleted' ) ) { $validationStatus->error( 'abusefilter-edit-deleting-enabled' ); return $validationStatus; } // If we've activated the 'tag' option, check the arguments for validity. if ( isset( $actions['tag'] ) ) { if ( count( $actions['tag'] ) === 0 ) { $validationStatus->error( 'tags-create-no-name' ); return $validationStatus; } foreach ( $actions['tag'] as $tag ) { $status = self::isAllowedTag( $tag ); if ( !$status->isGood() ) { $err = $status->getErrors(); $msg = $err[0]['message']; $validationStatus->error( $msg ); return $validationStatus; } } } // Warning and disallow message cannot be empty if ( isset( $actions['warn'] ) && $actions['warn'][0] === '' ) { $validationStatus->error( 'abusefilter-edit-invalid-warn-message' ); return $validationStatus; } elseif ( isset( $actions['disallow'] ) && $actions['disallow'][0] === '' ) { $validationStatus->error( 'abusefilter-edit-invalid-disallow-message' ); return $validationStatus; } // If 'throttle' is selected, check its parameters if ( isset( $actions['throttle'] ) ) { $throttleCheck = self::checkThrottleParameters( $actions['throttle'] ); if ( $throttleCheck !== null ) { $validationStatus->error( $throttleCheck ); return $validationStatus; } } $availableActions = array_keys( array_filter( $context->getConfig()->get( 'AbuseFilterActions' ) ) ); $differences = self::compareVersions( [ $newRow, $actions ], [ $newRow->mOriginalRow, $newRow->mOriginalActions ], $availableActions ); // Don't allow adding a new global rule, or updating a // rule that is currently global, without permissions. if ( !self::canEditFilter( $user, $newRow ) || !self::canEditFilter( $user, $newRow->mOriginalRow ) ) { $validationStatus->fatal( 'abusefilter-edit-notallowed-global' ); return $validationStatus; } // Don't allow custom messages on global rules if ( $newRow->af_global == 1 && ( $request->getVal( 'wpFilterWarnMessage' ) !== 'abusefilter-warning' || $request->getVal( 'wpFilterDisallowMessage' ) !== 'abusefilter-disallowed' ) ) { $validationStatus->fatal( 'abusefilter-edit-notallowed-global-custom-msg' ); return $validationStatus; } $origActions = $newRow->mOriginalActions; $wasGlobal = (bool)$newRow->mOriginalRow->af_global; unset( $newRow->mOriginalRow ); unset( $newRow->mOriginalActions ); // Check for non-changes if ( !count( $differences ) ) { $validationStatus->setResult( true, false ); return $validationStatus; } // Check for restricted actions $restrictions = $context->getConfig()->get( 'AbuseFilterRestrictions' ); if ( count( array_intersect_key( array_filter( $restrictions ), array_merge( $actions, $origActions ) ) ) && !MediaWikiServices::getInstance()->getPermissionManager() ->userHasRight( $user, 'abusefilter-modify-restricted' ) ) { $validationStatus->error( 'abusefilter-edit-restricted' ); return $validationStatus; } // Everything went fine, so let's save the filter list( $new_id, $history_id ) = self::doSaveFilter( $newRow, $differences, $filter, $actions, $wasGlobal, $context, $dbw ); $validationStatus->setResult( true, [ $new_id, $history_id ] ); return $validationStatus; } /** * Saves new filter's info to DB * * @param stdClass $newRow * @param array $differences * @param int|string $filter * @param array $actions * @param bool $wasGlobal * @param ContextSource $context * @param IDatabase $dbw DB_MASTER where the filter will be saved * @return int[] first element is new ID, second is history ID */ private static function doSaveFilter( $newRow, $differences, $filter, $actions, $wasGlobal, ContextSource $context, IDatabase $dbw ) { $user = $context->getUser(); // Convert from object to array $newRow = get_object_vars( $newRow ); // Set last modifier. $newRow['af_timestamp'] = $dbw->timestamp(); $newRow['af_user'] = $user->getId(); $newRow['af_user_text'] = $user->getName(); $dbw->startAtomic( __METHOD__ ); // Insert MAIN row. if ( $filter === 'new' ) { $new_id = null; $is_new = true; } else { $new_id = $filter; $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; // 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(); } '@phan-var int $new_id'; $availableActions = $context->getConfig()->get( 'AbuseFilterActions' ); $actionsRows = []; foreach ( array_filter( $availableActions ) as $action => $_ ) { // Check if it's set $enabled = isset( $actions[$action] ); if ( $enabled ) { $parameters = $actions[$action]; if ( $action === 'throttle' && $parameters[0] === 'new' ) { // FIXME: Do we really need to keep the filter ID inside throttle parameters? // We'd save space, keep things simpler and avoid this hack. Note: if removing // it, a maintenance script will be necessary to clean up the table. $parameters[0] = $new_id; } $thisRow = [ 'afa_filter' => $new_id, 'afa_consequence' => $action, 'afa_parameters' => implode( "\n", $parameters ) ]; $actionsRows[] = $thisRow; } } // Create a history row $afh_row = []; foreach ( self::HISTORY_MAPPINGS as $af_col => $afh_col ) { $afh_row[$afh_col] = $newRow[$af_col]; } $afh_row['afh_actions'] = serialize( $actions ); $afh_row['afh_changed_fields'] = implode( ',', $differences ); $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; // 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 = self::getGlobalRulesKey( $group ); MediaWikiServices::getInstance()->getMainWANObjectCache()->touchCheckKey( $globalRulesKey ); } // Logging $subtype = $filter === 'new' ? 'create' : 'modify'; $logEntry = new ManualLogEntry( 'abusefilter', $subtype ); $logEntry->setPerformer( $user ); $logEntry->setTarget( SpecialPage::getTitleFor( SpecialAbuseFilter::PAGE_NAME, (string)$new_id ) ); $logEntry->setParameters( [ 'historyId' => $history_id, 'newId' => $new_id ] ); $logid = $logEntry->insert( $dbw ); $logEntry->publish( $logid ); // Purge the tag list cache so the fetchAllTags hook applies tag changes if ( isset( $actions['tag'] ) ) { AbuseFilterHooks::purgeTagCache(); } self::resetFilterProfile( $new_id ); return [ $new_id, $history_id ]; } /** * Each version is expected to be an array( $row, $actions ) * Returns an array of fields that are different. * * @param array $version_1 * @param array $version_2 * @param string[] $availableActions All actions enabled in the AF config * * @return array */ public static function compareVersions( array $version_1, array $version_2, array $availableActions ) { $compareFields = [ 'af_public_comments', 'af_pattern', 'af_comments', 'af_deleted', 'af_enabled', 'af_hidden', 'af_global', 'af_group', ]; $differences = []; list( $row1, $actions1 ) = $version_1; list( $row2, $actions2 ) = $version_2; foreach ( $compareFields as $field ) { if ( !isset( $row2->$field ) || $row1->$field != $row2->$field ) { $differences[] = $field; } } foreach ( $availableActions as $action ) { if ( !isset( $actions1[$action] ) && !isset( $actions2[$action] ) ) { // They're both unset } elseif ( isset( $actions1[$action] ) && isset( $actions2[$action] ) ) { // They're both set. Double check needed, e.g. per T180194 if ( array_diff( $actions1[$action], $actions2[$action] ) || array_diff( $actions2[$action], $actions1[$action] ) ) { // Different parameters $differences[] = 'actions'; } } else { // One's unset, one's set. $differences[] = 'actions'; } } return array_unique( $differences ); } /** * @param stdClass $row * @return array */ public static function translateFromHistory( $row ) { // Manually translate into an abuse_filter row. $af_row = new stdClass; foreach ( self::HISTORY_MAPPINGS as $af_col => $afh_col ) { $af_row->$af_col = $row->$afh_col; } // Process flags $af_row->af_deleted = 0; $af_row->af_hidden = 0; $af_row->af_enabled = 0; if ( $row->afh_flags !== '' ) { $flags = explode( ',', $row->afh_flags ); foreach ( $flags as $flag ) { $col_name = "af_$flag"; $af_row->$col_name = 1; } } // Process actions $actionsRaw = unserialize( $row->afh_actions ); $actionsOutput = is_array( $actionsRaw ) ? $actionsRaw : []; return [ $af_row, $actionsOutput ]; } /** * @param string $action * @param MessageLocalizer|null $localizer * @return string HTML */ public static function getActionDisplay( $action, MessageLocalizer $localizer = null ) { $msgCallback = $localizer != null ? [ $localizer, 'msg' ] : 'wfMessage'; // Give grep a chance to find the usages: // abusefilter-action-tag, abusefilter-action-throttle, abusefilter-action-warn, // abusefilter-action-blockautopromote, abusefilter-action-block, abusefilter-action-degroup, // abusefilter-action-rangeblock, abusefilter-action-disallow $display = $msgCallback( "abusefilter-action-$action" )->escaped(); $display = $msgCallback( "abusefilter-action-$action" )->rawParams( $display )->isDisabled() ? htmlspecialchars( $action ) : $display; return $display; } /** * @param mixed $var * @param string $indent * @return string */ public static function formatVar( $var, string $indent = '' ) { if ( $var === [] ) { return '[]'; } elseif ( is_array( $var ) ) { $ret = '['; $indent .= "\t"; foreach ( $var as $key => $val ) { $ret .= "\n$indent" . self::formatVar( $key, $indent ) . ' => ' . self::formatVar( $val, $indent ) . ','; } // Strip trailing commas return substr( $ret, 0, -1 ) . "\n" . substr( $indent, 0, -1 ) . ']'; } elseif ( is_string( $var ) ) { // Don't escape the string (specifically backslashes) to avoid displaying wrong stuff return "'$var'"; } elseif ( $var === null ) { return 'null'; } elseif ( is_float( $var ) ) { // Don't let float precision produce weirdness return (string)$var; } return var_export( $var, true ); } /** * @param AbuseFilterVariableHolder|array $vars * @param IContextSource $context * @return string */ public static function buildVarDumpTable( $vars, IContextSource $context ) { // Export all values if ( $vars instanceof AbuseFilterVariableHolder ) { $vars = $vars->exportAllVars(); } $output = ''; $output .= Xml::openElement( 'table', [ 'class' => 'mw-abuselog-details' ] ) . Xml::openElement( 'tbody' ) . "\n"; $header = Xml::element( 'th', null, $context->msg( 'abusefilter-log-details-var' )->text() ) . Xml::element( 'th', null, $context->msg( 'abusefilter-log-details-val' )->text() ); $output .= Xml::tags( 'tr', null, $header ) . "\n"; if ( !count( $vars ) ) { $output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' ); return $output; } $keywordsManager = AbuseFilterServices::getKeywordsManager(); // Now, build the body of the table. foreach ( $vars as $key => $value ) { $key = strtolower( $key ); $varMsgKey = $keywordsManager->getMessageKeyForVar( $key ); if ( $varMsgKey ) { $keyDisplay = $context->msg( $varMsgKey )->parse() . ' ' . Html::element( 'code', [], $context->msg( 'parentheses' )->rawParams( $key )->text() ); } else { $keyDisplay = Html::element( 'code', [], $key ); } if ( $value === null ) { $value = ''; } $value = Html::element( 'div', [ 'class' => 'mw-abuselog-var-value' ], self::formatVar( $value ) ); $trow = Xml::tags( 'td', [ 'class' => 'mw-abuselog-var' ], $keyDisplay ) . Xml::tags( 'td', [ 'class' => 'mw-abuselog-var-value' ], $value ); $output .= Xml::tags( 'tr', [ 'class' => "mw-abuselog-details-$key mw-abuselog-value" ], $trow ) . "\n"; } $output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' ); return $output; } /** * @param string $action * @param string[] $parameters * @param Language $lang * @return string */ public static function formatAction( $action, $parameters, $lang ) { if ( count( $parameters ) === 0 || ( $action === 'block' && count( $parameters ) !== 3 ) ) { $displayAction = self::getActionDisplay( $action ); } else { if ( $action === 'block' ) { // Needs to be treated separately since the message is more complex $messages = [ wfMessage( 'abusefilter-block-anon' )->escaped() . wfMessage( 'colon-separator' )->escaped() . $lang->translateBlockExpiry( $parameters[1] ), wfMessage( 'abusefilter-block-user' )->escaped() . wfMessage( 'colon-separator' )->escaped() . $lang->translateBlockExpiry( $parameters[2] ) ]; if ( $parameters[0] === 'blocktalk' ) { $messages[] = wfMessage( 'abusefilter-block-talk' )->escaped(); } $displayAction = $lang->commaList( $messages ); } elseif ( $action === 'throttle' ) { array_shift( $parameters ); list( $actions, $time ) = explode( ',', array_shift( $parameters ) ); // Join comma-separated groups in a commaList with a final "and", and convert to messages. // Messages used here: abusefilter-throttle-ip, abusefilter-throttle-user, // abusefilter-throttle-site, abusefilter-throttle-creationdate, abusefilter-throttle-editcount // abusefilter-throttle-range, abusefilter-throttle-page, abusefilter-throttle-none foreach ( $parameters as &$val ) { if ( strpos( $val, ',' ) !== false ) { $subGroups = explode( ',', $val ); foreach ( $subGroups as &$group ) { $msg = wfMessage( "abusefilter-throttle-$group" ); // We previously accepted literally everything in this field, so old entries // may have weird stuff. $group = $msg->exists() ? $msg->text() : $group; } unset( $group ); $val = $lang->listToText( $subGroups ); } else { $msg = wfMessage( "abusefilter-throttle-$val" ); $val = $msg->exists() ? $msg->text() : $val; } } unset( $val ); $groups = $lang->semicolonList( $parameters ); $displayAction = self::getActionDisplay( $action ) . wfMessage( 'colon-separator' )->escaped() . wfMessage( 'abusefilter-throttle-details' )->params( $actions, $time, $groups )->escaped(); } else { $displayAction = self::getActionDisplay( $action ) . wfMessage( 'colon-separator' )->escaped() . $lang->semicolonList( array_map( 'htmlspecialchars', $parameters ) ); } } return $displayAction; } /** * @param string $value * @param Language $lang * @return string */ public static function formatFlags( $value, $lang ) { $flags = array_filter( explode( ',', $value ) ); $flags_display = []; foreach ( $flags as $flag ) { $flags_display[] = wfMessage( "abusefilter-history-$flag" )->escaped(); } return $lang->commaList( $flags_display ); } /** * @param int $filterID * @return string */ public static function getGlobalFilterDescription( $filterID ) { global $wgAbuseFilterCentralDB; if ( !$wgAbuseFilterCentralDB ) { return ''; } static $cache = []; if ( isset( $cache[$filterID] ) ) { return $cache[$filterID]; } $fdb = self::getCentralDB( DB_REPLICA ); $cache[$filterID] = $fdb->selectField( 'abuse_filter', 'af_public_comments', [ 'af_id' => $filterID ], __METHOD__ ); return $cache[$filterID]; } /** * Gives either the user-specified name for a group, * or spits the input back out * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) * @return string A name for that filter group, or the input. */ public static function nameGroup( $group ) { // Give grep a chance to find the usages: abusefilter-group-default $msg = "abusefilter-group-$group"; return wfMessage( $msg )->exists() ? wfMessage( $msg )->escaped() : $group; } /** * Look up some text of a revision from its revision id * * Note that this is really *some* text, we do not make *any* guarantee * that this text will be even close to what the user actually sees, or * that the form is fit for any intended purpose. * * Note also that if the revision for any reason is not an Revision * the function returns with an empty string. * * For now, this returns all the revision's slots, concatenated together. * In future, this will be replaced by a better solution. See T208769 for * discussion. * * @internal * @todo Move elsewhere. VariableGenerator is a good candidate * * @param RevisionRecord|null $revision a valid revision * @param User $user the user instance to check for privileged access * @return string the content of the revision as some kind of string, * or an empty string if it can not be found */ public static function revisionToString( ?RevisionRecord $revision, User $user ) { if ( !$revision ) { return ''; } $strings = []; foreach ( $revision->getSlotRoles() as $role ) { $content = $revision->getContent( $role, RevisionRecord::FOR_THIS_USER, $user ); if ( $content === null ) { continue; } $strings[$role] = self::contentToString( $content ); } $result = implode( "\n\n", $strings ); return $result; } /** * Converts the given Content object to a string. * * This uses Content::getNativeData() if $content is an instance of TextContent, * or Content::getTextForSearchIndex() otherwise. * * The hook 'AbuseFilter::contentToString' can be used to override this * behavior. * * @internal * @todo Move elsewhere. VariableGenerator is a good candidate * * @param Content $content * * @return string a suitable string representation of the content. */ public static function contentToString( Content $content ) { $text = null; $hookRunner = AbuseFilterHookRunner::getRunner(); if ( $hookRunner->onAbuseFilterContentToString( $content, $text ) ) { $text = $content instanceof TextContent ? $content->getText() : $content->getTextForSearchIndex(); } // T22310 $text = TextContent::normalizeLineEndings( (string)$text ); return $text; } /** * Get the history ID of the first change to a given filter * * @param int $filterID Filter id * @return string */ public static function getFirstFilterChange( $filterID ) { static $firstChanges = []; if ( !isset( $firstChanges[$filterID] ) ) { $dbr = wfGetDB( DB_REPLICA ); $historyID = $dbr->selectField( 'abuse_filter_history', 'afh_id', [ 'afh_filter' => $filterID, ], __METHOD__, [ 'ORDER BY' => 'afh_timestamp ASC' ] ); $firstChanges[$filterID] = $historyID; } return $firstChanges[$filterID]; } /** * @param int $index DB_MASTER/DB_REPLICA * @return IDatabase * @throws DBerror * @throws RuntimeException */ public static function getCentralDB( $index ) { global $wgAbuseFilterCentralDB; if ( !is_string( $wgAbuseFilterCentralDB ) ) { throw new RuntimeException( '$wgAbuseFilterCentralDB is not configured' ); } return MediaWikiServices::getInstance() ->getDBLoadBalancerFactory() ->getMainLB( $wgAbuseFilterCentralDB ) ->getConnectionRef( $index, [], $wgAbuseFilterCentralDB ); } /** * @param User $user * @return bool */ public static function canEdit( User $user ) { $block = $user->getBlock(); $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); return ( !( $block && $block->isSitewide() ) && $permissionManager->userHasRight( $user, 'abusefilter-modify' ) ); } /** * @param User $user * @return bool */ public static function canEditGlobal( User $user ) { return MediaWikiServices::getInstance()->getPermissionManager() ->userHasRight( $user, 'abusefilter-modify-global' ); } /** * Whether the user can edit the given filter. * * @param User $user * @param object $row Filter row * @return bool */ public static function canEditFilter( User $user, $row ) { return ( self::canEdit( $user ) && !( isset( $row->af_global ) && $row->af_global == 1 && !self::canEditGlobal( $user ) ) ); } /** * @param User $user * @return bool */ public static function canViewPrivate( User $user ) { return MediaWikiServices::getInstance()->getPermissionManager() ->userHasAnyRight( $user, 'abusefilter-modify', 'abusefilter-view-private' ); } /** * Get a parser instance using default options. This should mostly be intended as a wrapper * around $wgAbuseFilterParserClass and for choosing the right type of cache. It also has the * benefit of typehinting the return value, thus making IDEs and static analysis tools happier. * * @param AbuseFilterVariableHolder|null $vars * @return AbuseFilterParser * @throws InvalidArgumentException if $wgAbuseFilterParserClass is not valid */ public static function getDefaultParser( AbuseFilterVariableHolder $vars = null ) : AbuseFilterParser { global $wgAbuseFilterParserClass; $allowedValues = [ AbuseFilterParser::class, AbuseFilterCachingParser::class ]; if ( !in_array( $wgAbuseFilterParserClass, $allowedValues ) ) { throw new InvalidArgumentException( "Invalid value $wgAbuseFilterParserClass for \$wgAbuseFilterParserClass." ); } $contLang = MediaWikiServices::getInstance()->getContentLanguage(); $cache = ObjectCache::getLocalServerInstance( 'hash' ); $logger = LoggerFactory::getInstance( 'AbuseFilter' ); $keywordsManager = AbuseFilterServices::getKeywordsManager(); return new $wgAbuseFilterParserClass( $contLang, $cache, $logger, $keywordsManager, $vars ); } /** * Shortcut for checking whether $user can view the given revision, with mask * SUPPRESSED_ALL. * * @note This assumes that a revision with the given ID exists * * @param RevisionRecord $revRec * @param User $user * @return bool */ public static function userCanViewRev( RevisionRecord $revRec, User $user ) : bool { return $revRec->audienceCan( RevisionRecord::SUPPRESSED_ALL, RevisionRecord::FOR_THIS_USER, $user ); } }