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