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