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