Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 208
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 / 208
0.00% covered (danger)
0.00%
0 / 11
3540
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 / 20
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 / 16
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 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 formatValue
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 1
380
 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\AbuseFilterServices;
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        $actorQuery = AbuseFilterServices::getActorMigration()->getJoin( 'af_user' );
100        return [
101            'tables' => [ 'abuse_filter' ] + $actorQuery['tables'],
102            'fields' => [
103                // All columns but af_comments
104                'af_id',
105                'af_enabled',
106                'af_deleted',
107                'af_pattern',
108                'af_global',
109                'af_public_comments',
110                'af_hidden',
111                'af_hit_count',
112                'af_timestamp',
113                'af_actions',
114                'af_group',
115                'af_throttled'
116            ] + $actorQuery['fields'],
117            'conds' => $this->conds,
118            'join_conds' => $actorQuery['joins'],
119        ];
120    }
121
122    /**
123     * @param IResultWrapper $result
124     */
125    protected function preprocessResults( $result ) {
126        // LinkBatchFactory only provided and needed for local wiki results
127        if ( $this->linkBatchFactory === null || $this->getNumRows() === 0 ) {
128            return;
129        }
130
131        $lb = $this->linkBatchFactory->newLinkBatch();
132        $lb->setCaller( __METHOD__ );
133        foreach ( $result as $row ) {
134            $lb->add( NS_USER, $row->af_user_text );
135            $lb->add( NS_USER_TALK, $row->af_user_text );
136        }
137        $lb->execute();
138        $result->seek( 0 );
139    }
140
141    /**
142     * @inheritDoc
143     * This is the same as the parent implementation if no search pattern was specified.
144     * Otherwise, it does a query with no limit and then slices the results Ã  la ContribsPager.
145     */
146    public function reallyDoQuery( $offset, $limit, $order ) {
147        if ( $this->searchMode === null ) {
148            return parent::reallyDoQuery( $offset, $limit, $order );
149        }
150
151        [ $tables, $fields, $conds, $fname, $options, $join_conds ] =
152            $this->buildQueryInfo( $offset, $limit, $order );
153
154        unset( $options['LIMIT'] );
155        $res = $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds );
156
157        $filtered = [];
158        foreach ( $res as $row ) {
159            if ( $this->matchesPattern( $row->af_pattern ) ) {
160                $filtered[$row->af_id] = $row;
161            }
162        }
163
164        // sort results and enforce limit like ContribsPager
165        if ( $order === self::QUERY_ASCENDING ) {
166            ksort( $filtered );
167        } else {
168            krsort( $filtered );
169        }
170        $filtered = array_slice( $filtered, 0, $limit );
171        $filtered = array_values( $filtered );
172        return new FakeResultWrapper( $filtered );
173    }
174
175    /**
176     * Check whether $subject matches the given $pattern.
177     *
178     * @param string $subject
179     * @return bool
180     * @throws LogicException
181     */
182    private function matchesPattern( $subject ) {
183        $pattern = $this->searchPattern;
184        switch ( $this->searchMode ) {
185            case 'RLIKE':
186                return (bool)preg_match( "/$pattern/u", $subject );
187            case 'IRLIKE':
188                return (bool)preg_match( "/$pattern/ui", $subject );
189            case 'LIKE':
190                return mb_stripos( $subject, $pattern ) !== false;
191            default:
192                throw new LogicException( "Unknown search type {$this->searchMode}" );
193        }
194    }
195
196    /**
197     * Note: this method is called by parent::__construct
198     * @return array
199     * @see MediaWiki\Pager\Pager::getFieldNames()
200     */
201    public function getFieldNames() {
202        $headers = [
203            'af_id' => 'abusefilter-list-id',
204            'af_public_comments' => 'abusefilter-list-public',
205            'af_actions' => 'abusefilter-list-consequences',
206            'af_enabled' => 'abusefilter-list-status',
207            'af_timestamp' => 'abusefilter-list-lastmodified',
208            'af_hidden' => 'abusefilter-list-visibility',
209        ];
210
211        $performer = $this->getAuthority();
212        if ( $this->afPermManager->canSeeLogDetails( $performer ) ) {
213            $headers['af_hit_count'] = 'abusefilter-list-hitcount';
214        }
215
216        if ( $this->afPermManager->canViewPrivateFilters( $performer ) && $this->searchMode !== null ) {
217            // This is also excluded in the default view
218            $headers['af_pattern'] = 'abusefilter-list-pattern';
219        }
220
221        if ( count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 ) {
222            $headers['af_group'] = 'abusefilter-list-group';
223        }
224
225        foreach ( $headers as &$msg ) {
226            $msg = $this->msg( $msg )->text();
227        }
228
229        return $headers;
230    }
231
232    /**
233     * @param string $name
234     * @param string|null $value
235     * @return string
236     */
237    public function formatValue( $name, $value ) {
238        $lang = $this->getLanguage();
239        $user = $this->getUser();
240        $linkRenderer = $this->getLinkRenderer();
241        $row = $this->mCurrentRow;
242
243        switch ( $name ) {
244            case 'af_id':
245                return $linkRenderer->makeLink(
246                    SpecialPage::getTitleFor( 'AbuseFilter', $value ),
247                    $lang->formatNum( intval( $value ) )
248                );
249            case 'af_pattern':
250                return $this->getHighlightedPattern( $row );
251            case 'af_public_comments':
252                return $linkRenderer->makeLink(
253                    SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ),
254                    $value
255                );
256            case 'af_actions':
257                $actions = explode( ',', $value );
258                $displayActions = [];
259                foreach ( $actions as $action ) {
260                    $displayActions[] = $this->specsFormatter->getActionDisplay( $action );
261                }
262                return $lang->commaList( $displayActions );
263            case 'af_enabled':
264                $statuses = [];
265                if ( $row->af_deleted ) {
266                    $statuses[] = $this->msg( 'abusefilter-deleted' )->parse();
267                } elseif ( $row->af_enabled ) {
268                    $statuses[] = $this->msg( 'abusefilter-enabled' )->parse();
269                    if ( $row->af_throttled ) {
270                        $statuses[] = $this->msg( 'abusefilter-throttled' )->parse();
271                    }
272                } else {
273                    $statuses[] = $this->msg( 'abusefilter-disabled' )->parse();
274                }
275
276                if ( $row->af_global && $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
277                    $statuses[] = $this->msg( 'abusefilter-status-global' )->parse();
278                }
279
280                return $lang->commaList( $statuses );
281            case 'af_hidden':
282                $msg = $value ? 'abusefilter-hidden' : 'abusefilter-unhidden';
283                return $this->msg( $msg )->parse();
284            case 'af_hit_count':
285                if ( $this->afPermManager->canSeeLogDetailsForFilter( $user, $row->af_hidden ) ) {
286                    $count_display = $this->msg( 'abusefilter-hitcount' )
287                        ->numParams( $value )->text();
288                    $link = $linkRenderer->makeKnownLink(
289                        SpecialPage::getTitleFor( 'AbuseLog' ),
290                        $count_display,
291                        [],
292                        [ 'wpSearchFilter' => $row->af_id ]
293                    );
294                } else {
295                    $link = "";
296                }
297                return $link;
298            case 'af_timestamp':
299                $userLink =
300                    Linker::userLink(
301                        $row->af_user,
302                        $row->af_user_text
303                    ) .
304                    Linker::userToolLinks(
305                        $row->af_user,
306                        $row->af_user_text
307                    );
308
309                return $this->msg( 'abusefilter-edit-lastmod-text' )
310                    ->rawParams(
311                        $this->mPage->getLinkToLatestDiff(
312                            $row->af_id,
313                            $lang->userTimeAndDate( $value, $user )
314                        ),
315                        $userLink,
316                        $this->mPage->getLinkToLatestDiff(
317                            $row->af_id,
318                            $lang->userDate( $value, $user )
319                        ),
320                        $this->mPage->getLinkToLatestDiff(
321                            $row->af_id,
322                            $lang->userTime( $value, $user )
323                        )
324                    )->params(
325                        wfEscapeWikiText( $row->af_user_text )
326                    )->parse();
327            case 'af_group':
328                return $this->specsFormatter->nameGroup( $value );
329            default:
330                throw new UnexpectedValueException( "Unknown row type $name!" );
331        }
332    }
333
334    /**
335     * Get the filter pattern with <b> elements surrounding the searched pattern
336     *
337     * @param stdClass $row
338     * @return string
339     */
340    private function getHighlightedPattern( stdClass $row ) {
341        if ( $this->searchMode === null ) {
342            throw new LogicException( 'Cannot search without a mode.' );
343        }
344        $maxLen = 50;
345        if ( $this->searchMode === 'LIKE' ) {
346            $position = mb_stripos( $row->af_pattern, $this->searchPattern );
347            $length = mb_strlen( $this->searchPattern );
348        } else {
349            $regex = '/' . $this->searchPattern . '/u';
350            if ( $this->searchMode === 'IRLIKE' ) {
351                $regex .= 'i';
352            }
353
354            $matches = [];
355            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
356            $check = @preg_match(
357                $regex,
358                $row->af_pattern,
359                $matches
360            );
361            // This may happen in case of catastrophic backtracking, or regexps matching
362            // the empty string.
363            if ( $check === false || strlen( $matches[0] ) === 0 ) {
364                return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50 ) );
365            }
366
367            $length = mb_strlen( $matches[0] );
368            $position = mb_strpos( $row->af_pattern, $matches[0] );
369        }
370
371        $remaining = $maxLen - $length;
372        if ( $remaining <= 0 ) {
373            $pattern = '<b>' .
374                htmlspecialchars( mb_substr( $row->af_pattern, $position, $maxLen ) ) .
375                '</b>';
376        } else {
377            // Center the snippet on the matched string
378            $minoffset = max( $position - round( $remaining / 2 ), 0 );
379            $pattern = mb_substr( $row->af_pattern, $minoffset, $maxLen );
380            $pattern =
381                htmlspecialchars( mb_substr( $pattern, 0, $position - $minoffset ) ) .
382                '<b>' .
383                htmlspecialchars( mb_substr( $pattern, $position - $minoffset, $length ) ) .
384                '</b>' .
385                htmlspecialchars( mb_substr(
386                        $pattern,
387                        $position - $minoffset + $length,
388                        $remaining - ( $position - $minoffset + $length )
389                    )
390                );
391        }
392        return $pattern;
393    }
394
395    /**
396     * @codeCoverageIgnore Merely declarative
397     * @inheritDoc
398     */
399    public function getDefaultSort() {
400        return 'af_id';
401    }
402
403    /**
404     * @codeCoverageIgnore Merely declarative
405     * @inheritDoc
406     */
407    public function getTableClass() {
408        return parent::getTableClass() . ' mw-abusefilter-list-scrollable';
409    }
410
411    /**
412     * @param stdClass $row
413     * @return string
414     * @see TablePager::getRowClass()
415     */
416    public function getRowClass( $row ) {
417        if ( $row->af_enabled ) {
418            return $row->af_throttled ? 'mw-abusefilter-list-throttled' : 'mw-abusefilter-list-enabled';
419        } elseif ( $row->af_deleted ) {
420            return 'mw-abusefilter-list-deleted';
421        } else {
422            return 'mw-abusefilter-list-disabled';
423        }
424    }
425
426    /**
427     * @inheritDoc
428     */
429    public function getIndexField() {
430        return [ self::INDEX_FIELDS[$this->mSort] ];
431    }
432
433    /**
434     * @param string $field
435     *
436     * @return bool
437     */
438    public function isFieldSortable( $field ) {
439        if ( ( $field === 'af_hit_count' || $field === 'af_public_comments' )
440            && !$this->afPermManager->canSeeLogDetails( $this->getAuthority() )
441        ) {
442            return false;
443        }
444        return isset( self::INDEX_FIELDS[$field] );
445    }
446}