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->makeList(
583                    [ 'afl_global' => 0, 'afl_filter_id' => $filterConds['local'] ],
584                    LIST_AND
585                );
586            }
587            if ( $filterConds['global'] ) {
588                $filterWhere[] = $dbr->makeList(
589                    [ 'afl_global' => 1, 'afl_filter_id' => $filterConds['global'] ],
590                    LIST_AND
591                );
592            }
593            $conds[] = $dbr->makeList( $filterWhere, LIST_OR );
594        }
595
596        $searchTitle = Title::newFromText( $this->mSearchTitle );
597        if ( $searchTitle ) {
598            $conds['afl_namespace'] = $searchTitle->getNamespace();
599            $conds['afl_title'] = $searchTitle->getDBkey();
600        }
601
602        if ( $this->afPermissionManager->canSeeHiddenLogEntries( $performer ) ) {
603            if ( $this->mSearchEntries === '1' ) {
604                $conds['afl_deleted'] = 1;
605            } elseif ( $this->mSearchEntries === '2' ) {
606                $conds['afl_deleted'] = 0;
607            }
608        }
609
610        if ( $this->mSearchImpact === '1' ) {
611            $conds[] = $dbr->expr( 'afl_rev_id', '!=', null );
612        } elseif ( $this->mSearchImpact === '2' ) {
613            $conds[] = $dbr->expr( 'afl_rev_id', '=', null );
614        }
615
616        if ( $this->mSearchActionTaken ) {
617            if ( in_array( $this->mSearchActionTaken, $this->consequencesRegistry->getAllActionNames() ) ) {
618                $conds[] = $dbr->expr( 'afl_actions', '=', $this->mSearchActionTaken )
619                    ->or( 'afl_actions', IExpression::LIKE, new LikeValue(
620                        $this->mSearchActionTaken, ',', $dbr->anyString()
621                    ) )
622                    ->or( 'afl_actions', IExpression::LIKE, new LikeValue(
623                        $dbr->anyString(), ',', $this->mSearchActionTaken
624                    ) )
625                    ->or( 'afl_actions', IExpression::LIKE, new LikeValue(
626                        $dbr->anyString(),
627                        ',', $this->mSearchActionTaken, ',',
628                        $dbr->anyString()
629                    ) );
630            } elseif ( $this->mSearchActionTaken === 'noactions' ) {
631                $conds['afl_actions'] = '';
632            }
633        }
634
635        if ( $this->mSearchAction ) {
636            $filterableActions = $this->getAllFilterableActions();
637            if ( in_array( $this->mSearchAction, $filterableActions ) ) {
638                $conds['afl_action'] = $this->mSearchAction;
639            } elseif ( $this->mSearchAction === 'other' ) {
640                $conds[] = $dbr->expr( 'afl_action', '!=', $filterableActions );
641            }
642        }
643
644        $pager = new AbuseLogPager(
645            $this->getContext(),
646            $this->getLinkRenderer(),
647            $conds,
648            $this->linkBatchFactory,
649            $this->permissionManager,
650            $this->afPermissionManager,
651            $this->getName()
652        );
653        $pager->doQuery();
654        $result = $pager->getResult();
655
656        $form = Html::rawElement(
657            'form',
658            [
659                'method' => 'GET',
660                'action' => $this->getPageTitle( 'hide' )->getLocalURL()
661            ],
662            $this->getDeleteButton() . $this->getListToggle() .
663                Html::rawElement( 'ul', [ 'class' => 'plainlinks' ], $pager->getBody() ) .
664                $this->getListToggle() . $this->getDeleteButton()
665        );
666
667        if ( $result && $result->numRows() !== 0 ) {
668            $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
669            $out->addHTML( $pager->getNavigationBar() . $form . $pager->getNavigationBar() );
670        } else {
671            $out->addWikiMsg( 'abusefilter-log-noresults' );
672        }
673    }
674
675    /**
676     * Returns the HTML for a button to hide selected entries
677     *
678     * @return string|ButtonInputWidget
679     */
680    private function getDeleteButton() {
681        if ( !$this->afPermissionManager->canHideAbuseLog( $this->getAuthority() ) ) {
682            return '';
683        }
684        return new ButtonInputWidget( [
685            'label' => $this->msg( 'abusefilter-log-hide-entries' )->text(),
686            'type' => 'submit'
687        ] );
688    }
689
690    /**
691     * Get the All / Invert / None options provided by
692     * ToggleList.php to mass select the checkboxes.
693     *
694     * @return string
695     */
696    private function getListToggle() {
697        if ( !$this->afPermissionManager->canHideAbuseLog( $this->getUser() ) ) {
698            return '';
699        }
700        return ( new ListToggle( $this->getOutput() ) )->getHtml();
701    }
702
703    /**
704     * @param string|int $id
705     * @suppress SecurityCheck-SQLInjection
706     */
707    public function showDetails( $id ) {
708        $out = $this->getOutput();
709        $performer = $this->getAuthority();
710
711        $pager = new AbuseLogPager(
712            $this->getContext(),
713            $this->getLinkRenderer(),
714            [],
715            $this->linkBatchFactory,
716            $this->permissionManager,
717            $this->afPermissionManager,
718            $this->getName()
719        );
720
721        [
722            'tables' => $tables,
723            'fields' => $fields,
724            'join_conds' => $join_conds,
725        ] = $pager->getQueryInfo();
726
727        $dbr = $this->lbFactory->getReplicaDatabase();
728        $row = $dbr->newSelectQueryBuilder()
729            ->tables( $tables )
730            ->fields( $fields )
731            ->where( [ 'afl_id' => $id ] )
732            ->caller( __METHOD__ )
733            ->joinConds( $join_conds )
734            ->fetchRow();
735
736        $error = null;
737        if ( !$row ) {
738            $error = 'abusefilter-log-nonexistent';
739        } else {
740            $filterID = $row->afl_filter_id;
741            $global = $row->afl_global;
742
743            if ( $global ) {
744                try {
745                    $privacyLevel = AbuseFilterServices::getFilterLookup()->getFilter( $filterID, $global )
746                        ->getPrivacyLevel();
747                } catch ( CentralDBNotAvailableException $_ ) {
748                    // Conservatively assume that it's hidden and protected, like in formatRow
749                    $privacyLevel = Flags::FILTER_HIDDEN & Flags::FILTER_USES_PROTECTED_VARS;
750                }
751            } else {
752                $privacyLevel = $row->af_hidden;
753            }
754
755            if ( !$this->afPermissionManager->canSeeLogDetailsForFilter( $performer, $privacyLevel ) ) {
756                $error = 'abusefilter-log-cannot-see-details';
757            } else {
758                $visibility = self::getEntryVisibilityForUser( $row, $performer, $this->afPermissionManager );
759                if ( $visibility === self::VISIBILITY_HIDDEN ) {
760                    $error = 'abusefilter-log-details-hidden';
761                } elseif ( $visibility === self::VISIBILITY_HIDDEN_IMPLICIT ) {
762                    $error = 'abusefilter-log-details-hidden-implicit';
763                }
764            }
765        }
766
767        if ( $error ) {
768            $out->addWikiMsg( $error );
769            return;
770        }
771
772        $output = Html::element(
773            'legend',
774            [],
775            $this->msg( 'abusefilter-log-details-legend' )
776                ->numParams( $id )
777                ->text()
778        );
779        $output .= Html::rawElement( 'p', [], $pager->doFormatRow( $row, false ) );
780
781        // Load data
782        $vars = $this->varBlobStore->loadVarDump( $row );
783
784        $out->addJsConfigVars( 'wgAbuseFilterVariables', $this->varManager->dumpAllVars( $vars, true ) );
785        $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
786
787        // Diff, if available
788        if ( $row->afl_action === 'edit' ) {
789            // Guard for exception because these variables may be unset in case of data corruption (T264513)
790            // No need to lazy-load as these come from a DB dump.
791            try {
792                $old_wikitext = $vars->getComputedVariable( 'old_wikitext' )->toString();
793            } catch ( UnsetVariableException $_ ) {
794                $old_wikitext = '';
795            }
796            try {
797                $new_wikitext = $vars->getComputedVariable( 'new_wikitext' )->toString();
798            } catch ( UnsetVariableException $_ ) {
799                $new_wikitext = '';
800            }
801
802            $diffEngine = new DifferenceEngine( $this->getContext() );
803
804            $diffEngine->showDiffStyle();
805
806            $formattedDiff = $diffEngine->addHeader(
807                $diffEngine->generateTextDiffBody( $old_wikitext, $new_wikitext ),
808                '', ''
809            );
810
811            $output .=
812                Html::rawElement(
813                    'h3',
814                    [],
815                    $this->msg( 'abusefilter-log-details-diff' )->parse()
816                );
817
818            $output .= $formattedDiff;
819        }
820
821        $output .= Html::element( 'h3', [], $this->msg( 'abusefilter-log-details-vars' )->text() );
822
823        // Build a table.
824        $output .= $this->variablesFormatter->buildVarDumpTable( $vars );
825
826        if ( $this->afPermissionManager->canSeePrivateDetails( $performer ) ) {
827            $formDescriptor = [
828                'Reason' => [
829                    'label-message' => 'abusefilter-view-privatedetails-reason',
830                    'type' => 'text',
831                    'size' => 45,
832                ],
833            ];
834
835            $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
836            $htmlForm->setTitle( $this->getPageTitle( 'private/' . $id ) )
837                ->setWrapperLegendMsg( 'abusefilter-view-privatedetails-legend' )
838                ->setSubmitTextMsg( 'abusefilter-view-privatedetails-submit' )
839                ->prepareForm();
840
841            $output .= $htmlForm->getHTML( false );
842        }
843
844        $out->addHTML( Html::rawElement( 'fieldset', [], $output ) );
845    }
846
847    /**
848     * Helper function to select a row with private details and some more context
849     * for an AbuseLog entry.
850     * @todo Create a service for this
851     *
852     * @param Authority $authority The user who's trying to view the row
853     * @param int $id The ID of the log entry
854     * @return Status A status object with the requested row stored in the value property,
855     *  or an error and no row.
856     */
857    public static function getPrivateDetailsRow( Authority $authority, $id ) {
858        $afPermManager = AbuseFilterServices::getPermissionManager();
859        $dbr = MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->getReplicaDatabase();
860
861        $row = $dbr->newSelectQueryBuilder()
862            ->select( [ 'afl_id', 'afl_user_text', 'afl_filter_id', 'afl_global', 'afl_timestamp', 'afl_ip',
863                'af_id', 'af_public_comments', 'af_hidden' ] )
864            ->from( 'abuse_filter_log' )
865            ->leftJoin( 'abuse_filter', null, [ 'af_id=afl_filter_id', 'afl_global' => 0 ] )
866            ->where( [ 'afl_id' => $id ] )
867            ->caller( __METHOD__ )
868            ->fetchRow();
869
870        $status = Status::newGood();
871        if ( !$row ) {
872            $status->fatal( 'abusefilter-log-nonexistent' );
873            return $status;
874        }
875
876        $filterID = $row->afl_filter_id;
877        $global = $row->afl_global;
878
879        if ( $global ) {
880            $lookup = AbuseFilterServices::getFilterLookup();
881            $privacyLevel = $lookup->getFilter( $filterID, $global )->getPrivacyLevel();
882        } else {
883            $privacyLevel = $row->af_hidden;
884        }
885
886        if ( !$afPermManager->canSeeLogDetailsForFilter( $authority, $privacyLevel ) ) {
887            $status->fatal( 'abusefilter-log-cannot-see-details' );
888            return $status;
889        }
890        $status->setResult( true, $row );
891        return $status;
892    }
893
894    /**
895     * Builds an HTML table with the private details for a given abuseLog entry.
896     *
897     * @param stdClass $row The row, as returned by self::getPrivateDetailsRow()
898     * @return string The HTML output
899     */
900    private function buildPrivateDetailsTable( $row ) {
901        $output = '';
902
903        // Log ID
904        $linkRenderer = $this->getLinkRenderer();
905        $output .=
906            Html::rawElement( 'tr', [],
907                Html::element( 'td',
908                    [ 'style' => 'width: 30%;' ],
909                    $this->msg( 'abusefilter-log-details-id' )->text()
910                ) .
911                Html::rawElement( 'td', [], $linkRenderer->makeKnownLink(
912                    $this->getPageTitle( $row->afl_id ),
913                    $this->getLanguage()->formatNum( $row->afl_id )
914                ) )
915            );
916
917        // Timestamp
918        $output .=
919            Html::rawElement( 'tr', [],
920                Html::element( 'td',
921                    [ 'style' => 'width: 30%;' ],
922                    $this->msg( 'abusefilter-edit-builder-vars-timestamp-expanded' )->text()
923                ) .
924                Html::element( 'td',
925                    [],
926                    $this->getLanguage()->userTimeAndDate( $row->afl_timestamp, $this->getUser() )
927                )
928            );
929
930        // User
931        $output .=
932            Html::rawElement( 'tr', [],
933                Html::element( 'td',
934                    [ 'style' => 'width: 30%;' ],
935                    $this->msg( 'abusefilter-edit-builder-vars-user-name' )->text()
936                ) .
937                Html::element( 'td',
938                    [],
939                    $row->afl_user_text
940                )
941            );
942
943        // Filter ID
944        $output .=
945            Html::rawElement( 'tr', [],
946                Html::element( 'td',
947                    [ 'style' => 'width: 30%;' ],
948                    $this->msg( 'abusefilter-list-id' )->text()
949                ) .
950                Html::rawElement( 'td', [], $linkRenderer->makeKnownLink(
951                    SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ),
952                    $this->getLanguage()->formatNum( $row->af_id )
953                ) )
954            );
955
956        // Filter description
957        $output .=
958            Html::rawElement( 'tr', [],
959                Html::element( 'td',
960                    [ 'style' => 'width: 30%;' ],
961                    $this->msg( 'abusefilter-list-public' )->text()
962                ) .
963                Html::element( 'td',
964                    [],
965                    $row->af_public_comments
966                )
967            );
968
969        // IP address
970        if ( $row->afl_ip !== '' ) {
971            if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) &&
972                $this->permissionManager->userHasRight( $this->getUser(), 'checkuser' )
973            ) {
974                $CULink = '&nbsp;&middot;&nbsp;' . $linkRenderer->makeKnownLink(
975                    SpecialPage::getTitleFor(
976                        'CheckUser',
977                        $row->afl_ip
978                    ),
979                    $this->msg( 'abusefilter-log-details-checkuser' )->text()
980                );
981            } else {
982                $CULink = '';
983            }
984            $output .=
985                Html::rawElement( 'tr', [],
986                    Html::element( 'td',
987                        [ 'style' => 'width: 30%;' ],
988                        $this->msg( 'abusefilter-log-details-ip' )->text()
989                    ) .
990                    Html::rawElement(
991                        'td',
992                        [],
993                        self::getUserLinks( 0, $row->afl_ip ) . $CULink
994                    )
995                );
996        } else {
997            $output .=
998                Html::rawElement( 'tr', [],
999                    Html::element( 'td',
1000                        [ 'style' => 'width: 30%;' ],
1001                        $this->msg( 'abusefilter-log-details-ip' )->text()
1002                    ) .
1003                    Html::element(
1004                        'td',
1005                        [],
1006                        $this->msg( 'abusefilter-log-ip-not-available' )->text()
1007                    )
1008                );
1009        }
1010
1011        return Html::rawElement( 'fieldset', [],
1012            Html::element( 'legend', [],
1013                $this->msg( 'abusefilter-log-details-privatedetails' )->text()
1014            ) .
1015            Html::rawElement( 'table',
1016                [
1017                    'class' => 'wikitable mw-abuselog-private',
1018                    'style' => 'width: 80%;'
1019                ],
1020                Html::rawElement( 'thead', [],
1021                    Html::rawElement( 'tr', [],
1022                        Html::element( 'th', [],
1023                            $this->msg( 'abusefilter-log-details-var' )->text()
1024                        ) .
1025                        Html::element( 'th', [],
1026                            $this->msg( 'abusefilter-log-details-val' )->text()
1027                        )
1028                    )
1029                ) .
1030                Html::rawElement( 'tbody', [], $output )
1031            )
1032        );
1033    }
1034
1035    /**
1036     * @param int $id
1037     * @return void
1038     */
1039    public function showPrivateDetails( $id ) {
1040        $out = $this->getOutput();
1041        $user = $this->getUser();
1042
1043        if ( !$this->afPermissionManager->canSeePrivateDetails( $user ) ) {
1044            $out->addWikiMsg( 'abusefilter-log-cannot-see-privatedetails' );
1045
1046            return;
1047        }
1048        $request = $this->getRequest();
1049
1050        // Make sure it is a valid request
1051        $token = $request->getVal( 'wpEditToken' );
1052        if ( !$request->wasPosted() || !$user->matchEditToken( $token ) ) {
1053            $out->addHTML(
1054                Html::rawElement(
1055                    'p',
1056                    [],
1057                    Html::errorBox( $this->msg( 'abusefilter-invalid-request' )->params( $id )->parse() )
1058                )
1059            );
1060
1061            return;
1062        }
1063
1064        $reason = $request->getText( 'wpReason' );
1065        if ( !self::checkPrivateDetailsAccessReason( $reason ) ) {
1066            $out->addWikiMsg( 'abusefilter-noreason' );
1067            $this->showDetails( $id );
1068            return;
1069        }
1070
1071        $status = self::getPrivateDetailsRow( $user, $id );
1072        if ( !$status->isGood() ) {
1073            $out->addWikiMsg( $status->getMessages()[0] );
1074            return;
1075        }
1076        $row = $status->getValue();
1077
1078        // Log accessing private details
1079        if ( $this->getConfig()->get( 'AbuseFilterLogPrivateDetailsAccess' ) ) {
1080            self::addPrivateDetailsAccessLogEntry( $id, $reason, $user );
1081        }
1082
1083        // Show private details (IP).
1084        $table = $this->buildPrivateDetailsTable( $row );
1085        $out->addHTML( $table );
1086    }
1087
1088    /**
1089     * If specifying a reason for viewing private details of abuse log is required
1090     * then it makes sure that a reason is provided.
1091     *
1092     * @param string $reason
1093     * @return bool
1094     */
1095    public static function checkPrivateDetailsAccessReason( $reason ) {
1096        global $wgAbuseFilterPrivateDetailsForceReason;
1097        return ( !$wgAbuseFilterPrivateDetailsForceReason || strlen( $reason ) > 0 );
1098    }
1099
1100    /**
1101     * @param int $logID int The ID of the AbuseFilter log that was accessed
1102     * @param string $reason The reason provided for accessing private details
1103     * @param UserIdentity $userIdentity The user who accessed the private details
1104     * @return void
1105     */
1106    public static function addPrivateDetailsAccessLogEntry( $logID, $reason, UserIdentity $userIdentity ) {
1107        $target = self::getTitleFor( self::PAGE_NAME, (string)$logID );
1108
1109        $logEntry = new ManualLogEntry( 'abusefilterprivatedetails', 'access' );
1110        $logEntry->setPerformer( $userIdentity );
1111        $logEntry->setTarget( $target );
1112        $logEntry->setParameters( [
1113            '4::logid' => $logID,
1114        ] );
1115        $logEntry->setComment( $reason );
1116
1117        $logEntry->insert();
1118    }
1119
1120    /**
1121     * @param int $userId
1122     * @param string $userName
1123     * @return string
1124     */
1125    public static function getUserLinks( $userId, $userName ) {
1126        static $cache = [];
1127
1128        if ( !isset( $cache[$userName][$userId] ) ) {
1129            $cache[$userName][$userId] = Linker::userLink( $userId, $userName ) .
1130                Linker::userToolLinks( $userId, $userName, true );
1131        }
1132
1133        return $cache[$userName][$userId];
1134    }
1135
1136    /**
1137     * @param stdClass $row
1138     * @param Authority $authority
1139     * @param AbuseFilterPermissionManager $afPermManager
1140     * @return string One of the self::VISIBILITY_* constants
1141     */
1142    public static function getEntryVisibilityForUser(
1143        stdClass $row,
1144        Authority $authority,
1145        AbuseFilterPermissionManager $afPermManager
1146    ): string {
1147        if ( $row->afl_deleted && !$afPermManager->canSeeHiddenLogEntries( $authority ) ) {
1148            return self::VISIBILITY_HIDDEN;
1149        }
1150        if ( !$row->afl_rev_id ) {
1151            return self::VISIBILITY_VISIBLE;
1152        }
1153        $revRec = MediaWikiServices::getInstance()
1154            ->getRevisionLookup()
1155            ->getRevisionById( (int)$row->afl_rev_id );
1156        if ( !$revRec || $revRec->getVisibility() === 0 ) {
1157            return self::VISIBILITY_VISIBLE;
1158        }
1159        return $revRec->audienceCan( RevisionRecord::SUPPRESSED_ALL, RevisionRecord::FOR_THIS_USER, $authority )
1160            ? self::VISIBILITY_VISIBLE
1161            : self::VISIBILITY_HIDDEN_IMPLICIT;
1162    }
1163}