Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.50% covered (warning)
67.50%
162 / 240
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseLogPager
67.50% covered (warning)
67.50%
162 / 240
33.33% covered (danger)
33.33%
3 / 9
163.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 formatRow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doFormatRow
66.01% covered (warning)
66.01%
101 / 153
0.00% covered (danger)
0.00%
0 / 1
83.09
 canSeeUndeleteDiffForPage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 canSeeUndeleteDiffs
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 getQueryInfo
76.60% covered (warning)
76.60%
36 / 47
0.00% covered (danger)
0.00%
0 / 1
3.12
 preprocessResults
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 entryHasAssociatedDeletedRev
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
 isHidingEntry
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getIndexField
n/a
0 / 0
n/a
0 / 0
1
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\Pager;
4
5use HtmlArmor;
6use MediaWiki\Cache\LinkBatchFactory;
7use MediaWiki\Context\IContextSource;
8use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
9use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
10use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
11use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
12use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
13use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
14use MediaWiki\Html\Html;
15use MediaWiki\Linker\Linker;
16use MediaWiki\Linker\LinkRenderer;
17use MediaWiki\Linker\LinkTarget;
18use MediaWiki\MediaWikiServices;
19use MediaWiki\Pager\ReverseChronologicalPager;
20use MediaWiki\Parser\Sanitizer;
21use MediaWiki\Permissions\PermissionManager;
22use MediaWiki\SpecialPage\SpecialPage;
23use MediaWiki\Title\Title;
24use MediaWiki\User\UserIdentityValue;
25use MediaWiki\WikiMap\WikiMap;
26use stdClass;
27use Wikimedia\Rdbms\IResultWrapper;
28
29class AbuseLogPager extends ReverseChronologicalPager {
30    /**
31     * @var array
32     */
33    private $conds;
34
35    /** @var string */
36    private $basePageName;
37
38    /**
39     * @var string[] Map of [ id => show|hide ], for entries that we're currently (un)hiding
40     */
41    private $hideEntries;
42
43    private LinkBatchFactory $linkBatchFactory;
44    private PermissionManager $permissionManager;
45    private AbuseFilterPermissionManager $afPermissionManager;
46    private VariablesBlobStore $varBlobStore;
47
48    public function __construct(
49        IContextSource $context,
50        LinkRenderer $linkRenderer,
51        array $conds,
52        LinkBatchFactory $linkBatchFactory,
53        PermissionManager $permManager,
54        AbuseFilterPermissionManager $afPermissionManager,
55        VariablesBlobStore $varBlobStore,
56        string $basePageName,
57        array $hideEntries = []
58    ) {
59        parent::__construct( $context, $linkRenderer );
60        $this->conds = $conds;
61        $this->linkBatchFactory = $linkBatchFactory;
62        $this->permissionManager = $permManager;
63        $this->afPermissionManager = $afPermissionManager;
64        $this->varBlobStore = $varBlobStore;
65        $this->basePageName = $basePageName;
66        $this->hideEntries = $hideEntries;
67    }
68
69    /**
70     * @param stdClass $row
71     * @return string
72     */
73    public function formatRow( $row ) {
74        return $this->doFormatRow( $row );
75    }
76
77    /**
78     * @param stdClass $row
79     * @param bool $isListItem
80     * @return string
81     */
82    public function doFormatRow( stdClass $row, bool $isListItem = true ): string {
83        $performer = $this->getAuthority();
84        $visibility = SpecialAbuseLog::getEntryVisibilityForUser( $row, $performer, $this->afPermissionManager );
85
86        if ( $visibility !== SpecialAbuseLog::VISIBILITY_VISIBLE ) {
87            return '';
88        }
89
90        $linkRenderer = $this->getLinkRenderer();
91        $diffLink = false;
92
93        if ( !$row->afl_wiki ) {
94            $title = Title::makeTitle( $row->afl_namespace, $row->afl_title );
95
96            $pageLink = $linkRenderer->makeLink(
97                $title,
98                null,
99                [],
100                [ 'redirect' => 'no' ]
101            );
102            if ( $row->rev_id ) {
103                $diffLink = $linkRenderer->makeKnownLink(
104                    $title,
105                    new HtmlArmor( $this->msg( 'abusefilter-log-diff' )->parse() ),
106                    [],
107                    [ 'diff' => 'prev', 'oldid' => $row->rev_id ]
108                );
109            } elseif (
110                isset( $row->ar_timestamp ) && $row->ar_timestamp
111                && $this->canSeeUndeleteDiffForPage( $title )
112            ) {
113                $diffLink = $linkRenderer->makeKnownLink(
114                    SpecialPage::getTitleFor( 'Undelete' ),
115                    new HtmlArmor( $this->msg( 'abusefilter-log-diff' )->parse() ),
116                    [],
117                    [
118                        'diff' => 'prev',
119                        'target' => $title->getPrefixedText(),
120                        'timestamp' => $row->ar_timestamp,
121                    ]
122                );
123            }
124        } else {
125            $pageLink = WikiMap::makeForeignLink( $row->afl_wiki, $row->afl_title );
126
127            if ( $row->afl_rev_id ) {
128                $diffUrl = WikiMap::getForeignURL( $row->afl_wiki, $row->afl_title );
129                $diffUrl = wfAppendQuery( $diffUrl,
130                    [ 'diff' => 'prev', 'oldid' => $row->afl_rev_id ] );
131
132                $diffLink = Linker::makeExternalLink( $diffUrl,
133                    $this->msg( 'abusefilter-log-diff' )->text() );
134            }
135        }
136
137        if ( !$row->afl_wiki ) {
138            // Local user
139            $userLink = SpecialAbuseLog::getUserLinks( $row->afl_user, $row->afl_user_text );
140        } else {
141            $userLink = WikiMap::foreignUserLink( $row->afl_wiki, $row->afl_user_text ) . ' ' .
142                $this->msg( 'parentheses' )->params( WikiMap::getWikiName( $row->afl_wiki ) )->escaped();
143        }
144
145        $lang = $this->getLanguage();
146        $timestamp = htmlspecialchars( $lang->userTimeAndDate( $row->afl_timestamp, $this->getUser() ) );
147
148        $actions_takenRaw = $row->afl_actions;
149        if ( !strlen( trim( $actions_takenRaw ) ) ) {
150            $actions_taken = $this->msg( 'abusefilter-log-noactions' )->escaped();
151        } else {
152            $actions = explode( ',', $actions_takenRaw );
153            $displayActions = [];
154
155            $specsFormatter = AbuseFilterServices::getSpecsFormatter();
156            $specsFormatter->setMessageLocalizer( $this->getContext() );
157            foreach ( $actions as $action ) {
158                $displayActions[] = $specsFormatter->getActionDisplay( $action );
159            }
160            $actions_taken = $lang->commaList( $displayActions );
161        }
162
163        $filterID = $row->afl_filter_id;
164        $global = $row->afl_global;
165
166        // Pull global filter description
167        $lookup = AbuseFilterServices::getFilterLookup();
168        try {
169            $filterObj = $lookup->getFilter( $filterID, $global );
170            $escaped_comments = Sanitizer::escapeHtmlAllowEntities( $filterObj->getName() );
171        } catch ( CentralDBNotAvailableException $_ ) {
172            $escaped_comments = $this->msg( 'abusefilter-log-description-not-available' )->escaped();
173            // either hide all filters, including not hidden/protected, or show all, including hidden/protected
174            // we choose the former
175            $filterObj = MutableFilter::newDefault();
176            $filterObj->setProtected( true );
177            $filterObj->setHidden( true );
178        }
179
180        // Determine if the user has access to the associated filter and also the details of the current log
181        // These are used to determine what links to display for the log line.
182        $canSeeLogDetails = $this->afPermissionManager->canSeeLogDetailsForFilter( $performer, $filterObj );
183        $canSeeFilter = $canSeeLogDetails;
184        if ( $filterObj->isProtected() ) {
185            $canSeeFilter = $canSeeFilter &&
186                $this->afPermissionManager->canViewProtectedVariablesInFilter( $performer, $filterObj )->isGood();
187
188            if ( $canSeeLogDetails ) {
189                $vars = $this->varBlobStore->loadVarDump( $row );
190                $canSeeLogDetails = $this->afPermissionManager->canViewProtectedVariables(
191                    $performer, array_keys( $vars->getVars() )
192                )->isGood();
193            }
194        }
195
196        if ( $canSeeFilter ) {
197            $actionLinks = [];
198
199            if ( $canSeeLogDetails ) {
200                if ( $isListItem ) {
201                    $detailsLink = $linkRenderer->makeKnownLink(
202                        SpecialPage::getTitleFor( $this->basePageName, $row->afl_id ),
203                        $this->msg( 'abusefilter-log-detailslink' )->text()
204                    );
205                    $actionLinks[] = $detailsLink;
206                }
207
208                $examineTitle = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/log/' . $row->afl_id );
209                $examineLink = $linkRenderer->makeKnownLink(
210                    $examineTitle,
211                    new HtmlArmor( $this->msg( 'abusefilter-changeslist-examine' )->parse() )
212                );
213                $actionLinks[] = $examineLink;
214            }
215
216            if ( $diffLink ) {
217                $actionLinks[] = $diffLink;
218            }
219
220            if ( !$isListItem && $canSeeLogDetails && $this->afPermissionManager->canHideAbuseLog( $performer ) ) {
221                // Link for hiding a single entry from the details view
222                $hideLink = $linkRenderer->makeKnownLink(
223                    SpecialPage::getTitleFor( $this->basePageName, 'hide' ),
224                    $this->msg( 'abusefilter-log-hidelink' )->text(),
225                    [],
226                    [ "hideids[$row->afl_id]" => 1 ]
227                );
228
229                $actionLinks[] = $hideLink;
230            }
231
232            if ( $global ) {
233                $centralDb = $this->getConfig()->get( 'AbuseFilterCentralDB' );
234                $linkMsg = $this->msg( 'abusefilter-log-detailedentry-global' )
235                    ->numParams( $filterID );
236                if ( $centralDb !== null ) {
237                    $globalURL = WikiMap::getForeignURL(
238                        $centralDb,
239                        'Special:AbuseFilter/' . $filterID
240                    );
241                    $filterLink = Linker::makeExternalLink( $globalURL, $linkMsg->text() );
242                } else {
243                    $filterLink = $linkMsg->escaped();
244                }
245            } else {
246                $title = SpecialPage::getTitleFor( 'AbuseFilter', (string)$filterID );
247                $linkText = $this->msg( 'abusefilter-log-detailedentry-local' )
248                    ->numParams( $filterID )->text();
249                $filterLink = $linkRenderer->makeKnownLink( $title, $linkText );
250            }
251
252            if ( count( $actionLinks ) ) {
253                $msg = 'abusefilter-log-detailedentry-meta';
254            } else {
255                $msg = 'abusefilter-log-detailedentry-meta-without-action-links';
256            }
257            $description = $this->msg( $msg )->rawParams(
258                $timestamp,
259                $userLink,
260                $filterLink,
261                htmlspecialchars( $row->afl_action ),
262                $pageLink,
263                $actions_taken,
264                $escaped_comments,
265                // Passing $8 to 'abusefilter-log-detailedentry-meta-without-action-links' will do nothing,
266                // as it's not used.
267                $lang->pipeList( $actionLinks )
268            )->params( $row->afl_user_text )->parse();
269        } else {
270            if ( $diffLink ) {
271                $msg = 'abusefilter-log-entry-withdiff';
272            } else {
273                $msg = 'abusefilter-log-entry';
274            }
275            $description = $this->msg( $msg )->rawParams(
276                $timestamp,
277                $userLink,
278                htmlspecialchars( $row->afl_action ),
279                $pageLink,
280                $actions_taken,
281                $escaped_comments,
282                // Passing $7 to 'abusefilter-log-entry' will do nothing, as it's not used.
283                $diffLink ?: ''
284            )->params( $row->afl_user_text )->parse();
285        }
286
287        $attribs = [];
288        if (
289            $this->isHidingEntry( $row ) === true ||
290            // If isHidingEntry is false, we've just unhidden the row
291            ( $this->isHidingEntry( $row ) === null && $row->afl_deleted )
292        ) {
293            $attribs['class'] = 'mw-abusefilter-log-hidden-entry';
294        }
295        if ( self::entryHasAssociatedDeletedRev( $row ) ) {
296            $description .= ' ' .
297                $this->msg( 'abusefilter-log-hidden-implicit' )->parse();
298        }
299
300        if ( $isListItem && !$this->hideEntries && $this->afPermissionManager->canHideAbuseLog( $performer ) ) {
301            // Checkbox for hiding multiple entries, single entries are handled above
302            $description = Html::check( 'hideids[' . $row->afl_id . ']' ) . ' ' . $description;
303        }
304
305        if ( $isListItem ) {
306            return Html::rawElement( 'li', $attribs, $description );
307        } else {
308            return Html::rawElement( 'span', $attribs, $description );
309        }
310    }
311
312    /**
313     * Can this user see diffs generated by Special:Undelete for the page?
314     * @see MediaWiki\Specials\SpecialUndelete
315     * @param LinkTarget $page
316     *
317     * @return bool
318     */
319    private function canSeeUndeleteDiffForPage( LinkTarget $page ): bool {
320        if ( !$this->canSeeUndeleteDiffs() ) {
321            return false;
322        }
323
324        foreach ( [ 'deletedtext', 'undelete' ] as $action ) {
325            if ( $this->permissionManager->userCan(
326                $action, $this->getUser(), $page, PermissionManager::RIGOR_QUICK
327            ) ) {
328                return true;
329            }
330        }
331
332        return false;
333    }
334
335    /**
336     * Can this user see diffs generated by Special:Undelete?
337     * @see MediaWiki\Specials\SpecialUndelete
338     *
339     * @return bool
340     */
341    private function canSeeUndeleteDiffs(): bool {
342        if ( !$this->permissionManager->userHasRight( $this->getUser(), 'deletedhistory' ) ) {
343            return false;
344        }
345
346        return $this->permissionManager->userHasAnyRight(
347            $this->getUser(), 'deletedtext', 'undelete' );
348    }
349
350    /**
351     * @return array
352     */
353    public function getQueryInfo() {
354        $info = [
355            'tables' => [ 'abuse_filter_log', 'revision' ],
356            'fields' => [
357                'afl_id',
358                'afl_global',
359                'afl_filter_id',
360                'afl_user',
361                'afl_ip',
362                'afl_user_text',
363                'afl_action',
364                'afl_actions',
365                'afl_var_dump',
366                'afl_timestamp',
367                'afl_namespace',
368                'afl_title',
369                'afl_wiki',
370                'afl_deleted',
371                'afl_rev_id',
372                'rev_id',
373            ],
374            'conds' => $this->conds,
375            'join_conds' => [
376                'revision' => [
377                    'LEFT JOIN',
378                    [
379                        'afl_wiki' => null,
380                        $this->mDb->expr( 'afl_rev_id', '!=', null ),
381                        'rev_id=afl_rev_id',
382                    ]
383                ],
384            ],
385        ];
386
387        if ( $this->canSeeUndeleteDiffs() ) {
388            $info['tables'][] = 'archive';
389            $info['fields'][] = 'ar_timestamp';
390            $info['join_conds']['archive'] = [
391                'LEFT JOIN',
392                [
393                    'afl_wiki' => null,
394                    $this->mDb->expr( 'afl_rev_id', '!=', null ),
395                    'rev_id' => null,
396                    'ar_rev_id=afl_rev_id',
397                ]
398            ];
399        }
400
401        if ( !$this->afPermissionManager->canSeeHiddenLogEntries( $this->getAuthority() ) ) {
402            $info['conds']['afl_deleted'] = 0;
403        }
404
405        return $info;
406    }
407
408    /**
409     * @param IResultWrapper $result
410     */
411    protected function preprocessResults( $result ) {
412        if ( $this->getNumRows() === 0 ) {
413            return;
414        }
415
416        $lb = $this->linkBatchFactory->newLinkBatch();
417        $lb->setCaller( __METHOD__ );
418        foreach ( $result as $row ) {
419            // Only for local wiki results
420            if ( !$row->afl_wiki ) {
421                $lb->add( $row->afl_namespace, $row->afl_title );
422                $lb->addUser( new UserIdentityValue( $row->afl_user ?? 0, $row->afl_user_text ) );
423            }
424        }
425        $lb->execute();
426        $result->seek( 0 );
427    }
428
429    /**
430     * @param stdClass $row
431     * @return bool
432     * @todo This should be moved elsewhere
433     */
434    private static function entryHasAssociatedDeletedRev( stdClass $row ): bool {
435        if ( !$row->afl_rev_id ) {
436            return false;
437        }
438        $revision = MediaWikiServices::getInstance()
439            ->getRevisionLookup()
440            ->getRevisionById( $row->afl_rev_id );
441        return $revision && $revision->getVisibility() !== 0;
442    }
443
444    /**
445     * Check whether the entry passed in is being currently hidden/unhidden.
446     * This is used to format the entries list shown when updating visibility, and is necessary because
447     * when we decide whether to display the entry as hidden the DB hasn't been updated yet.
448     *
449     * @param stdClass $row
450     * @return bool|null True if just hidden, false if just unhidden, null if untouched
451     */
452    private function isHidingEntry( stdClass $row ): ?bool {
453        if ( isset( $this->hideEntries[ $row->afl_id ] ) ) {
454            return $this->hideEntries[ $row->afl_id ] === 'hide';
455        }
456        return null;
457    }
458
459    /**
460     * @codeCoverageIgnore Merely declarative
461     * @inheritDoc
462     */
463    public function getIndexField() {
464        return 'afl_timestamp';
465    }
466}