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