Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 225
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterPager
0.00% covered (danger)
0.00%
0 / 225
0.00% covered (danger)
0.00%
0 / 11
3782
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
 getQueryInfo
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
2
 preprocessResults
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 reallyDoQuery
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 matchesPattern
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getFieldNames
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 formatValue
0.00% covered (danger)
0.00%
0 / 86
0.00% covered (danger)
0.00%
0 / 1
462
 getHighlightedPattern
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
56
 getDefaultSort
n/a
0 / 0
n/a
0 / 0
1
 getTableClass
n/a
0 / 0
n/a
0 / 0
1
 getRowClass
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getIndexField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFieldSortable
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\Pager;
4
5use LogicException;
6use MediaWiki\Cache\LinkBatchFactory;
7use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
8use MediaWiki\Extension\AbuseFilter\FilterUtils;
9use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
10use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewList;
11use MediaWiki\Linker\Linker;
12use MediaWiki\Linker\LinkRenderer;
13use MediaWiki\Pager\TablePager;
14use MediaWiki\SpecialPage\SpecialPage;
15use stdClass;
16use UnexpectedValueException;
17use Wikimedia\Rdbms\FakeResultWrapper;
18use Wikimedia\Rdbms\IResultWrapper;
19
20/**
21 * Class to build paginated filter list
22 */
23class AbuseFilterPager extends TablePager {
24
25    /**
26     * The unique sort fields for the sort options for unique paginate
27     */
28    private const INDEX_FIELDS = [
29        'af_id' => [ 'af_id' ],
30        'af_enabled' => [ 'af_enabled', 'af_deleted', 'af_id' ],
31        'af_timestamp' => [ 'af_timestamp', 'af_id' ],
32        'af_hidden' => [ 'af_hidden', 'af_id' ],
33        'af_group' => [ 'af_group', 'af_id' ],
34        'af_hit_count' => [ 'af_hit_count', 'af_id' ],
35        'af_public_comments' => [ 'af_public_comments', 'af_id' ],
36    ];
37
38    /** @var ?LinkBatchFactory */
39    private $linkBatchFactory;
40
41    /** @var AbuseFilterPermissionManager */
42    private $afPermManager;
43
44    /** @var SpecsFormatter */
45    protected $specsFormatter;
46
47    /**
48     * @var AbuseFilterViewList The associated page
49     */
50    private $mPage;
51    /**
52     * @var array Query WHERE conditions
53     */
54    private $conds;
55    /**
56     * @var string|null The pattern being searched
57     */
58    private $searchPattern;
59    /**
60     * @var string|null The pattern search mode (LIKE, RLIKE or IRLIKE)
61     */
62    private $searchMode;
63
64    /**
65     * @param AbuseFilterViewList $page
66     * @param LinkRenderer $linkRenderer
67     * @param ?LinkBatchFactory $linkBatchFactory
68     * @param AbuseFilterPermissionManager $afPermManager
69     * @param SpecsFormatter $specsFormatter
70     * @param array $conds
71     * @param ?string $searchPattern Null if no pattern was specified
72     * @param ?string $searchMode
73     */
74    public function __construct(
75        AbuseFilterViewList $page,
76        LinkRenderer $linkRenderer,
77        ?LinkBatchFactory $linkBatchFactory,
78        AbuseFilterPermissionManager $afPermManager,
79        SpecsFormatter $specsFormatter,
80        array $conds,
81        ?string $searchPattern,
82        ?string $searchMode
83    ) {
84        // needed by parent's constructor call
85        $this->afPermManager = $afPermManager;
86        $this->specsFormatter = $specsFormatter;
87        parent::__construct( $page->getContext(), $linkRenderer );
88        $this->mPage = $page;
89        $this->linkBatchFactory = $linkBatchFactory;
90        $this->conds = $conds;
91        $this->searchPattern = $searchPattern;
92        $this->searchMode = $searchMode;
93    }
94
95    /**
96     * @return array
97     */
98    public function getQueryInfo() {
99        return [
100            'tables' => [ 'abuse_filter', 'actor' ],
101            'fields' => [
102                // All columns but af_comments
103                'af_id',
104                'af_enabled',
105                'af_deleted',
106                'af_pattern',
107                'af_global',
108                'af_public_comments',
109                'af_user' => 'actor_user',
110                'af_user_text' => 'actor_name',
111                'af_hidden',
112                'af_hit_count',
113                'af_timestamp',
114                'af_actions',
115                'af_group',
116                'af_throttled'
117            ],
118            'conds' => $this->conds,
119            'join_conds' => [
120                'actor' => [ 'JOIN', 'actor_id = af_actor' ],
121            ]
122        ];
123    }
124
125    /**
126     * @param IResultWrapper $result
127     */
128    protected function preprocessResults( $result ) {
129        // LinkBatchFactory only provided and needed for local wiki results
130        if ( $this->linkBatchFactory === null || $this->getNumRows() === 0 ) {
131            return;
132        }
133
134        $lb = $this->linkBatchFactory->newLinkBatch();
135        $lb->setCaller( __METHOD__ );
136        foreach ( $result as $row ) {
137            $lb->add( NS_USER, $row->af_user_text );
138            $lb->add( NS_USER_TALK, $row->af_user_text );
139        }
140        $lb->execute();
141        $result->seek( 0 );
142    }
143
144    /**
145     * @inheritDoc
146     * This is the same as the parent implementation if no search pattern was specified.
147     * Otherwise, it does a query with no limit and then slices the results Ã  la ContribsPager.
148     */
149    public function reallyDoQuery( $offset, $limit, $order ) {
150        if ( $this->searchMode === null ) {
151            return parent::reallyDoQuery( $offset, $limit, $order );
152        }
153
154        [ $tables, $fields, $conds, $fname, $options, $join_conds ] =
155            $this->buildQueryInfo( $offset, $limit, $order );
156
157        unset( $options['LIMIT'] );
158        $res = $this->mDb->newSelectQueryBuilder()
159            ->tables( $tables )
160            ->fields( $fields )
161            ->conds( $conds )
162            ->caller( $fname )
163            ->options( $options )
164            ->joinConds( $join_conds )
165            ->fetchResultSet();
166
167        $filtered = [];
168        foreach ( $res as $row ) {
169            if ( $this->matchesPattern( $row->af_pattern ) ) {
170                $filtered[$row->af_id] = $row;
171            }
172        }
173
174        // sort results and enforce limit like ContribsPager
175        if ( $order === self::QUERY_ASCENDING ) {
176            ksort( $filtered );
177        } else {
178            krsort( $filtered );
179        }
180        $filtered = array_slice( $filtered, 0, $limit );
181        $filtered = array_values( $filtered );
182        return new FakeResultWrapper( $filtered );
183    }
184
185    /**
186     * Check whether $subject matches the given $pattern.
187     *
188     * @param string $subject
189     * @return bool
190     * @throws LogicException
191     */
192    private function matchesPattern( $subject ) {
193        $pattern = $this->searchPattern;
194        switch ( $this->searchMode ) {
195            case 'RLIKE':
196                return (bool)preg_match( "/$pattern/u", $subject );
197            case 'IRLIKE':
198                return (bool)preg_match( "/$pattern/ui", $subject );
199            case 'LIKE':
200                return mb_stripos( $subject, $pattern ) !== false;
201            default:
202                throw new LogicException( "Unknown search type {$this->searchMode}" );
203        }
204    }
205
206    /**
207     * Note: this method is called by parent::__construct
208     * @return array
209     * @see MediaWiki\Pager\Pager::getFieldNames()
210     */
211    public function getFieldNames() {
212        $headers = [
213            'af_id' => 'abusefilter-list-id',
214            'af_public_comments' => 'abusefilter-list-public',
215            'af_actions' => 'abusefilter-list-consequences',
216            'af_enabled' => 'abusefilter-list-status',
217            'af_timestamp' => 'abusefilter-list-lastmodified',
218            'af_hidden' => 'abusefilter-list-visibility',
219        ];
220
221        $performer = $this->getAuthority();
222        if ( $this->afPermManager->canSeeLogDetails( $performer ) ) {
223            $headers['af_hit_count'] = 'abusefilter-list-hitcount';
224        }
225
226        if (
227                $this->afPermManager->canViewPrivateFilters( $performer ) &&
228                $this->searchMode !== null
229        ) {
230            // This is also excluded in the default view
231            $headers['af_pattern'] = 'abusefilter-list-pattern';
232        }
233
234        if ( count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 ) {
235            $headers['af_group'] = 'abusefilter-list-group';
236        }
237
238        foreach ( $headers as &$msg ) {
239            $msg = $this->msg( $msg )->text();
240        }
241
242        return $headers;
243    }
244
245    /**
246     * @param string $name
247     * @param string|null $value
248     * @return string
249     */
250    public function formatValue( $name, $value ) {
251        $lang = $this->getLanguage();
252        $user = $this->getUser();
253        $linkRenderer = $this->getLinkRenderer();
254        $row = $this->mCurrentRow;
255
256        switch ( $name ) {
257            case 'af_id':
258                return $linkRenderer->makeLink(
259                    SpecialPage::getTitleFor( 'AbuseFilter', $value ),
260                    $lang->formatNum( intval( $value ) )
261                );
262            case 'af_pattern':
263                return $this->getHighlightedPattern( $row );
264            case 'af_public_comments':
265                return $linkRenderer->makeLink(
266                    SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ),
267                    $value
268                );
269            case 'af_actions':
270                $actions = explode( ',', $value );
271                $displayActions = [];
272                foreach ( $actions as $action ) {
273                    $displayActions[] = $this->specsFormatter->getActionDisplay( $action );
274                }
275                return $lang->commaList( $displayActions );
276            case 'af_enabled':
277                $statuses = [];
278                if ( $row->af_deleted ) {
279                    $statuses[] = $this->msg( 'abusefilter-deleted' )->parse();
280                } elseif ( $row->af_enabled ) {
281                    $statuses[] = $this->msg( 'abusefilter-enabled' )->parse();
282                    if ( $row->af_throttled ) {
283                        $statuses[] = $this->msg( 'abusefilter-throttled' )->parse();
284                    }
285                } else {
286                    $statuses[] = $this->msg( 'abusefilter-disabled' )->parse();
287                }
288
289                if ( $row->af_global && $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
290                    $statuses[] = $this->msg( 'abusefilter-status-global' )->parse();
291                }
292
293                return $lang->commaList( $statuses );
294            case 'af_hidden':
295                $flagMsgs = [];
296                if ( FilterUtils::isHidden( (int)$value ) ) {
297                    $flagMsgs[] = $this->msg( 'abusefilter-hidden' )->parse();
298                }
299                if ( FilterUtils::isProtected( (int)$value ) ) {
300                    $flagMsgs[] = $this->msg( 'abusefilter-protected' )->parse();
301                }
302                if ( !$flagMsgs ) {
303                    return $this->msg( 'abusefilter-unhidden' )->parse();
304                }
305                return $lang->commaList( $flagMsgs );
306            case 'af_hit_count':
307                if ( $this->afPermManager->canSeeLogDetailsForFilter( $user, $row->af_hidden ) ) {
308                    $count_display = $this->msg( 'abusefilter-hitcount' )
309                        ->numParams( $value )->text();
310                    $link = $linkRenderer->makeKnownLink(
311                        SpecialPage::getTitleFor( 'AbuseLog' ),
312                        $count_display,
313                        [],
314                        [ 'wpSearchFilter' => $row->af_id ]
315                    );
316                } else {
317                    $link = "";
318                }
319                return $link;
320            case 'af_timestamp':
321                $userLink =
322                    Linker::userLink(
323                        $row->af_user ?? 0,
324                        $row->af_user_text
325                    ) .
326                    Linker::userToolLinks(
327                        $row->af_user ?? 0,
328                        $row->af_user_text
329                    );
330
331                return $this->msg( 'abusefilter-edit-lastmod-text' )
332                    ->rawParams(
333                        $this->mPage->getLinkToLatestDiff(
334                            $row->af_id,
335                            $lang->userTimeAndDate( $value, $user )
336                        ),
337                        $userLink,
338                        $this->mPage->getLinkToLatestDiff(
339                            $row->af_id,
340                            $lang->userDate( $value, $user )
341                        ),
342                        $this->mPage->getLinkToLatestDiff(
343                            $row->af_id,
344                            $lang->userTime( $value, $user )
345                        )
346                    )->params(
347                        wfEscapeWikiText( $row->af_user_text )
348                    )->parse();
349            case 'af_group':
350                return $this->specsFormatter->nameGroup( $value );
351            default:
352                throw new UnexpectedValueException( "Unknown row type $name!" );
353        }
354    }
355
356    /**
357     * Get the filter pattern with <b> elements surrounding the searched pattern
358     *
359     * @param stdClass $row
360     * @return string
361     */
362    private function getHighlightedPattern( stdClass $row ) {
363        if ( $this->searchMode === null ) {
364            throw new LogicException( 'Cannot search without a mode.' );
365        }
366        $maxLen = 50;
367        if ( $this->searchMode === 'LIKE' ) {
368            $position = mb_stripos( $row->af_pattern, $this->searchPattern );
369            $length = mb_strlen( $this->searchPattern );
370        } else {
371            $regex = '/' . $this->searchPattern . '/u';
372            if ( $this->searchMode === 'IRLIKE' ) {
373                $regex .= 'i';
374            }
375
376            $matches = [];
377            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
378            $check = @preg_match(
379                $regex,
380                $row->af_pattern,
381                $matches
382            );
383            // This may happen in case of catastrophic backtracking, or regexps matching
384            // the empty string.
385            if ( $check === false || strlen( $matches[0] ) === 0 ) {
386                return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50 ) );
387            }
388
389            $length = mb_strlen( $matches[0] );
390            $position = mb_strpos( $row->af_pattern, $matches[0] );
391        }
392
393        $remaining = $maxLen - $length;
394        if ( $remaining <= 0 ) {
395            $pattern = '<b>' .
396                htmlspecialchars( mb_substr( $row->af_pattern, $position, $maxLen ) ) .
397                '</b>';
398        } else {
399            // Center the snippet on the matched string
400            $minoffset = max( $position - round( $remaining / 2 ), 0 );
401            $pattern = mb_substr( $row->af_pattern, $minoffset, $maxLen );
402            $pattern =
403                htmlspecialchars( mb_substr( $pattern, 0, $position - $minoffset ) ) .
404                '<b>' .
405                htmlspecialchars( mb_substr( $pattern, $position - $minoffset, $length ) ) .
406                '</b>' .
407                htmlspecialchars( mb_substr(
408                        $pattern,
409                        $position - $minoffset + $length,
410                        $remaining - ( $position - $minoffset + $length )
411                    )
412                );
413        }
414        return $pattern;
415    }
416
417    /**
418     * @codeCoverageIgnore Merely declarative
419     * @inheritDoc
420     */
421    public function getDefaultSort() {
422        return 'af_id';
423    }
424
425    /**
426     * @codeCoverageIgnore Merely declarative
427     * @inheritDoc
428     */
429    public function getTableClass() {
430        return parent::getTableClass() . ' mw-abusefilter-list-scrollable';
431    }
432
433    /**
434     * @param stdClass $row
435     * @return string
436     * @see TablePager::getRowClass()
437     */
438    public function getRowClass( $row ) {
439        if ( $row->af_enabled ) {
440            return $row->af_throttled ? 'mw-abusefilter-list-throttled' : 'mw-abusefilter-list-enabled';
441        } elseif ( $row->af_deleted ) {
442            return 'mw-abusefilter-list-deleted';
443        } else {
444            return 'mw-abusefilter-list-disabled';
445        }
446    }
447
448    /**
449     * @inheritDoc
450     */
451    public function getIndexField() {
452        return [ self::INDEX_FIELDS[$this->mSort] ];
453    }
454
455    /**
456     * @param string $field
457     *
458     * @return bool
459     */
460    public function isFieldSortable( $field ) {
461        if ( ( $field === 'af_hit_count' || $field === 'af_public_comments' )
462            && !$this->afPermManager->canSeeLogDetails( $this->getAuthority() )
463        ) {
464            return false;
465        }
466        return isset( self::INDEX_FIELDS[$field] );
467    }
468}