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