[ 'local' => [ids], 'global' => [ids] ] ]. * @fixme avoid global state */ public static $logIds = []; /** * @var array Actions which may harm the user. Only retrieve via self::getDangerousActions * @internal */ public const DANGEROUS_ACTIONS = [ 'block', 'blockautopromote', 'degroup', 'rangeblock' ]; 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(); } /** * Get an array of action which harm the user. * * @return string[] * @internal Temporary hack */ public static function getDangerousActions() : array { static $actions = null; if ( !$actions ) { $extActions = []; AbuseFilterHookRunner::getRunner()->onAbuseFilterGetDangerousActions( $extActions ); $actions = array_unique( array_merge( $extActions, self::DANGEROUS_ACTIONS ) ); } return $actions; } /** * @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 = AbuseFilterServices::getParserFactory()->newParser( $vars ); $user = RequestContext::getMain()->getUser(); $runner = new AbuseFilterRunner( $user, $title, $vars, $group ); $runner->parser = $parser; return $runner->checkAllFilters(); } /** * 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 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( AbuseFilterServices::getCentralDBManager()->getConnection( DB_REPLICA ), $globalFilters, self::GLOBAL_FILTER_PREFIX ); } return $consequences; } /** * @param IDatabase $dbr * @param string[] $filters * @param string $prefix * @return array[][] */ private 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. foreach ( $res as $row ) { if ( $row->af_throttled && in_array( $row->afa_consequence, self::getDangerousActions() ) ) { // 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. continue; } else { $actionsByFilter[$prefix . $row->afa_filter][$row->afa_consequence] = 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(); } /** * 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 = AbuseFilterServices::getCentralDBManager()->getConnection( 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 ) ) { $vars = $obj; $obj = AbuseFilterVariableHolder::newFromArray( $vars ); $obj->translateDeprecatedVars(); } return $obj; } /** * 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 User $user * @param int|null $filter * @param Filter $newFilter * @param Filter $originalFilter * @param IDatabase $dbw DB_MASTER Where the filter should be saved * @param Config $config * @return Status * @internal */ public static function saveFilter( User $user, ?int $filter, Filter $newFilter, Filter $originalFilter, IDatabase $dbw, Config $config ) { $validator = AbuseFilterServices::getFilterValidator(); $validationStatus = $validator->checkAll( $newFilter, $originalFilter, $user ); if ( !$validationStatus->isGood() ) { return $validationStatus; } $wasGlobal = $originalFilter->isGlobal(); // Check for non-changes $differences = AbuseFilterServices::getFilterCompare()->compareVersions( $newFilter, $originalFilter ); if ( !count( $differences ) ) { return Status::newGood( false ); } // Everything went fine, so let's save the filter list( $new_id, $history_id ) = self::doSaveFilter( $user, $newFilter, $differences, $filter, $wasGlobal, $dbw, $config ); return Status::newGood( [ $new_id, $history_id ] ); } /** * Saves new filter's info to DB * * @param User $user * @param Filter $newFilter * @param array $differences * @param int|null $filter * @param bool $wasGlobal * @param IDatabase $dbw DB_MASTER where the filter will be saved * @param Config $config * @return int[] first element is new ID, second is history ID */ private static function doSaveFilter( User $user, Filter $newFilter, $differences, ?int $filter, $wasGlobal, IDatabase $dbw, Config $config ) { // TODO This code shouldn't be here $newRow = get_object_vars( $newFilter->toDatabaseRow() ); // 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. $is_new = $filter === null; $new_id = $filter; // Preserve the old throttled status (if any) only if disabling the filter. // TODO: It might make more sense to check what was actually changed $newRow['af_throttled'] = ( $newRow['af_throttled'] ?? false ) && !$newRow['af_enabled']; // This is null when creating a new filter, but the DB field is NOT NULL $newRow['af_hit_count'] = $newRow['af_hit_count'] ?? 0; $newRow['af_id'] = $new_id; $dbw->replace( 'abuse_filter', 'af_id', $newRow, __METHOD__ ); if ( $is_new ) { $new_id = $dbw->insertId(); } '@phan-var int $new_id'; $availableActions = $config->get( 'AbuseFilterActions' ); $actions = $newFilter->getActions(); $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] === null ) { // 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 !== null ) { $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']; } AbuseFilterServices::getFilterLookup()->purgeGroupWANCache( $group ); } // Logging $subtype = $filter === null ? 'create' : 'modify'; $logEntry = new ManualLogEntry( 'abusefilter', $subtype ); $logEntry->setPerformer( $user ); $logEntry->setTarget( SpecialAbuseFilter::getTitleForSubpage( (string)$new_id ) ); $logEntry->setParameters( [ 'historyId' => $history_id, 'newId' => $new_id ] ); $logid = $logEntry->insert( $dbw ); $logEntry->publish( $logid ); if ( isset( $actions['tag'] ) ) { AbuseFilterServices::getChangeTagsManager()->purgeTagCache(); } AbuseFilterServices::getFilterProfiler()->resetFilterProfile( $new_id ); return [ $new_id, $history_id ]; } /** * @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 $msg = $msgCallback( "abusefilter-action-$action" ); return $msg->isDisabled() ? htmlspecialchars( $action ) : $msg->escaped(); } /** * @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 ); } /** * 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]; } /** * 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 ); } }