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