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