Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 208 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
AbuseFilterPager | |
0.00% |
0 / 208 |
|
0.00% |
0 / 11 |
3540 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
getQueryInfo | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
2 | |||
preprocessResults | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
reallyDoQuery | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
matchesPattern | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
getFieldNames | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
42 | |||
formatValue | |
0.00% |
0 / 80 |
|
0.00% |
0 / 1 |
380 | |||
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\AbuseFilterServices; |
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 | $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 | } |