Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.96% covered (success)
97.96%
144 / 147
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComparePager
97.96% covered (success)
97.96%
144 / 147
91.67% covered (success)
91.67%
11 / 12
41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getTableClass
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getCellAttrs
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
14
 formatValue
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
12
 getIndexField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFieldNames
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 doQuery
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getQueryInfo
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getTargetsOverLimit
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 isFieldSortable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultSort
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPagingQueries
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
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
23namespace MediaWiki\CheckUser\Investigate\Pagers;
24
25use DateTime;
26use IContextSource;
27use MediaWiki\CheckUser\Investigate\Services\CompareService;
28use MediaWiki\CheckUser\Investigate\Utilities\DurationManager;
29use MediaWiki\CheckUser\Services\TokenQueryManager;
30use MediaWiki\Html\Html;
31use MediaWiki\Linker\Linker;
32use MediaWiki\Linker\LinkRenderer;
33use MediaWiki\Pager\TablePager;
34use MediaWiki\User\UserFactory;
35use Wikimedia\IPUtils;
36use Wikimedia\Rdbms\FakeResultWrapper;
37
38class 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}