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