Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.83% covered (success)
97.83%
180 / 184
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
CheckUserGetIPsPager
97.83% covered (success)
97.83%
180 / 184
76.92% covered (warning)
76.92%
10 / 13
51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 formatRow
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
6
 getIPBlockInfo
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 getCountForIPActions
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 getCountForIPActionsPerTable
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
7
 groupResultsByIndexField
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
14
 getQueryInfo
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 getQueryInfoForCuChanges
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 getQueryInfoForCuLogEvent
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getQueryInfoForCuPrivateEvent
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getIndexField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStartBody
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isNavigationBarShown
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\CheckUser\CheckUser\Pagers;
4
5use ExtensionRegistry;
6use IContextSource;
7use LogicException;
8use MediaWiki\Block\DatabaseBlock;
9use MediaWiki\CheckUser\CheckUser\SpecialCheckUser;
10use MediaWiki\CheckUser\Services\CheckUserLogService;
11use MediaWiki\CheckUser\Services\CheckUserLookupUtils;
12use MediaWiki\CheckUser\Services\TokenQueryManager;
13use MediaWiki\Extension\TorBlock\TorExitNodes;
14use MediaWiki\Html\FormOptions;
15use MediaWiki\Html\Html;
16use MediaWiki\Linker\LinkRenderer;
17use MediaWiki\SpecialPage\SpecialPage;
18use MediaWiki\SpecialPage\SpecialPageFactory;
19use MediaWiki\User\CentralId\CentralIdLookup;
20use MediaWiki\User\UserFactory;
21use MediaWiki\User\UserGroupManager;
22use MediaWiki\User\UserIdentity;
23use MediaWiki\User\UserIdentityLookup;
24use Wikimedia\IPUtils;
25use Wikimedia\Rdbms\IConnectionProvider;
26
27class CheckUserGetIPsPager extends AbstractCheckUserPager {
28
29    /**
30     * @param FormOptions $opts
31     * @param UserIdentity $target
32     * @param string $logType
33     * @param TokenQueryManager $tokenQueryManager
34     * @param UserGroupManager $userGroupManager
35     * @param CentralIdLookup $centralIdLookup
36     * @param IConnectionProvider $dbProvider
37     * @param SpecialPageFactory $specialPageFactory
38     * @param UserIdentityLookup $userIdentityLookup
39     * @param CheckUserLogService $checkUserLogService
40     * @param UserFactory $userFactory
41     * @param CheckUserLookupUtils $checkUserLookupUtils
42     * @param IContextSource|null $context
43     * @param LinkRenderer|null $linkRenderer
44     * @param ?int $limit
45     */
46    public function __construct(
47        FormOptions $opts,
48        UserIdentity $target,
49        string $logType,
50        TokenQueryManager $tokenQueryManager,
51        UserGroupManager $userGroupManager,
52        CentralIdLookup $centralIdLookup,
53        IConnectionProvider $dbProvider,
54        SpecialPageFactory $specialPageFactory,
55        UserIdentityLookup $userIdentityLookup,
56        CheckUserLogService $checkUserLogService,
57        UserFactory $userFactory,
58        CheckUserLookupUtils $checkUserLookupUtils,
59        IContextSource $context = null,
60        LinkRenderer $linkRenderer = null,
61        ?int $limit = null
62    ) {
63        parent::__construct( $opts, $target, $logType, $tokenQueryManager, $userGroupManager, $centralIdLookup,
64            $dbProvider, $specialPageFactory, $userIdentityLookup, $checkUserLogService, $userFactory,
65            $checkUserLookupUtils, $context, $linkRenderer, $limit );
66        $this->checkType = SpecialCheckUser::SUBTYPE_GET_IPS;
67    }
68
69    /** @inheritDoc */
70    public function formatRow( $row ): string {
71        $lang = $this->getLanguage();
72        $ip = IPUtils::prettifyIP( $row->ip );
73        $templateParams = [];
74        $templateParams['ipLink'] = $this->getSelfLink( $ip,
75            [
76                'user' => $ip,
77                'reason' => $this->opts->getValue( 'reason' ),
78            ]
79        );
80
81        // If we get some results, it helps to know if the IP in general
82        // has a lot more edits, e.g. "tip of the iceberg"...
83        $ipEdits = $this->getCountForIPActions( $ip );
84        if ( $ipEdits ) {
85            $templateParams['ipEditCount'] =
86                $this->msg( 'checkuser-ipeditcount' )->numParams( $ipEdits )->escaped();
87            $templateParams['showIpCounts'] = true;
88        }
89
90        if ( IPUtils::isValidIPv6( $ip ) ) {
91            $templateParams['ip64Link'] = $this->getSelfLink( '/64',
92                [
93                    'user' => $ip . '/64',
94                    'reason' => $this->opts->getValue( 'reason' ),
95                ]
96            );
97            $ipEdits64 = $this->getCountForIPActions( $ip . '/64' );
98            if ( $ipEdits64 && ( !$ipEdits || $ipEdits64 > $ipEdits ) ) {
99                $templateParams['ip64EditCount'] =
100                    $this->msg( 'checkuser-ipeditcount-64' )->numParams( $ipEdits64 )->escaped();
101                $templateParams['showIpCounts'] = true;
102            }
103        }
104        $templateParams['blockLink'] = $this->getLinkRenderer()->makeKnownLink(
105            SpecialPage::getTitleFor( 'Block', $ip ),
106            $this->msg( 'blocklink' )->text()
107        );
108        $templateParams['timeRange'] = $this->getTimeRangeString( $row->first, $row->last );
109        $templateParams['editCount'] = $lang->formatNum( $row->count );
110
111        // If this IP is blocked, give a link to the block log
112        $templateParams['blockInfo'] = $this->getIPBlockInfo( $ip );
113        $templateParams['toolLinks'] = $this->msg( 'checkuser-toollinks', urlencode( $ip ) )->parse();
114        return $this->templateParser->processTemplate( 'GetIPsLine', $templateParams );
115    }
116
117    /**
118     * Get information about any active blocks on a IP.
119     *
120     * @param string $ip the IP to get block info on.
121     * @return string
122     */
123    protected function getIPBlockInfo( string $ip ): string {
124        $block = DatabaseBlock::newFromTarget( null, $ip );
125        if ( $block instanceof DatabaseBlock ) {
126            return $this->getBlockFlag( $block );
127        } elseif (
128            ExtensionRegistry::getInstance()->isLoaded( 'TorBlock' ) &&
129            TorExitNodes::isExitNode( $ip )
130        ) {
131            return Html::rawElement( 'strong', [], '(' . $this->msg( 'checkuser-torexitnode' )->escaped() . ')' );
132        }
133        return '';
134    }
135
136    /**
137     * Return "checkuser-ipeditcount" number or false
138     * if the number is the same as the number of edits
139     * made by the user on the IP.
140     *
141     * @param string $ip_or_range
142     * @return int|false
143     */
144    protected function getCountForIPActions( string $ip_or_range ) {
145        $count = false;
146        $tables = self::RESULT_TABLES;
147        if ( !$this->eventTableReadNew ) {
148            $tables = [ self::CHANGES_TABLE ];
149        }
150        $countsPerTable = [];
151        // Get the total count and counts by this user.
152        foreach ( $tables as $table ) {
153            $countsPerTable[$table] = $this->getCountForIPActionsPerTable( $ip_or_range, $table );
154        }
155        // Display the count if at least one of the counts for a table has more actions
156        // performed by all users than the current target user.
157        $shouldDisplayCount = count( array_filter( $countsPerTable, static function ( $countsForTable ) {
158            return $countsForTable !== null && $countsForTable['total'] > $countsForTable['by_this_target'];
159        } ) );
160        if ( $shouldDisplayCount ) {
161            // If displaying the count, then sum the
162            // 'total' count for all three tables.
163            foreach ( $countsPerTable as $countsForTable ) {
164                if ( $countsForTable !== null ) {
165                    $count += $countsForTable['total'];
166                }
167            }
168        }
169        return $count;
170    }
171
172    /**
173     * Return the number of actions performed by all users
174     * and the current target on a given IP or IP range.
175     *
176     * @param string $ipOrRange The IP or IP range to get the counts from.
177     * @param string $table The table to get these results from (valid tables in self::RESULT_TABLES).
178     * @return array<string, integer>|null
179     */
180    protected function getCountForIPActionsPerTable( string $ipOrRange, string $table ): ?array {
181        // Get the IExpression which allows selecting results for the IP or IP range.
182        $expr = $this->checkUserLookupUtils->getIPTargetExpr( $ipOrRange, false, $table );
183        if ( $expr === null ) {
184            // Return null if no target conditions could be generated.
185            return null;
186        }
187        // We are only using startOffset for the period feature.
188        if ( $this->startOffset ) {
189            $expr = $this->mDb->expr( $this->getTimestampField( $table ), '>=', $this->startOffset )
190                ->andExpr( $expr );
191        }
192
193        // If the $table is cu_changes and event table migration
194        // is set to read new, then only include rows that have
195        // cuc_only_for_read_old equal to 0 to prevent duplicate
196        // rows appearing.
197        if ( $this->eventTableReadNew && $table === self::CHANGES_TABLE ) {
198            $expr = $this->mDb->expr( 'cuc_only_for_read_old', '=', 0 )
199                ->andExpr( $expr );
200        }
201
202        // Get counts for this IP / IP range
203        $query = $this->mDb->newSelectQueryBuilder()
204            ->table( $table )
205            ->conds( $expr )
206            ->caller( __METHOD__ );
207        $ipEdits = $query->estimateRowCount();
208        // If small enough, get a more accurate count
209        if ( $ipEdits <= 1000 ) {
210            $ipEdits = $query->fetchRowCount();
211        }
212
213        // Get counts for the target on this IP / IP range
214        $expr = $this->mDb->expr( 'actor_user', '=', $this->target->getId() )
215            ->andExpr( $expr );
216        $query = $this->mDb->newSelectQueryBuilder()
217            ->table( $table )
218            ->join(
219                'actor',
220                "{$table}_actor",
221                "{$table}_actor.actor_id = {$this::RESULT_TABLE_TO_PREFIX[$table]}actor"
222            )
223            ->conds( $expr )
224            ->caller( __METHOD__ );
225        $userOnIpEdits = $query->estimateRowCount();
226        // If small enough, get a more accurate count
227        if ( $userOnIpEdits <= 1000 ) {
228            $userOnIpEdits = $query->fetchRowCount();
229        }
230
231        return [ 'total' => $ipEdits, 'by_this_target' => $userOnIpEdits ];
232    }
233
234    /** @inheritDoc */
235    protected function groupResultsByIndexField( array $results ): array {
236        // Group rows that have the same 'ip' and 'ip_hex' value.
237        $resultsGroupedByIPAndIPHex = [];
238        foreach ( $results as $row ) {
239            if ( !array_key_exists( $row->ip, $resultsGroupedByIPAndIPHex ) ) {
240                $resultsGroupedByIPAndIPHex[$row->ip] = [];
241            }
242            if ( !array_key_exists( $row->ip_hex, $resultsGroupedByIPAndIPHex[$row->ip] ) ) {
243                $resultsGroupedByIPAndIPHex[$row->ip][$row->ip_hex] = [];
244            }
245            $resultsGroupedByIPAndIPHex[$row->ip][$row->ip_hex][] = $row;
246        }
247        // Combine the rows that have the same 'ip' and 'ip_hex' value.
248        $groupedResults = [];
249        $indexField = $this->getIndexField();
250        foreach ( $resultsGroupedByIPAndIPHex as $ip => $ipHexArray ) {
251            foreach ( $ipHexArray as $ipHex => $rows ) {
252                $combinedRow = [
253                    'ip' => $ip,
254                    'ip_hex' => $ipHex,
255                    'count' => 0,
256                    'first' => '',
257                    'last' => '',
258                ];
259                foreach ( $rows as $row ) {
260                    $combinedRow['count'] += $row->count;
261                    if ( $row->first && ( $combinedRow['first'] > $row->first || !$combinedRow['first'] ) ) {
262                        $combinedRow['first'] = $row->first;
263                    }
264                    if ( $row->last && ( $combinedRow['last'] < $row->last || !$combinedRow['last'] ) ) {
265                        $combinedRow['last'] = $row->last;
266                    }
267                }
268                $combinedRow = (object)$combinedRow;
269                if ( array_key_exists( $combinedRow->$indexField, $groupedResults ) ) {
270                    $groupedResults[$combinedRow->$indexField][] = $combinedRow;
271                } else {
272                    $groupedResults[$combinedRow->$indexField] = [ $combinedRow ];
273                }
274            }
275        }
276        return $groupedResults;
277    }
278
279    /** @inheritDoc */
280    public function getQueryInfo( ?string $table = null ): array {
281        if ( $table === null ) {
282            throw new LogicException(
283                "This ::getQueryInfo method must be provided with the table to generate " .
284                "the correct query info"
285            );
286        }
287
288        if ( $table === self::CHANGES_TABLE ) {
289            $queryInfo = $this->getQueryInfoForCuChanges();
290        } elseif ( $table === self::LOG_EVENT_TABLE ) {
291            $queryInfo = $this->getQueryInfoForCuLogEvent();
292        } elseif ( $table === self::PRIVATE_LOG_EVENT_TABLE ) {
293            $queryInfo = $this->getQueryInfoForCuPrivateEvent();
294        }
295
296        // Apply index, group by IP / IP hex, and filter results to just the target user.
297        $queryInfo['options']['USE INDEX'] = [
298            $table => $this->checkUserLookupUtils->getIndexName( $this->xfor, $table )
299        ];
300        $queryInfo['options']['GROUP BY'] = [ 'ip', 'ip_hex' ];
301        $queryInfo['conds']['actor_user'] = $this->target->getId();
302
303        return $queryInfo;
304    }
305
306    /** @inheritDoc */
307    protected function getQueryInfoForCuChanges(): array {
308        $queryInfo = [
309            'fields' => [
310                'ip' => 'cuc_ip',
311                'ip_hex' => 'cuc_ip_hex',
312                'count' => 'COUNT(*)',
313                'first' => 'MIN(cuc_timestamp)',
314                'last' => 'MAX(cuc_timestamp)',
315            ],
316            'tables' => [ 'cu_changes', 'actor_cuc_actor' => 'actor' ],
317            'conds' => [],
318            'join_conds' => [ 'actor_cuc_actor' => [ 'JOIN', 'actor_cuc_actor.actor_id=cuc_actor' ] ],
319            'options' => [],
320        ];
321        // When reading new, only select results from cu_changes that are
322        // for read new (defined as those with cuc_only_for_read_old set to 0).
323        if ( $this->eventTableReadNew ) {
324            $queryInfo['conds']['cuc_only_for_read_old'] = 0;
325        }
326        return $queryInfo;
327    }
328
329    /** @inheritDoc */
330    protected function getQueryInfoForCuLogEvent(): array {
331        return [
332            'fields' => [
333                'ip' => 'cule_ip',
334                'ip_hex' => 'cule_ip_hex',
335                'count' => 'COUNT(*)',
336                'first' => 'MIN(cule_timestamp)',
337                'last' => 'MAX(cule_timestamp)',
338            ],
339            'tables' => [ 'cu_log_event', 'actor_cule_actor' => 'actor' ],
340            'conds' => [],
341            'join_conds' => [ 'actor_cule_actor' => [ 'JOIN', 'actor_cule_actor.actor_id=cule_actor' ] ],
342            'options' => [],
343        ];
344    }
345
346    /** @inheritDoc */
347    protected function getQueryInfoForCuPrivateEvent(): array {
348        return [
349            'fields' => [
350                'ip' => 'cupe_ip',
351                'ip_hex' => 'cupe_ip_hex',
352                'count' => 'COUNT(*)',
353                'first' => 'MIN(cupe_timestamp)',
354                'last' => 'MAX(cupe_timestamp)',
355            ],
356            'tables' => [ 'cu_private_event', 'actor_cupe_actor' => 'actor' ],
357            'conds' => [],
358            'join_conds' => [ 'actor_cupe_actor' => [ 'JOIN', 'actor_cupe_actor.actor_id=cupe_actor' ] ],
359            'options' => [],
360        ];
361    }
362
363    /** @inheritDoc */
364    public function getIndexField(): string {
365        return 'last';
366    }
367
368    /** @inheritDoc */
369    protected function getStartBody(): string {
370        return $this->getNavigationBar()
371            . '<div id="checkuserresults" class="mw-checkuser-get-ips-results"><ul>';
372    }
373
374    /**
375     * Temporary measure until Get IPs query is fixed for pagination (T315612).
376     *
377     * @return bool
378     */
379    protected function isNavigationBarShown() {
380        return false;
381    }
382}