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