Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.96% |
144 / 147 |
|
91.67% |
11 / 12 |
CRAP | |
0.00% |
0 / 1 |
ComparePager | |
97.96% |
144 / 147 |
|
91.67% |
11 / 12 |
41 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
getTableClass | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
getCellAttrs | |
100.00% |
49 / 49 |
|
100.00% |
1 / 1 |
14 | |||
formatValue | |
100.00% |
48 / 48 |
|
100.00% |
1 / 1 |
12 | |||
getIndexField | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getFieldNames | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
doQuery | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
getQueryInfo | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
getTargetsOverLimit | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
isFieldSortable | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDefaultSort | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPagingQueries | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | /** |
4 | * This program is free software; you can redistribute it and/or modify |
5 | * it under the terms of the GNU General Public License as published by |
6 | * the Free Software Foundation; either version 2 of the License, or |
7 | * (at your option) any later version. |
8 | * |
9 | * This program is distributed in the hope that it will be useful, |
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 | * GNU General Public License for more details. |
13 | * |
14 | * You should have received a copy of the GNU General Public License along |
15 | * with this program; if not, write to the Free Software Foundation, Inc., |
16 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
17 | * http://www.gnu.org/copyleft/gpl.html |
18 | * |
19 | * @file |
20 | * @ingroup Pager |
21 | */ |
22 | |
23 | namespace MediaWiki\CheckUser\Investigate\Pagers; |
24 | |
25 | use DateTime; |
26 | use IContextSource; |
27 | use MediaWiki\CheckUser\Investigate\Services\CompareService; |
28 | use MediaWiki\CheckUser\Investigate\Utilities\DurationManager; |
29 | use MediaWiki\CheckUser\Services\TokenQueryManager; |
30 | use MediaWiki\Html\Html; |
31 | use MediaWiki\Linker\Linker; |
32 | use MediaWiki\Linker\LinkRenderer; |
33 | use MediaWiki\Pager\TablePager; |
34 | use MediaWiki\User\UserFactory; |
35 | use Wikimedia\IPUtils; |
36 | use Wikimedia\Rdbms\FakeResultWrapper; |
37 | |
38 | class ComparePager extends TablePager { |
39 | private CompareService $compareService; |
40 | private TokenQueryManager $tokenQueryManager; |
41 | private UserFactory $userFactory; |
42 | |
43 | /** @var array */ |
44 | private $fieldNames; |
45 | |
46 | /** |
47 | * Holds a cache of iphex => action-count to avoid |
48 | * recurring queries to the database for the same ip |
49 | * |
50 | * @var array |
51 | */ |
52 | private $ipTotalActions; |
53 | |
54 | /** |
55 | * Targets whose results should not be included in the investigation. |
56 | * Targets in this list may or may not also be in the $targets list. |
57 | * Either way, no activity related to these targets will appear in the |
58 | * results. |
59 | * |
60 | * @var string[] |
61 | */ |
62 | private $excludeTargets; |
63 | |
64 | /** |
65 | * Targets that have been added to the investigation but that are not |
66 | * present in $excludeTargets. These are the targets that will actually |
67 | * be investigated. |
68 | * |
69 | * @var string[] |
70 | */ |
71 | private $filteredTargets; |
72 | |
73 | /** @var string */ |
74 | private $start; |
75 | |
76 | /** |
77 | * @param IContextSource $context |
78 | * @param LinkRenderer $linkRenderer |
79 | * @param TokenQueryManager $tokenQueryManager |
80 | * @param DurationManager $durationManager |
81 | * @param CompareService $compareService |
82 | * @param UserFactory $userFactory |
83 | */ |
84 | public function __construct( |
85 | IContextSource $context, |
86 | LinkRenderer $linkRenderer, |
87 | TokenQueryManager $tokenQueryManager, |
88 | DurationManager $durationManager, |
89 | CompareService $compareService, |
90 | UserFactory $userFactory |
91 | ) { |
92 | parent::__construct( $context, $linkRenderer ); |
93 | $this->compareService = $compareService; |
94 | $this->tokenQueryManager = $tokenQueryManager; |
95 | $this->userFactory = $userFactory; |
96 | |
97 | $tokenData = $tokenQueryManager->getDataFromRequest( $context->getRequest() ); |
98 | $this->mOffset = $tokenData['offset'] ?? ''; |
99 | |
100 | $this->excludeTargets = $tokenData['exclude-targets'] ?? []; |
101 | $this->filteredTargets = array_diff( |
102 | $tokenData['targets'] ?? [], |
103 | $this->excludeTargets |
104 | ); |
105 | |
106 | $this->start = $durationManager->getTimestampFromRequest( $context->getRequest() ); |
107 | } |
108 | |
109 | /** |
110 | * @inheritDoc |
111 | */ |
112 | protected function getTableClass() { |
113 | $sortableClass = $this->mIsFirst && $this->mIsLast ? 'sortable' : ''; |
114 | return implode( ' ', [ |
115 | parent::getTableClass(), |
116 | $sortableClass, |
117 | 'ext-checkuser-investigate-table', |
118 | 'ext-checkuser-investigate-table-compare' |
119 | ] ); |
120 | } |
121 | |
122 | /** |
123 | * @inheritDoc |
124 | */ |
125 | public function getCellAttrs( $field, $value ) { |
126 | $attributes = parent::getCellAttrs( $field, $value ); |
127 | $attributes['class'] ??= ''; |
128 | |
129 | $row = $this->mCurrentRow; |
130 | switch ( $field ) { |
131 | case 'ip': |
132 | foreach ( $this->filteredTargets as $target ) { |
133 | if ( IPUtils::isIPAddress( $target ) && IPUtils::isInRange( $value, $target ) ) { |
134 | // If the current cuc_ip is either in the range of a filtered target or is the filtered target, |
135 | // then mark the cell as a target. |
136 | $attributes['class'] .= ' ext-checkuser-compare-table-cell-target'; |
137 | break; |
138 | } |
139 | } |
140 | $ipHex = $row->ip_hex; |
141 | $attributes['class'] .= ' ext-checkuser-investigate-table-cell-interactive'; |
142 | $attributes['class'] .= ' ext-checkuser-investigate-table-cell-pinnable'; |
143 | $attributes['class'] .= ' ext-checkuser-compare-table-cell-ip-target'; |
144 | $attributes['data-field'] = $field; |
145 | $attributes['data-value'] = $value; |
146 | $attributes['data-sort-value'] = $ipHex; |
147 | $attributes['data-actions'] = $row->total_actions; |
148 | $attributes['data-all-actions'] = $this->ipTotalActions[$ipHex]; |
149 | break; |
150 | case 'user_text': |
151 | // Hide the username if it is hidden from the current authority. |
152 | $user = $this->userFactory->newFromName( $value ); |
153 | $userIsHidden = $user !== null && $user->isHidden() && !$this->getAuthority()->isAllowed( 'hideuser' ); |
154 | if ( $userIsHidden ) { |
155 | $value = $this->msg( 'rev-deleted-user' )->text(); |
156 | } |
157 | $attributes['class'] .= ' ext-checkuser-investigate-table-cell-interactive'; |
158 | if ( !IPUtils::isIpAddress( $value ) ) { |
159 | if ( !$userIsHidden ) { |
160 | $attributes['class'] .= ' ext-checkuser-compare-table-cell-user-target'; |
161 | } |
162 | if ( in_array( $value, $this->filteredTargets ) ) { |
163 | $attributes['class'] .= ' ext-checkuser-compare-table-cell-target'; |
164 | } |
165 | $attributes['data-field'] = $field; |
166 | $attributes['data-value'] = $value; |
167 | } |
168 | // Store the sort value as an attribute, to avoid using the table cell contents |
169 | // as the sort value, since UI elements are added to the table cell. |
170 | $attributes['data-sort-value'] = $value; |
171 | break; |
172 | case 'agent': |
173 | $attributes['class'] .= ' ext-checkuser-investigate-table-cell-interactive'; |
174 | $attributes['class'] .= ' ext-checkuser-investigate-table-cell-pinnable'; |
175 | $attributes['class'] .= ' ext-checkuser-compare-table-cell-user-agent'; |
176 | $attributes['data-field'] = $field; |
177 | $attributes['data-value'] = $value; |
178 | // Store the sort value as an attribute, to avoid using the table cell contents |
179 | // as the sort value, since UI elements are added to the table cell. |
180 | $attributes['data-sort-value'] = $value; |
181 | break; |
182 | case 'activity': |
183 | $attributes['class'] .= ' ext-checkuser-compare-table-cell-activity'; |
184 | $start = new DateTime( $row->first_action ); |
185 | $end = new DateTime( $row->last_action ); |
186 | $attributes['data-sort-value'] = $start->format( 'Ymd' ) . $end->format( 'Ymd' ); |
187 | break; |
188 | } |
189 | |
190 | // Add each cell to the tab index. |
191 | $attributes['tabindex'] = 0; |
192 | |
193 | return $attributes; |
194 | } |
195 | |
196 | /** |
197 | * @param string $name |
198 | * @param string|null $value |
199 | * @return string |
200 | */ |
201 | public function formatValue( $name, $value ) { |
202 | $language = $this->getLanguage(); |
203 | $row = $this->mCurrentRow; |
204 | |
205 | switch ( $name ) { |
206 | case 'user_text': |
207 | // Hide the username if it is hidden from the current authority. |
208 | $user = $this->userFactory->newFromName( $value ); |
209 | if ( $user !== null && $user->isHidden() && !$this->getAuthority()->isAllowed( 'hideuser' ) ) { |
210 | return $this->msg( 'rev-deleted-user' )->text(); |
211 | } |
212 | if ( IPUtils::isValid( $value ) ) { |
213 | $formatted = $this->msg( 'checkuser-investigate-compare-table-cell-unregistered' )->text(); |
214 | } else { |
215 | $formatted = Linker::userLink( $row->user ?? 0, $value ); |
216 | } |
217 | break; |
218 | case 'ip': |
219 | $formatted = Html::rawElement( |
220 | 'span', |
221 | [ 'class' => "ext-checkuser-compare-table-cell-ip" ], |
222 | htmlspecialchars( $value ) |
223 | ); |
224 | |
225 | // get other actions |
226 | $otherActions = ''; |
227 | $ipHex = $row->ip_hex; |
228 | if ( !isset( $this->ipTotalActions[$ipHex] ) ) { |
229 | $this->ipTotalActions[$ipHex] = $this->compareService->getTotalActionsFromIP( $ipHex ); |
230 | } |
231 | |
232 | if ( $this->ipTotalActions[$ipHex] ) { |
233 | $otherActions = Html::rawElement( |
234 | 'span', |
235 | [], |
236 | $this->msg( |
237 | 'checkuser-investigate-compare-table-cell-other-actions', |
238 | $this->ipTotalActions[$ipHex] |
239 | )->parse() |
240 | ); |
241 | } |
242 | |
243 | $formatted .= Html::rawElement( |
244 | 'div', |
245 | [], |
246 | $this->msg( |
247 | 'checkuser-investigate-compare-table-cell-actions', |
248 | $row->total_actions |
249 | )->parse() . ' ' . $otherActions |
250 | ); |
251 | |
252 | break; |
253 | case 'agent': |
254 | $formatted = htmlspecialchars( $value ?? '' ); |
255 | break; |
256 | case 'activity': |
257 | $firstAction = $language->userDate( $row->first_action, $this->getUser() ); |
258 | $lastAction = $language->userDate( $row->last_action, $this->getUser() ); |
259 | $formatted = htmlspecialchars( $firstAction . ' - ' . $lastAction ); |
260 | break; |
261 | default: |
262 | $formatted = ''; |
263 | } |
264 | |
265 | return $formatted; |
266 | } |
267 | |
268 | /** |
269 | * @inheritDoc |
270 | */ |
271 | public function getIndexField() { |
272 | return [ [ 'user_text', 'ip_hex', 'agent' ] ]; |
273 | } |
274 | |
275 | /** |
276 | * @inheritDoc |
277 | */ |
278 | public function getFieldNames() { |
279 | if ( $this->fieldNames === null ) { |
280 | $this->fieldNames = [ |
281 | 'user_text' => 'checkuser-investigate-compare-table-header-username', |
282 | 'ip' => 'checkuser-investigate-compare-table-header-ip', |
283 | 'agent' => 'checkuser-investigate-compare-table-header-useragent', |
284 | 'activity' => 'checkuser-investigate-compare-table-header-activity', |
285 | ]; |
286 | foreach ( $this->fieldNames as &$val ) { |
287 | $val = $this->msg( $val )->text(); |
288 | } |
289 | } |
290 | return $this->fieldNames; |
291 | } |
292 | |
293 | /** |
294 | * @inheritDoc |
295 | * |
296 | * Handle special case where all targets are filtered. |
297 | */ |
298 | public function doQuery() { |
299 | // If there are no targets, there is no need to run the query and an empty result can be used. |
300 | if ( $this->filteredTargets === [] ) { |
301 | $this->mResult = new FakeResultWrapper( [] ); |
302 | $this->mQueryDone = true; |
303 | return; |
304 | } |
305 | |
306 | parent::doQuery(); |
307 | } |
308 | |
309 | /** |
310 | * @inheritDoc |
311 | */ |
312 | public function getQueryInfo() { |
313 | return $this->compareService->getQueryInfo( |
314 | $this->filteredTargets, |
315 | $this->excludeTargets, |
316 | $this->start |
317 | ); |
318 | } |
319 | |
320 | /** |
321 | * Check if we have incomplete data for any of the targets. |
322 | * |
323 | * @return string[] Targets whose limits were exceeded (if any) |
324 | */ |
325 | public function getTargetsOverLimit(): array { |
326 | return $this->compareService->getTargetsOverLimit( |
327 | $this->filteredTargets, |
328 | $this->excludeTargets, |
329 | $this->start |
330 | ); |
331 | } |
332 | |
333 | /** |
334 | * @inheritDoc |
335 | */ |
336 | public function isFieldSortable( $field ) { |
337 | return false; |
338 | } |
339 | |
340 | /** |
341 | * @inheritDoc |
342 | */ |
343 | public function getDefaultSort() { |
344 | return ''; |
345 | } |
346 | |
347 | /** |
348 | * @inheritDoc |
349 | * |
350 | * Conceal the offset which may reveal private data. |
351 | */ |
352 | public function getPagingQueries() { |
353 | return $this->tokenQueryManager->getPagingQueries( |
354 | $this->getRequest(), parent::getPagingQueries() |
355 | ); |
356 | } |
357 | } |