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