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