Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
48.39% covered (danger)
48.39%
331 / 684
25.00% covered (danger)
25.00%
5 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialAbuseLog
48.39% covered (danger)
48.39%
331 / 684
25.00% covered (danger)
25.00%
5 / 20
2489.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getRestriction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
n/a
0 / 0
n/a
0 / 0
1
 getGroupName
n/a
0 / 0
n/a
0 / 0
1
 execute
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
8.06
 getShortDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadParameters
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
4.02
 getAllFilterableActions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 searchForm
78.38% covered (warning)
78.38%
87 / 111
0.00% covered (danger)
0.00%
0 / 1
7.50
 showHideView
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 showList
36.18% covered (danger)
36.18%
55 / 152
0.00% covered (danger)
0.00%
0 / 1
646.78
 getDeleteButton
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 getListToggle
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 showDetails
51.97% covered (warning)
51.97%
66 / 127
0.00% covered (danger)
0.00%
0 / 1
59.00
 getPrivateDetailsRow
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
3.00
 buildPrivateDetailsTable
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 1
20
 showPrivateDetails
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
56
 getUserFilter
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 checkPrivateDetailsAccessReason
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 addPrivateDetailsAccessLogEntry
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getEntryVisibilityForUser
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
8.10
 canAccessTemporaryAccountIPAddresses
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\Special;
4
5use InvalidArgumentException;
6use MediaWiki\Diff\DifferenceEngine;
7use MediaWiki\Extension\AbuseFilter\AbuseFilter;
8use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
9use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
10use MediaWiki\Extension\AbuseFilter\AbuseLogConditionFactory;
11use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
12use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
13use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
14use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
15use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
16use MediaWiki\Extension\AbuseFilter\FilterLookup;
17use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
18use MediaWiki\Extension\AbuseFilter\Pager\AbuseLogPager;
19use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
20use MediaWiki\Extension\AbuseFilter\Variables\UnsetVariableException;
21use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
22use MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter;
23use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
24use MediaWiki\Extension\AbuseFilter\View\HideAbuseLog;
25use MediaWiki\Html\Html;
26use MediaWiki\Html\ListToggle;
27use MediaWiki\HTMLForm\HTMLForm;
28use MediaWiki\Linker\Linker;
29use MediaWiki\Logging\ManualLogEntry;
30use MediaWiki\MediaWikiServices;
31use MediaWiki\Page\LinkBatchFactory;
32use MediaWiki\Permissions\Authority;
33use MediaWiki\Permissions\PermissionManager;
34use MediaWiki\Registration\ExtensionRegistry;
35use MediaWiki\SpecialPage\SpecialPage;
36use MediaWiki\Status\Status;
37use MediaWiki\Title\Title;
38use MediaWiki\User\TempUser\TempUserConfig;
39use MediaWiki\User\UserIdentity;
40use MediaWiki\User\UserIdentityLookup;
41use MediaWiki\User\UserIdentityValue;
42use MediaWiki\WikiMap\WikiMap;
43use OOUI\ButtonInputWidget;
44use stdClass;
45use Wikimedia\IPUtils;
46use Wikimedia\Rdbms\LBFactory;
47
48class SpecialAbuseLog extends AbuseFilterSpecialPage {
49    public const PAGE_NAME = 'AbuseLog';
50
51    /** Visible entry */
52    public const VISIBILITY_VISIBLE = 'visible';
53    /** Explicitly hidden entry */
54    public const VISIBILITY_HIDDEN = 'hidden';
55    /** Visible entry but the associated revision is hidden */
56    public const VISIBILITY_HIDDEN_IMPLICIT = 'implicit';
57    /** Explicity suppressed entry due to the filter being suppressed */
58    public const VISIBILITY_SUPPRESSED = 'suppressed';
59
60    /**
61     * @var string|null The user whose AbuseLog entries are being searched
62     */
63    private $mSearchUser;
64
65    /**
66     * @var string The start time of the search period
67     */
68    private $mSearchPeriodStart;
69
70    /**
71     * @var string The end time of the search period
72     */
73    private $mSearchPeriodEnd;
74
75    /**
76     * @var string The page of which AbuseLog entries are being searched
77     */
78    private $mSearchTitle;
79
80    /**
81     * @var string The action performed by the user
82     */
83    private $mSearchAction;
84
85    /**
86     * @var string The action taken by AbuseFilter
87     */
88    private $mSearchActionTaken;
89
90    /**
91     * @var string The wiki name where we're performing the search
92     */
93    private $mSearchWiki;
94
95    /**
96     * @var string|null The filter IDs we're looking for. Either a single one, or a pipe-separated list
97     */
98    private $mSearchFilter;
99
100    /**
101     * @var string The visibility of entries we're interested in
102     */
103    private $mSearchEntries;
104
105    /**
106     * @var string The impact of the user action, i.e. if the change has been saved
107     */
108    private $mSearchImpact;
109
110    /** @var string|null The filter group to search, as defined in $wgAbuseFilterValidGroups */
111    private $mSearchGroup;
112
113    public function __construct(
114        private readonly LBFactory $lbFactory,
115        private readonly LinkBatchFactory $linkBatchFactory,
116        private readonly PermissionManager $permissionManager,
117        private readonly UserIdentityLookup $userIdentityLookup,
118        AbuseFilterPermissionManager $afPermissionManager,
119        private readonly ConsequencesRegistry $consequencesRegistry,
120        private readonly VariablesBlobStore $varBlobStore,
121        private readonly SpecsFormatter $specsFormatter,
122        private readonly VariablesFormatter $variablesFormatter,
123        private readonly VariablesManager $varManager,
124        private readonly AbuseLoggerFactory $abuseLoggerFactory,
125        private readonly FilterLookup $filterLookup,
126        private readonly TempUserConfig $tempUserConfig,
127        private readonly ExtensionRegistry $extensionRegistry,
128        private readonly AbuseLogConditionFactory $abuseLogConditionFactory
129    ) {
130        parent::__construct( self::PAGE_NAME, $afPermissionManager );
131        $this->specsFormatter->setMessageLocalizer( $this );
132        $this->variablesFormatter->setMessageLocalizer( $this );
133    }
134
135    /** @inheritDoc */
136    public function getRestriction(): string {
137        return 'abusefilter-log';
138    }
139
140    /**
141     * @codeCoverageIgnore Merely declarative
142     * @inheritDoc
143     */
144    public function doesWrites() {
145        return true;
146    }
147
148    /**
149     * @codeCoverageIgnore Merely declarative
150     * @inheritDoc
151     */
152    protected function getGroupName() {
153        return 'changes';
154    }
155
156    /**
157     * Main routine
158     *
159     * $parameter string is converted into the $args array, which can come in
160     * three shapes:
161     *
162     * An array of size 2: only if the URL is like Special:AbuseLog/private/id
163     * where id is the log identifier. In this case, the private details of the
164     * log (e.g. IP address) will be shown.
165     *
166     * An array of size 1: either the URL is like Special:AbuseLog/id where
167     * the id is log identifier, in which case the details of the log except for
168     * private bits (e.g. IP address) are shown, or Special:AbuseLog/hide for hiding entries,
169     * or the URL is incomplete as in Special:AbuseLog/private (without specifying id),
170     * in which case a warning is shown to the user
171     *
172     * An array of size 0 when URL is like Special:AbuseLog or an array of size
173     * 1 when the URL is like Special:AbuseFilter/ (i.e. without anything after
174     * the slash). Otherwise, the abuse logs are shown as a list, with a search form above the list.
175     *
176     * @param string|null $parameter URL parameters
177     */
178    public function execute( $parameter ) {
179        $out = $this->getOutput();
180
181        $this->addNavigationLinks( 'log' );
182
183        $this->setHeaders();
184        $this->addHelpLink( 'Extension:AbuseFilter' );
185        $this->loadParameters();
186
187        $out->disableClientCache();
188
189        $out->addModuleStyles( 'ext.abuseFilter' );
190
191        $this->checkPermissions();
192
193        $args = $parameter !== null ? explode( '/', $parameter ) : [];
194
195        if ( count( $args ) === 2 && $args[0] === 'private' ) {
196            $this->showPrivateDetails( (int)$args[1] );
197        } elseif ( count( $args ) === 1 && $args[0] !== '' ) {
198            if ( $args[0] === 'private' ) {
199                $out->addWikiMsg( 'abusefilter-invalid-request-noid' );
200            } elseif ( $args[0] === 'hide' ) {
201                $this->showHideView();
202            } else {
203                $this->showDetails( $args[0] );
204            }
205        } else {
206            $this->outputHeader( 'abusefilter-log-summary' );
207            $this->searchForm();
208            $this->showList();
209        }
210    }
211
212    /**
213     * @inheritDoc
214     */
215    public function getShortDescription( string $path = '' ): string {
216        return $this->msg( 'abusefilter-topnav-log' )->text();
217    }
218
219    /**
220     * Loads parameters from request
221     */
222    public function loadParameters() {
223        $request = $this->getRequest();
224
225        $searchUsername = trim( $request->getText( 'wpSearchUser' ) );
226        $userTitle = Title::newFromText( $searchUsername, NS_USER );
227        $this->mSearchUser = $userTitle?->getText();
228        if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
229            $this->mSearchWiki = $request->getText( 'wpSearchWiki' );
230        }
231
232        $this->mSearchPeriodStart = $request->getText( 'wpSearchPeriodStart' );
233        $this->mSearchPeriodEnd = $request->getText( 'wpSearchPeriodEnd' );
234        $this->mSearchTitle = $request->getText( 'wpSearchTitle' );
235
236        $this->mSearchFilter = null;
237        $this->mSearchGroup = null;
238        if ( $this->afPermissionManager->canSeeLogDetails( $this->getAuthority() ) ) {
239            $this->mSearchFilter = $request->getText( 'wpSearchFilter' );
240            if ( count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 ) {
241                $this->mSearchGroup = $request->getText( 'wpSearchGroup' );
242            }
243        }
244
245        $this->mSearchAction = $request->getText( 'wpSearchAction' );
246        $this->mSearchActionTaken = $request->getText( 'wpSearchActionTaken' );
247        $this->mSearchEntries = $request->getText( 'wpSearchEntries' );
248        $this->mSearchImpact = $request->getText( 'wpSearchImpact' );
249    }
250
251    /**
252     * @return string[]
253     */
254    private function getAllFilterableActions() {
255        return [
256            'edit',
257            'move',
258            'upload',
259            'stashupload',
260            'delete',
261            'createaccount',
262            'autocreateaccount',
263        ];
264    }
265
266    /**
267     * Builds the search form
268     */
269    public function searchForm() {
270        $performer = $this->getAuthority();
271        $formDescriptor = [
272            'SearchUser' => [
273                'label-message' => 'abusefilter-log-search-user',
274                'type' => 'user',
275                'ipallowed' => true,
276                'iprange' => true,
277                'default' => $this->mSearchUser,
278            ],
279            'SearchPeriodStart' => [
280                'label-message' => 'abusefilter-test-period-start',
281                'type' => 'datetime',
282                'default' => $this->mSearchPeriodStart
283            ],
284            'SearchPeriodEnd' => [
285                'label-message' => 'abusefilter-test-period-end',
286                'type' => 'datetime',
287                'default' => $this->mSearchPeriodEnd
288            ],
289            'SearchTitle' => [
290                'label-message' => 'abusefilter-log-search-title',
291                'type' => 'title',
292                'interwiki' => false,
293                'default' => $this->mSearchTitle,
294                'required' => false
295            ],
296            'SearchImpact' => [
297                'label-message' => 'abusefilter-log-search-impact',
298                'type' => 'select',
299                'options' => [
300                    $this->msg( 'abusefilter-log-search-impact-all' )->text() => 0,
301                    $this->msg( 'abusefilter-log-search-impact-saved' )->text() => 1,
302                    $this->msg( 'abusefilter-log-search-impact-not-saved' )->text() => 2,
303                ],
304            ],
305        ];
306        $filterableActions = $this->getAllFilterableActions();
307        $actions = array_combine( $filterableActions, $filterableActions );
308        ksort( $actions );
309        $actions = array_merge(
310            [ $this->msg( 'abusefilter-log-search-action-any' )->text() => 'any' ],
311            $actions,
312            [ $this->msg( 'abusefilter-log-search-action-other' )->text() => 'other' ]
313        );
314        $formDescriptor['SearchAction'] = [
315            'label-message' => 'abusefilter-log-search-action-label',
316            'type' => 'select',
317            'options' => $actions,
318            'default' => 'any',
319        ];
320        $options = [];
321        foreach ( $this->consequencesRegistry->getAllActionNames() as $action ) {
322            $key = $this->specsFormatter->getActionDisplay( $action );
323            $options[$key] = $action;
324        }
325        ksort( $options );
326        $options = array_merge(
327            [ $this->msg( 'abusefilter-log-search-action-taken-any' )->text() => '' ],
328            $options,
329            [ $this->msg( 'abusefilter-log-noactions-filter' )->text() => 'noactions' ]
330        );
331        $formDescriptor['SearchActionTaken'] = [
332            'label-message' => 'abusefilter-log-search-action-taken-label',
333            'type' => 'select',
334            'options' => $options,
335        ];
336        if ( $this->afPermissionManager->canSeeHiddenLogEntries( $performer ) ) {
337            $formDescriptor['SearchEntries'] = [
338                'type' => 'select',
339                'label-message' => 'abusefilter-log-search-entries-label',
340                'options' => [
341                    $this->msg( 'abusefilter-log-search-entries-all' )->text() => 0,
342                    $this->msg( 'abusefilter-log-search-entries-hidden' )->text() => 1,
343                    $this->msg( 'abusefilter-log-search-entries-visible' )->text() => 2,
344                ],
345            ];
346        }
347
348        if ( $this->afPermissionManager->canSeeLogDetails( $performer ) ) {
349            $groups = $this->getConfig()->get( 'AbuseFilterValidGroups' );
350            if ( count( $groups ) > 1 ) {
351                $options = array_merge(
352                    [ $this->msg( 'abusefilter-log-search-group-any' )->text() => 0 ],
353                    array_combine( $groups, $groups )
354                );
355                $formDescriptor['SearchGroup'] = [
356                    'label-message' => 'abusefilter-log-search-group',
357                    'type' => 'select',
358                    'options' => $options
359                ];
360            }
361            $helpmsg = $this->getConfig()->get( 'AbuseFilterIsCentral' )
362                ? $this->msg( 'abusefilter-log-search-filter-help-central' )->escaped()
363                : $this->msg( 'abusefilter-log-search-filter-help' )
364                    ->params( GlobalNameUtils::GLOBAL_FILTER_PREFIX )->escaped();
365            $formDescriptor['SearchFilter'] = [
366                'label-message' => 'abusefilter-log-search-filter',
367                'type' => 'text',
368                'default' => $this->mSearchFilter,
369                'help' => $helpmsg
370            ];
371        }
372        if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
373            // @todo Add free form input for wiki name. Would be nice to generate
374            // a select with unique names in the db at some point.
375            $formDescriptor['SearchWiki'] = [
376                'label-message' => 'abusefilter-log-search-wiki',
377                'type' => 'text',
378                'default' => $this->mSearchWiki,
379            ];
380        }
381
382        HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
383            ->setWrapperLegendMsg( 'abusefilter-log-search' )
384            ->setSubmitTextMsg( 'abusefilter-log-search-submit' )
385            ->setMethod( 'get' )
386            ->setId( 'abusefilter-log-search' )
387            ->setCollapsibleOptions( true )
388            ->prepareForm()
389            ->displayForm( false );
390    }
391
392    private function showHideView() {
393        $view = new HideAbuseLog(
394            $this->lbFactory,
395            $this->afPermissionManager,
396            $this->getContext(),
397            $this->getLinkRenderer(),
398            $this->linkBatchFactory,
399            $this->permissionManager,
400            $this->filterLookup,
401            $this->varBlobStore,
402            self::PAGE_NAME
403        );
404        $view->show();
405    }
406
407    /**
408     * Shows the results list
409     */
410    public function showList() {
411        $out = $this->getOutput();
412        $performer = $this->getAuthority();
413
414        // Generate conditions list.
415        $dbr = $this->lbFactory->getReplicaDatabase();
416        $conds = $this->getUserFilter( $performer, $this->mSearchUser );
417
418        if ( $this->mSearchPeriodStart ) {
419            $conds[] = $dbr->expr( 'afl_timestamp', '>=',
420                $dbr->timestamp( strtotime( $this->mSearchPeriodStart ) ) );
421        }
422
423        if ( $this->mSearchPeriodEnd ) {
424            $conds[] = $dbr->expr( 'afl_timestamp', '<=',
425                $dbr->timestamp( strtotime( $this->mSearchPeriodEnd ) ) );
426        }
427
428        if ( $this->mSearchWiki ) {
429            if ( $this->mSearchWiki === WikiMap::getCurrentWikiDbDomain()->getId() ) {
430                $conds['afl_wiki'] = null;
431            } else {
432                $conds['afl_wiki'] = $this->mSearchWiki;
433            }
434        }
435
436        $groupFilters = [];
437        if ( $this->mSearchGroup ) {
438            $groupFilters = $dbr->newSelectQueryBuilder()
439                ->select( 'af_id' )
440                ->from( 'abuse_filter' )
441                ->where( [ 'af_group' => $this->mSearchGroup ] )
442                ->caller( __METHOD__ )
443                ->fetchFieldValues();
444        }
445
446        $searchFilters = [];
447        if ( $this->mSearchFilter ) {
448            $rawFilters = array_map( 'trim', explode( '|', $this->mSearchFilter ) );
449            // Map of [ [ id, global ], ... ]
450            $filtersList = [];
451            $foundInvalid = false;
452            foreach ( $rawFilters as $filter ) {
453                try {
454                    $filtersList[] = GlobalNameUtils::splitGlobalName( $filter );
455                } catch ( InvalidArgumentException ) {
456                    $foundInvalid = true;
457                    continue;
458                }
459            }
460
461            // Initialize flags for warning messages about filters excluded due to permissions
462            $excludedDueToSuppression = false;
463            $excludedDueToHidden = false;
464            $excludedDueToProtected = false;
465
466            // Evaluate the user's permissions
467            // We can't evaluate their protected variable permissions as it could be different between filter objects
468            $canViewSuppressed = $this->afPermissionManager->canViewSuppressed( $performer );
469            $canViewPrivate = $this->afPermissionManager->canViewPrivateFiltersLogs( $performer );
470
471            // Iterate over the filters the user is searching for.
472            // Remove filters from the list if the user lacks permissions to see their logs
473            // based on suppression, hidden status, or protected status.
474            foreach ( $filtersList as $index => $filterData ) {
475                try {
476                    // Use the injected filterLookup service
477                    $filter = $this->filterLookup->getFilter( ...$filterData );
478                } catch ( FilterNotFoundException ) {
479                    // Filter ID is invalid or filter doesn't exist
480                    unset( $filtersList[$index] );
481                    $foundInvalid = true;
482                    continue;
483                }
484
485                // 1. Check for suppression
486                if ( $filter->isSuppressed() && !$canViewSuppressed ) {
487                    unset( $filtersList[$index] );
488                    $excludedDueToSuppression = true;
489                    continue;
490                }
491
492                // 2. Check if filter is hidden (private)
493                if ( $filter->isHidden() && !$canViewPrivate ) {
494                    unset( $filtersList[$index] );
495                    $excludedDueToHidden = true;
496                    continue;
497                }
498
499                // 3. Check if filter is protected and user lacks permission for its variables
500                if (
501                    $filter->isProtected() &&
502                    !$this->afPermissionManager->canViewProtectedVariablesInFilter( $performer, $filter )->isGood()
503                ) {
504                    unset( $filtersList[$index] );
505                    $excludedDueToProtected = true;
506                    continue;
507                }
508            }
509
510            // Add warning messages if any filters were excluded due to specific permission restrictions
511            if ( $excludedDueToSuppression ) {
512                $out->addWikiMsg( 'abusefilter-log-suppressed-not-included' );
513            }
514            // Note: The message for "hidden" is 'abusefilter-log-private-not-included'
515            if ( $excludedDueToHidden ) {
516                $out->addWikiMsg( 'abusefilter-log-private-not-included' );
517            }
518            if ( $excludedDueToProtected ) {
519                $out->addWikiMsg( 'abusefilter-log-protected-not-included' );
520            }
521
522            // Continue with building the list of filters to search and warning for invalid IDs
523
524            if ( $foundInvalid ) {
525                $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
526                // @todo Tell what the invalid IDs are
527                $out->addHTML(
528                    Html::rawElement(
529                        'p',
530                        [],
531                        Html::warningBox( $this->msg( 'abusefilter-log-invalid-filter' )->escaped() )
532                    )
533                );
534            }
535
536            foreach ( $filtersList as $filterData ) {
537                $searchFilters[] = GlobalNameUtils::buildGlobalName( ...$filterData );
538            }
539        }
540
541        $searchIDs = null;
542        if ( $this->mSearchGroup && !$this->mSearchFilter ) {
543            $searchIDs = $groupFilters;
544        } elseif ( !$this->mSearchGroup && $this->mSearchFilter ) {
545            $searchIDs = $searchFilters;
546        } elseif ( $this->mSearchGroup && $this->mSearchFilter ) {
547            $searchIDs = array_intersect( $groupFilters, $searchFilters );
548        }
549
550        if ( $searchIDs !== null ) {
551            if ( !count( $searchIDs ) ) {
552                $out->addWikiMsg( 'abusefilter-log-noresults' );
553                return;
554            }
555
556            $filterConds = [ 'local' => [], 'global' => [] ];
557            foreach ( $searchIDs as $filter ) {
558                [ $filterID, $isGlobal ] = GlobalNameUtils::splitGlobalName( $filter );
559                $key = $isGlobal ? 'global' : 'local';
560                $filterConds[$key][] = $filterID;
561            }
562            $filterWhere = [];
563            if ( $filterConds['local'] ) {
564                $filterWhere[] = $dbr->andExpr( [
565                    'afl_global' => 0,
566                    'afl_filter_id' => $filterConds['local'],
567                ] );
568            }
569            if ( $filterConds['global'] ) {
570                $filterWhere[] = $dbr->andExpr( [
571                    'afl_global' => 1,
572                    'afl_filter_id' => $filterConds['global'],
573                ] );
574            }
575            $conds[] = $dbr->orExpr( $filterWhere );
576        }
577
578        $searchTitle = Title::newFromText( $this->mSearchTitle );
579        if ( $searchTitle ) {
580            $conds['afl_namespace'] = $searchTitle->getNamespace();
581            $conds['afl_title'] = $searchTitle->getDBkey();
582        }
583
584        if ( $this->afPermissionManager->canSeeHiddenLogEntries( $performer ) ) {
585            if ( $this->mSearchEntries === '1' ) {
586                $conds['afl_deleted'] = 1;
587            } elseif ( $this->mSearchEntries === '2' ) {
588                $conds['afl_deleted'] = 0;
589            }
590        }
591
592        if ( $this->mSearchImpact === '1' ) {
593            $conds[] = $dbr->expr( 'afl_rev_id', '!=', null );
594        } elseif ( $this->mSearchImpact === '2' ) {
595            $conds[] = $dbr->expr( 'afl_rev_id', '=', null );
596        }
597
598        if ( $this->mSearchActionTaken ) {
599            if ( in_array( $this->mSearchActionTaken, $this->consequencesRegistry->getAllActionNames() ) ) {
600                $conds[] = AbuseFilter::findInSet( $dbr, 'afl_actions', $this->mSearchActionTaken );
601            } elseif ( $this->mSearchActionTaken === 'noactions' ) {
602                $conds['afl_actions'] = '';
603            }
604        }
605
606        if ( $this->mSearchAction ) {
607            $filterableActions = $this->getAllFilterableActions();
608            if ( in_array( $this->mSearchAction, $filterableActions ) ) {
609                $conds['afl_action'] = $this->mSearchAction;
610            } elseif ( $this->mSearchAction === 'other' ) {
611                $conds[] = $dbr->expr( 'afl_action', '!=', $filterableActions );
612            }
613        }
614
615        $pager = new AbuseLogPager(
616            $this->getContext(),
617            $this->getLinkRenderer(),
618            $conds,
619            $this->linkBatchFactory,
620            $this->permissionManager,
621            $this->afPermissionManager,
622            $this->filterLookup,
623            $this->varBlobStore,
624            $this->getName()
625        );
626        $pager->doQuery();
627        $result = $pager->getResult();
628
629        $form = Html::rawElement(
630            'form',
631            [
632                'method' => 'GET',
633                'action' => $this->getPageTitle( 'hide' )->getLocalURL()
634            ],
635            $this->getDeleteButton() . $this->getListToggle() .
636                Html::rawElement( 'ul', [ 'class' => 'plainlinks' ], $pager->getBody() ) .
637                $this->getListToggle() . $this->getDeleteButton()
638        );
639
640        if ( $result && $result->numRows() !== 0 ) {
641            $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
642            $out->addHTML( $pager->getNavigationBar() . $form . $pager->getNavigationBar() );
643        } else {
644            $out->addWikiMsg( 'abusefilter-log-noresults' );
645        }
646    }
647
648    /**
649     * Returns the HTML for a button to hide selected entries
650     *
651     * @return string|ButtonInputWidget
652     */
653    private function getDeleteButton() {
654        if ( !$this->afPermissionManager->canHideAbuseLog( $this->getAuthority() ) ) {
655            return '';
656        }
657        return new ButtonInputWidget( [
658            'label' => $this->msg( 'abusefilter-log-hide-entries' )->text(),
659            'type' => 'submit'
660        ] );
661    }
662
663    /**
664     * Get the All / Invert / None options provided by
665     * ToggleList.php to mass select the checkboxes.
666     *
667     * @return string
668     */
669    private function getListToggle() {
670        if ( !$this->afPermissionManager->canHideAbuseLog( $this->getUser() ) ) {
671            return '';
672        }
673        return ( new ListToggle( $this->getOutput() ) )->getHtml();
674    }
675
676    /**
677     * @param string|int $id
678     * @suppress SecurityCheck-SQLInjection
679     */
680    public function showDetails( $id ) {
681        $out = $this->getOutput();
682        $performer = $this->getAuthority();
683
684        $pager = new AbuseLogPager(
685            $this->getContext(),
686            $this->getLinkRenderer(),
687            [],
688            $this->linkBatchFactory,
689            $this->permissionManager,
690            $this->afPermissionManager,
691            $this->filterLookup,
692            $this->varBlobStore,
693            $this->getName()
694        );
695
696        [
697            'tables' => $tables,
698            'fields' => $fields,
699            'join_conds' => $join_conds,
700        ] = $pager->getQueryInfo();
701
702        $dbr = $this->lbFactory->getReplicaDatabase();
703        $row = $dbr->newSelectQueryBuilder()
704            ->tables( $tables )
705            ->fields( $fields )
706            ->where( [ 'afl_id' => $id ] )
707            ->caller( __METHOD__ )
708            ->joinConds( $join_conds )
709            ->fetchRow();
710
711        if ( !$row ) {
712            $out->addWikiMsg( 'abusefilter-log-nonexistent' );
713            return;
714        }
715
716        $error = null;
717        $filterID = $row->afl_filter_id;
718        $global = $row->afl_global;
719
720        try {
721            $filter = $this->filterLookup->getFilter( $filterID, $global );
722        } catch ( CentralDBNotAvailableException ) {
723            // Conservatively assume that it's hidden and protected, like in AbuseLogPager::doFormatRow
724            $filter = MutableFilter::newDefault();
725            $filter->setHidden( true );
726            $filter->setProtected( true );
727        }
728
729        if ( !$this->afPermissionManager->canSeeLogDetailsForFilter( $performer, $filter ) ) {
730            $error = 'abusefilter-log-cannot-see-details';
731        } else {
732            $visibility = self::getEntryVisibilityForUser( $row, $performer, $this->afPermissionManager );
733            if ( $visibility === self::VISIBILITY_HIDDEN ) {
734                $error = 'abusefilter-log-details-hidden';
735            } elseif ( $visibility === self::VISIBILITY_HIDDEN_IMPLICIT ) {
736                $error = 'abusefilter-log-details-hidden-implicit';
737            } elseif ( $visibility === self::VISIBILITY_SUPPRESSED ) {
738                $error = 'abusefilter-log-details-suppressed';
739            }
740        }
741
742        if ( $error ) {
743            $out->addWikiMsg( $error );
744            return;
745        }
746
747        // Load data
748        $vars = $this->varBlobStore->loadVarDump( $row );
749        $varsArray = $this->varManager->dumpAllVars( $vars, $this->afPermissionManager->getProtectedVariables() );
750
751        // Prevent users seeing logs which contain protected variables that the user cannot see.
752        if ( $filter->isProtected() ) {
753            $permStatus = $this->afPermissionManager->canViewProtectedVariables(
754                $performer, array_keys( $vars->getVars() )
755            );
756            if ( !$permStatus->isGood() ) {
757                if ( $permStatus->getPermission() ) {
758                    $out->addWikiMsg( $this->msg(
759                        'abusefilter-examine-error-protected-due-to-permission',
760                        $this->msg( "action-{$permStatus->getPermission()}" )->plain()
761                    ) );
762                    return;
763                }
764
765                // Add any messages in the status after a generic error message.
766                $additional = '';
767                foreach ( $permStatus->getMessages() as $message ) {
768                    $additional .= $this->msg( $message )->parseAsBlock();
769                }
770
771                $out->addWikiMsg( $this->msg( 'abusefilter-examine-error-protected' )->rawParams( $additional ) );
772                return;
773            }
774
775            $userAuthority = $this->getAuthority();
776            $protectedVariableValuesShown = [];
777            foreach ( $this->afPermissionManager->getProtectedVariables() as $protectedVariable ) {
778                if ( isset( $varsArray[$protectedVariable] ) ) {
779                    $protectedVariableValuesShown[] = $protectedVariable;
780                }
781            }
782
783            if ( count( $protectedVariableValuesShown ) ) {
784                $this->checkReadOnly();
785
786                $logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
787                $logger->logViewProtectedVariableValue(
788                    $userAuthority->getUser(),
789                    $varsArray['user_name'] ?? $varsArray['account_name'],
790                    $protectedVariableValuesShown
791                );
792            }
793        }
794
795        $output = Html::element(
796            'legend',
797            [],
798            $this->msg( 'abusefilter-log-details-legend' )
799                ->params( $this->getLanguage()->formatNumNoSeparators( $id ) )
800                ->text()
801        );
802        $output .= Html::rawElement( 'p', [], $pager->doFormatRow( $row, false ) );
803        $out->addJsConfigVars( 'wgAbuseFilterVariables', $varsArray );
804        $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
805
806        // Diff, if available
807        if ( $row->afl_action === 'edit' ) {
808            // Guard for exception because these variables may be unset in case of data corruption (T264513)
809            // No need to lazy-load as these come from a DB dump.
810            try {
811                $old_wikitext = $vars->getComputedVariable( 'old_wikitext' )->toString();
812            } catch ( UnsetVariableException ) {
813                $old_wikitext = '';
814            }
815            try {
816                $new_wikitext = $vars->getComputedVariable( 'new_wikitext' )->toString();
817            } catch ( UnsetVariableException ) {
818                $new_wikitext = '';
819            }
820
821            $diffEngine = new DifferenceEngine( $this->getContext() );
822
823            $diffEngine->showDiffStyle();
824
825            $formattedDiff = $diffEngine->addHeader(
826                $diffEngine->generateTextDiffBody( $old_wikitext, $new_wikitext ),
827                '', ''
828            );
829
830            $output .=
831                Html::rawElement(
832                    'h3',
833                    [],
834                    $this->msg( 'abusefilter-log-details-diff' )->parse()
835                );
836
837            $output .= $formattedDiff;
838        }
839
840        $output .= Html::element( 'h3', [], $this->msg( 'abusefilter-log-details-vars' )->text() );
841
842        // Build a table.
843        $output .= $this->variablesFormatter->buildVarDumpTable( $vars );
844
845        if ( $this->afPermissionManager->canSeePrivateDetails( $performer ) ) {
846            $formDescriptor = [
847                'Reason' => [
848                    'label-message' => 'abusefilter-view-privatedetails-reason',
849                    'type' => 'text',
850                    'size' => 45,
851                ],
852            ];
853
854            $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
855            $htmlForm->setTitle( $this->getPageTitle( 'private/' . $id ) )
856                ->setWrapperLegendMsg( 'abusefilter-view-privatedetails-legend' )
857                ->setSubmitTextMsg( 'abusefilter-view-privatedetails-submit' )
858                ->prepareForm();
859
860            $output .= $htmlForm->getHTML( false );
861        }
862
863        $out->addHTML( Html::rawElement( 'fieldset', [], $output ) );
864    }
865
866    /**
867     * Helper function to select a row with private details and some more context
868     * for an AbuseLog entry.
869     *
870     * @deprecated Since 1.45. Use {@link AbuseFilterLogDetailsLookup::getIPForAbuseFilterLog} instead.
871     * @param Authority $authority The user who's trying to view the row
872     * @param int $id The ID of the log entry
873     * @return Status<stdClass> A status object with the requested row stored in the value property,
874     *  or an error and no row.
875     */
876    public static function getPrivateDetailsRow( Authority $authority, $id ) {
877        $afLogPrivateDetailsLookup = AbuseFilterServices::getLogDetailsLookup();
878
879        $ipForAbuseFilterLogStatus = $afLogPrivateDetailsLookup->getIPForAbuseFilterLog( $authority, $id );
880        if ( !$ipForAbuseFilterLogStatus->isGood() ) {
881            return Status::wrap( $ipForAbuseFilterLogStatus );
882        }
883
884        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
885        $row = $dbr->newSelectQueryBuilder()
886            ->select( [ 'afl_id', 'afl_user_text', 'afl_filter_id', 'afl_global', 'afl_timestamp' ] )
887            ->from( 'abuse_filter_log' )
888            ->where( [ 'afl_id' => $id ] )
889            ->caller( __METHOD__ )
890            ->fetchRow();
891
892        if ( !$row ) {
893            return Status::newFatal( 'abusefilter-log-nonexistent' );
894        }
895
896        // Append the afl_ip to the row
897        $row->afl_ip = $ipForAbuseFilterLogStatus->getValue();
898
899        // Append details about the filter to the row.
900        $filter = AbuseFilterServices::getFilterLookup()->getFilter( $row->afl_filter_id, $row->afl_global );
901        $row->af_id = (string)$filter->getId();
902        $row->af_public_comments = $filter->getName();
903        $row->af_hidden = $filter->getPrivacyLevel();
904
905        return Status::newGood( $row );
906    }
907
908    /**
909     * Builds an HTML table with the private details for a given abuseLog entry.
910     *
911     * @param stdClass $row The row, as returned by self::getPrivateDetailsRow()
912     * @return string The HTML output
913     */
914    private function buildPrivateDetailsTable( $row ) {
915        $output = '';
916
917        // Log ID
918        $linkRenderer = $this->getLinkRenderer();
919        $output .=
920            Html::rawElement( 'tr', [],
921                Html::element( 'td',
922                    [ 'style' => 'width: 30%;' ],
923                    $this->msg( 'abusefilter-log-details-id' )->text()
924                ) .
925                Html::rawElement( 'td', [], $linkRenderer->makeKnownLink(
926                    $this->getPageTitle( $row->afl_id ),
927                    $this->getLanguage()->formatNumNoSeparators( $row->afl_id )
928                ) )
929            );
930
931        // Timestamp
932        $output .=
933            Html::rawElement( 'tr', [],
934                Html::element( 'td',
935                    [ 'style' => 'width: 30%;' ],
936                    $this->msg( 'abusefilter-edit-builder-vars-timestamp-expanded' )->text()
937                ) .
938                Html::element( 'td',
939                    [],
940                    $this->getLanguage()->userTimeAndDate( $row->afl_timestamp, $this->getUser() )
941                )
942            );
943
944        // User
945        $output .=
946            Html::rawElement( 'tr', [],
947                Html::element( 'td',
948                    [ 'style' => 'width: 30%;' ],
949                    $this->msg( 'abusefilter-edit-builder-vars-user-name' )->text()
950                ) .
951                Html::element( 'td',
952                    [],
953                    $row->afl_user_text
954                )
955            );
956
957        // Filter ID
958        $output .=
959            Html::rawElement( 'tr', [],
960                Html::element( 'td',
961                    [ 'style' => 'width: 30%;' ],
962                    $this->msg( 'abusefilter-list-id' )->text()
963                ) .
964                Html::rawElement( 'td', [], $linkRenderer->makeKnownLink(
965                    SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ),
966                    $this->getLanguage()->formatNum( $row->af_id )
967                ) )
968            );
969
970        // Filter description
971        $output .=
972            Html::rawElement( 'tr', [],
973                Html::element( 'td',
974                    [ 'style' => 'width: 30%;' ],
975                    $this->msg( 'abusefilter-list-public' )->text()
976                ) .
977                Html::element( 'td',
978                    [],
979                    $row->af_public_comments
980                )
981            );
982
983        // IP address
984        if ( $row->afl_ip !== '' ) {
985            if ( $this->extensionRegistry->isLoaded( 'CheckUser' ) &&
986                $this->permissionManager->userHasRight( $this->getUser(), 'checkuser' )
987            ) {
988                $CULink = '&nbsp;&middot;&nbsp;' . $linkRenderer->makeKnownLink(
989                    SpecialPage::getTitleFor(
990                        'CheckUser',
991                        $row->afl_ip
992                    ),
993                    $this->msg( 'abusefilter-log-details-checkuser' )->text()
994                );
995            } else {
996                $CULink = '';
997            }
998            $output .=
999                Html::rawElement( 'tr', [],
1000                    Html::element( 'td',
1001                        [ 'style' => 'width: 30%;' ],
1002                        $this->msg( 'abusefilter-log-details-ip' )->text()
1003                    ) .
1004                    Html::rawElement(
1005                        'td',
1006                        [],
1007                        $linkRenderer->makeUserLink( new UserIdentityValue( 0, $row->afl_ip ), $this->getContext() )
1008                            . Linker::userToolLinks( 0, $row->afl_ip, true )
1009                            . $CULink
1010                    )
1011                );
1012        } else {
1013            $output .=
1014                Html::rawElement( 'tr', [],
1015                    Html::element( 'td',
1016                        [ 'style' => 'width: 30%;' ],
1017                        $this->msg( 'abusefilter-log-details-ip' )->text()
1018                    ) .
1019                    Html::element(
1020                        'td',
1021                        [],
1022                        $this->msg( 'abusefilter-log-ip-not-available' )->text()
1023                    )
1024                );
1025        }
1026
1027        return Html::rawElement( 'fieldset', [],
1028            Html::element( 'legend', [],
1029                $this->msg( 'abusefilter-log-details-privatedetails' )->text()
1030            ) .
1031            Html::rawElement( 'table',
1032                [
1033                    'class' => 'wikitable mw-abuselog-private',
1034                    'style' => 'width: 80%;'
1035                ],
1036                Html::rawElement( 'thead', [],
1037                    Html::rawElement( 'tr', [],
1038                        Html::element( 'th', [],
1039                            $this->msg( 'abusefilter-log-details-var' )->text()
1040                        ) .
1041                        Html::element( 'th', [],
1042                            $this->msg( 'abusefilter-log-details-val' )->text()
1043                        )
1044                    )
1045                ) .
1046                Html::rawElement( 'tbody', [], $output )
1047            )
1048        );
1049    }
1050
1051    /**
1052     * @param int $id
1053     * @return void
1054     */
1055    public function showPrivateDetails( $id ) {
1056        $out = $this->getOutput();
1057        $user = $this->getUser();
1058
1059        if ( !$this->afPermissionManager->canSeePrivateDetails( $user ) ) {
1060            $out->addWikiMsg( 'abusefilter-log-cannot-see-privatedetails' );
1061
1062            return;
1063        }
1064        $request = $this->getRequest();
1065
1066        // Make sure it is a valid request
1067        $token = $request->getVal( 'wpEditToken' );
1068        if ( !$request->wasPosted() || !$user->matchEditToken( $token ) ) {
1069            $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
1070            $out->addHTML(
1071                Html::rawElement(
1072                    'p',
1073                    [],
1074                    Html::errorBox( $this->msg( 'abusefilter-invalid-request' )->params( $id )->parse() )
1075                )
1076            );
1077
1078            return;
1079        }
1080
1081        $reason = $request->getText( 'wpReason' );
1082        if ( !self::checkPrivateDetailsAccessReason( $reason ) ) {
1083            $out->addWikiMsg( 'abusefilter-noreason' );
1084            $this->showDetails( $id );
1085            return;
1086        }
1087
1088        $status = self::getPrivateDetailsRow( $user, $id );
1089        if ( !$status->isGood() ) {
1090            $out->addWikiMsg( $status->getMessages()[0] );
1091            return;
1092        }
1093        $row = $status->getValue();
1094
1095        // Log accessing private details
1096        if ( $this->getConfig()->get( 'AbuseFilterLogPrivateDetailsAccess' ) ) {
1097            self::addPrivateDetailsAccessLogEntry( $id, $reason, $user );
1098        }
1099
1100        // Show private details (IP).
1101        $table = $this->buildPrivateDetailsTable( $row );
1102        $out->addHTML( $table );
1103    }
1104
1105    /**
1106     * Returns an array with the conditions required for filtering out entries
1107     * not associated with the provided username.
1108     *
1109     * If temporary accounts are enabled and the user can reveal their IP
1110     * addresses, the filter returned allows to:
1111     *
1112     * - Lookup temporary accounts that used the IP via afl_ip_hex
1113     * - Lookup temporary accounts that used an IP in a range via afl_ip_hex
1114     * - Lookup historical anonymous edits via matches on afl_user_text
1115     *
1116     * Otherwise, if the provided username corresponds to an existing account,
1117     * this method lists entries by the given user (using both its ID and name);
1118     * if it doesn't (i.e. "IP users"), this lists entries where the name matches
1119     * the account and the user ID is zero.
1120     *
1121     * @param Authority $performer Authority accessing the AbuseLog.
1122     * @param ?string $userName Username or IP address to filter for.
1123     */
1124    private function getUserFilter(
1125        Authority $performer,
1126        ?string $userName
1127    ): array {
1128        if ( $userName === null ) {
1129            return [];
1130        }
1131
1132        $conditions = [];
1133
1134        if (
1135            IPUtils::isIPAddress( $userName ) &&
1136            $this->tempUserConfig->isKnown() &&
1137            $this->canAccessTemporaryAccountIPAddresses( $performer )
1138        ) {
1139            $expression = $this->abuseLogConditionFactory
1140                ->getUserFilterByIPAddress( $userName );
1141
1142            if ( $expression ) {
1143                $conditions[] = $expression;
1144            }
1145        } else {
1146            // Otherwise search only afl_user and afl_user_text
1147            $searchedUser = $this->userIdentityLookup
1148                ->getUserIdentityByName( $userName );
1149
1150            if ( !$searchedUser ) {
1151                $searchedUser = new UserIdentityValue( 0, $userName );
1152            }
1153
1154            $conditions = array_merge(
1155                $conditions,
1156                $this->abuseLogConditionFactory->getUserFilterByUserIdentity(
1157                    $searchedUser
1158                )
1159            );
1160        }
1161
1162        return $conditions;
1163    }
1164
1165    /**
1166     * If specifying a reason for viewing private details of abuse log is required
1167     * then it makes sure that a reason is provided.
1168     *
1169     * @param string $reason
1170     * @return bool
1171     */
1172    public static function checkPrivateDetailsAccessReason( $reason ) {
1173        global $wgAbuseFilterPrivateDetailsForceReason;
1174        return ( !$wgAbuseFilterPrivateDetailsForceReason || strlen( $reason ) > 0 );
1175    }
1176
1177    /**
1178     * @param int $logID int The ID of the AbuseFilter log that was accessed
1179     * @param string $reason The reason provided for accessing private details
1180     * @param UserIdentity $userIdentity The user who accessed the private details
1181     * @return void
1182     */
1183    public static function addPrivateDetailsAccessLogEntry( $logID, $reason, UserIdentity $userIdentity ) {
1184        $target = self::getTitleFor( self::PAGE_NAME, (string)$logID );
1185
1186        $logEntry = new ManualLogEntry( 'abusefilterprivatedetails', 'access' );
1187        $logEntry->setPerformer( $userIdentity );
1188        $logEntry->setTarget( $target );
1189        $logEntry->setParameters( [
1190            '4::logid' => $logID,
1191        ] );
1192        $logEntry->setComment( $reason );
1193
1194        $logEntry->insert();
1195    }
1196
1197    /**
1198     * @param stdClass $row
1199     * @param Authority $authority
1200     * @param AbuseFilterPermissionManager $afPermissionManager
1201     * @return string One of the self::VISIBILITY_* constants
1202     */
1203    public static function getEntryVisibilityForUser(
1204        stdClass $row,
1205        Authority $authority,
1206        AbuseFilterPermissionManager $afPermissionManager
1207    ): string {
1208        // Check if the filter related to the log entry is suppressed.
1209        // This check takes precedence over other visibility states.
1210        $filter = AbuseFilterServices::getFilterLookup()->getFilter( $row->afl_filter_id, (bool)$row->afl_global );
1211        if ( $filter->isSuppressed() ) {
1212            if ( !$afPermissionManager->canViewSuppressed( $authority ) ) {
1213                return self::VISIBILITY_SUPPRESSED;
1214            }
1215        }
1216
1217        if ( $row->afl_deleted && !$afPermissionManager->canSeeHiddenLogEntries( $authority ) ) {
1218            return self::VISIBILITY_HIDDEN;
1219        }
1220        if ( !$row->afl_rev_id ) {
1221            return self::VISIBILITY_VISIBLE;
1222        }
1223        $vis = (int)MediaWikiServices::getInstance()
1224            ->getRevisionLookup()
1225            ->getRevisionById( (int)$row->afl_rev_id )
1226            ?->getVisibility();
1227        if ( $vis === 0 ) {
1228            return self::VISIBILITY_VISIBLE;
1229        }
1230
1231        // Check if this specific revision (not the entire filter) is suppressed or hidden.
1232        return AbuseFilterPermissionManager::hasRevisionAccess( $vis, $authority )
1233            ? self::VISIBILITY_VISIBLE
1234            : self::VISIBILITY_HIDDEN_IMPLICIT;
1235    }
1236
1237    private function canAccessTemporaryAccountIPAddresses(
1238        Authority $performer
1239    ): bool {
1240        if ( !$this->extensionRegistry->isLoaded( 'CheckUser' ) ) {
1241            return false;
1242        }
1243
1244        return MediaWikiServices::getInstance()
1245            ->getService( 'CheckUserPermissionManager' )
1246            ->canAccessTemporaryAccountIPAddresses( $performer )
1247            ->isGood();
1248    }
1249}