Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.67% |
182 / 210 |
|
45.45% |
5 / 11 |
CRAP | |
0.00% |
0 / 1 |
AbuseFilterPager | |
86.67% |
182 / 210 |
|
45.45% |
5 / 11 |
72.41 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
getQueryInfo | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
preprocessResults | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
reallyDoQuery | |
92.59% |
25 / 27 |
|
0.00% |
0 / 1 |
7.02 | |||
matchesPattern | |
66.67% |
6 / 9 |
|
0.00% |
0 / 1 |
5.93 | |||
getFieldNames | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
6 | |||
formatValue | |
95.40% |
83 / 87 |
|
0.00% |
0 / 1 |
21 | |||
getHighlightedPattern | |
55.26% |
21 / 38 |
|
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% |
4 / 5 |
|
0.00% |
0 / 1 |
4.13 | |||
getIndexField | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isFieldSortable | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
4.25 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter\Pager; |
4 | |
5 | use LogicException; |
6 | use MediaWiki\Cache\LinkBatchFactory; |
7 | use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager; |
8 | use MediaWiki\Extension\AbuseFilter\FilterLookup; |
9 | use MediaWiki\Extension\AbuseFilter\FilterUtils; |
10 | use MediaWiki\Extension\AbuseFilter\SpecsFormatter; |
11 | use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewList; |
12 | use MediaWiki\Linker\Linker; |
13 | use MediaWiki\Linker\LinkRenderer; |
14 | use MediaWiki\Pager\TablePager; |
15 | use MediaWiki\SpecialPage\SpecialPage; |
16 | use MediaWiki\User\UserIdentityValue; |
17 | use stdClass; |
18 | use UnexpectedValueException; |
19 | use Wikimedia\Rdbms\FakeResultWrapper; |
20 | use Wikimedia\Rdbms\IResultWrapper; |
21 | |
22 | /** |
23 | * Class to build paginated filter list |
24 | */ |
25 | class 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 | } |