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