Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.91% covered (success)
95.91%
211 / 220
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialIPInfo
95.91% covered (success)
95.91%
211 / 220
78.57% covered (warning)
78.57%
11 / 14
36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 didNotAcceptIPInfoAgreement
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requiresPost
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSubpageField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormFields
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 alterForm
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMessagePrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDisplayFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getShowAlways
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onSubmit
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
5.25
 onSuccess
96.71% covered (success)
96.71%
147 / 152
0.00% covered (danger)
0.00%
0 / 1
14
1<?php
2namespace MediaWiki\IPInfo\Special;
3
4use MediaWiki\Config\Config;
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\Html\Html;
7use MediaWiki\Html\TemplateParser;
8use MediaWiki\HTMLForm\HTMLForm;
9use MediaWiki\IPInfo\InfoManager;
10use MediaWiki\IPInfo\InfoRetriever\GeoLite2InfoRetriever;
11use MediaWiki\IPInfo\InfoRetriever\IPoidInfoRetriever;
12use MediaWiki\IPInfo\Rest\Presenter\DefaultPresenter;
13use MediaWiki\IPInfo\TempUserIPLookup;
14use MediaWiki\Linker\Linker;
15use MediaWiki\Message\Message;
16use MediaWiki\Permissions\PermissionManager;
17use MediaWiki\SpecialPage\FormSpecialPage;
18use MediaWiki\Status\Status;
19use MediaWiki\User\Options\UserOptionsLookup;
20use MediaWiki\User\Options\UserOptionsManager;
21use MediaWiki\User\UserIdentity;
22use MediaWiki\User\UserIdentityLookup;
23use MediaWiki\User\UserNameUtils;
24use UserBlockedError;
25use Wikimedia\IPUtils;
26use Wikimedia\ObjectCache\BagOStuff;
27
28/**
29 * A special page that displays IP information for all IP addresses used by a temporary user.
30 */
31class SpecialIPInfo extends FormSpecialPage {
32    private const CONSTRUCTOR_OPTIONS = [
33        'IPInfoMaxDistinctIPResults'
34    ];
35
36    private const TARGET_FIELD = 'Target';
37    private const IP_INFO_AGREEMENT_FIELD = 'AcceptAgreement';
38
39    private const SORT_ASC = 'asc';
40    private const SORT_DESC = 'desc';
41
42    private UserOptionsLookup $userOptionsManager;
43    private UserNameUtils $userNameUtils;
44    private TemplateParser $templateParser;
45    private TempUserIPLookup $tempUserIPLookup;
46    private UserIdentityLookup $userIdentityLookup;
47    private InfoManager $infoManager;
48    private DefaultPresenter $defaultPresenter;
49    private ServiceOptions $serviceOptions;
50
51    private UserIdentity $targetUser;
52
53    public function __construct(
54        UserOptionsManager $userOptionsManager,
55        UserNameUtils $userNameUtils,
56        BagOStuff $srvCache,
57        TempUserIPLookup $tempUserIPLookup,
58        UserIdentityLookup $userIdentityLookup,
59        InfoManager $infoManager,
60        PermissionManager $permissionManager,
61        Config $config
62    ) {
63        parent::__construct( 'IPInfo', 'ipinfo' );
64        $serviceOptions = new ServiceOptions( self::CONSTRUCTOR_OPTIONS, $config );
65        $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
66
67        $this->userOptionsManager = $userOptionsManager;
68        $this->userNameUtils = $userNameUtils;
69        $this->templateParser = new TemplateParser( __DIR__ . '/templates', $srvCache );
70        $this->tempUserIPLookup = $tempUserIPLookup;
71        $this->userIdentityLookup = $userIdentityLookup;
72        $this->infoManager = $infoManager;
73        $this->defaultPresenter = new DefaultPresenter( $permissionManager );
74        $this->serviceOptions = $serviceOptions;
75    }
76
77    /** @inheritDoc */
78    public function execute( $par ): void {
79        $this->addHelpLink( 'Trust_and_Safety_Product/IP_Info' );
80
81        $block = $this->getAuthority()->getBlock();
82        if ( $block && $block->isSitewide() ) {
83            throw new UserBlockedError(
84                $block,
85                $this->getAuthority()->getUser(),
86                $this->getLanguage(),
87                $this->getRequest()->getIP()
88            );
89        }
90
91        parent::execute( $par );
92    }
93
94    private function didNotAcceptIPInfoAgreement(): bool {
95        return !$this->userOptionsManager->getBoolOption( $this->getAuthority()->getUser(), 'ipinfo-use-agreement' );
96    }
97
98    /** @inheritDoc */
99    public function requiresPost(): bool {
100        // POST the form if the agreement needs to be accepted to allow DB writes
101        // for updating the corresponding preference.
102        return $this->didNotAcceptIPInfoAgreement();
103    }
104
105    /** @inheritDoc */
106    public function doesWrites(): bool {
107        return $this->didNotAcceptIPInfoAgreement();
108    }
109
110    protected function getSubpageField(): string {
111        return self::TARGET_FIELD;
112    }
113
114    protected function getFormFields(): array {
115        $fields = [
116            self::TARGET_FIELD => [
117                'type' => 'user',
118                'label-message' => 'ipinfo-special-ipinfo-target',
119                'excludenamed' => true,
120                'autocomplete' => 'on',
121                'exists' => true,
122                'required' => true
123            ]
124        ];
125
126        $request = $this->getRequest();
127
128        // Require accepting the IP info data use agreement in order to view IP info
129        if ( $this->didNotAcceptIPInfoAgreement() ) {
130            // Workaround: Avoid showing the now-superfluous agreement checkbox after submission if the
131            // user accepted the agreement, but keep it part of the form so that its value remains usable
132            // by onSubmit().
133            $willAcceptAgreement = $request->wasPosted() && $request->getCheck( 'wp' . self::IP_INFO_AGREEMENT_FIELD );
134            $type = $willAcceptAgreement ? 'hidden' : 'check';
135
136            $fields[self::IP_INFO_AGREEMENT_FIELD] = [
137                'type' => $type,
138                'label-message' => 'ipinfo-preference-use-agreement',
139                'help-message' => 'ipinfo-infobox-use-terms',
140                'required' => true
141            ];
142        }
143
144        return $fields;
145    }
146
147    protected function alterForm( HTMLForm $form ): void {
148        $legend = $this->msg( 'ipinfo-special-ipinfo-legend' )
149            ->numParams( $this->serviceOptions->get( 'IPInfoMaxDistinctIPResults' ) )
150            ->parseAsBlock();
151
152        $form->addHeaderHtml( $legend );
153    }
154
155    /** @inheritDoc */
156    public function getDescription(): Message {
157        return $this->msg( 'ipinfo-special-ipinfo' );
158    }
159
160    protected function getMessagePrefix(): string {
161        // Possible message keys used here:
162        // * ipinfo-special-ipinfo-form-text
163        return 'ipinfo-special-ipinfo-form';
164    }
165
166    protected function getDisplayFormat(): string {
167        // Use OOUI rather than Codex for this form
168        // until a satisfactory solution for reusable MW-specific Codex widgets is devised (T334986).
169        return 'ooui';
170    }
171
172    protected function getShowAlways(): bool {
173        return true;
174    }
175
176    /**
177     * Process form data on submission.
178     * @param array $data Map of form data keyed by unprefixed field name
179     * @return Status
180     */
181    public function onSubmit( array $data ): Status {
182        $targetName = $data[self::TARGET_FIELD];
183
184        if ( !$this->userNameUtils->isTemp( $targetName ) ) {
185            return Status::newFatal( 'htmlform-user-not-valid', $targetName );
186        }
187
188        $targetUser = $this->userIdentityLookup->getUserIdentityByName( $targetName );
189        if ( $targetUser === null ) {
190            return Status::newFatal( 'htmlform-user-not-valid', $targetName );
191        }
192
193        if ( $this->didNotAcceptIPInfoAgreement() ) {
194            if ( !( $data[self::IP_INFO_AGREEMENT_FIELD] ?? false ) ) {
195                return Status::newFatal( 'ipinfo-preference-agreement-error' );
196            }
197
198            $user = $this->getUser()->getInstanceForUpdate();
199            $this->userOptionsManager->setOption( $user, 'ipinfo-use-agreement', '1' );
200            $user->saveSettings();
201        }
202
203        $this->targetUser = $targetUser;
204
205        return Status::newGood();
206    }
207
208    /** @inheritDoc */
209    public function onSuccess(): void {
210        $out = $this->getOutput();
211        $out->addModuleStyles( [ 'codex-styles', 'ext.ipInfo.specialIpInfo' ] );
212
213        $out->addSubtitle(
214            $this->msg( 'ipinfo-special-ipinfo-user-tool-links', $this->targetUser->getName() )->escaped() .
215            Linker::userToolLinks( $this->targetUser->getId(), $this->targetUser->getName() )
216        );
217
218        $records = $this->tempUserIPLookup->getDistinctIPInfo( $this->targetUser );
219
220        if ( count( $records ) === 0 ) {
221            $zeroStateMsg = $this->msg( 'ipinfo-special-ipinfo-no-results', $this->targetUser->getName() )->escaped();
222            $out->addHTML( Html::noticeBox( $zeroStateMsg, 'ext-ipinfo-special-ipinfo__zero-state' ) );
223            return;
224        }
225
226        $tableHeaders = [
227            [
228                'name' => 'address',
229                'title' => $this->msg( 'ipinfo-special-ipinfo-column-ip' )->text(),
230                'sortable' => false
231            ],
232            [
233                'name' => 'location',
234                'title' => $this->msg( 'ipinfo-property-label-location' )->text(),
235                'sortable' => true
236            ],
237            [
238                'name' => 'isp',
239                'title' => $this->msg( 'ipinfo-property-label-isp' )->text(),
240                'sortable' => true
241            ],
242            [
243                'name' => 'asn',
244                'title' => $this->msg( 'ipinfo-property-label-asn' )->text(),
245                'sortable' => true
246            ],
247            [
248                'name' => 'organization',
249                'title' => $this->msg( 'ipinfo-property-label-organization' )->text(),
250                'sortable' => true
251            ],
252            [
253                'name' => 'ipversion',
254                'title' => $this->msg( 'ipinfo-property-label-ipversion' )->text(),
255                'sortable' => true
256            ],
257            [
258                'name' => 'behaviors',
259                'title' => $this->msg( 'ipinfo-property-label-behaviors' )->text(),
260                'sortable' => true
261            ],
262            [
263                'name' => 'risks',
264                'title' => $this->msg( 'ipinfo-property-label-risks' )->text(),
265                'sortable' => true
266            ],
267            [
268                'name' => 'connectiontypes',
269                'title' => $this->msg( 'ipinfo-property-label-connectiontypes' )->text(),
270                'sortable' => true
271            ],
272            [
273                'name' => 'tunneloperators',
274                'title' => $this->msg( 'ipinfo-property-label-tunneloperators' )->text(),
275                'sortable' => true
276            ],
277            [
278                'name' => 'proxies',
279                'title' => $this->msg( 'ipinfo-property-label-proxies' )->text(),
280                'sortable' => true
281            ],
282            [
283                'name' => 'usercount',
284                'title' => $this->msg( 'ipinfo-property-label-usercount' )->text(),
285                'sortable' => true
286            ],
287        ];
288
289        $tableRows = [];
290
291        $commaMsg = $this->msg( 'comma-separator' )->text();
292        $ipv4Msg = $this->msg( 'ipinfo-value-ipversion-ipv4' )->text();
293        $ipv6Msg = $this->msg( 'ipinfo-value-ipversion-ipv6' )->text();
294
295        $batch = $this->infoManager->retrieveBatch(
296            $this->targetUser,
297            array_keys( $records ),
298            [
299                GeoLite2InfoRetriever::NAME,
300                IPoidInfoRetriever::NAME
301            ]
302        );
303
304        foreach ( $records as $record ) {
305            $info = $batch[$record->getIp()];
306            $info = $this->defaultPresenter->present( $info, $this->getContext()->getUser() );
307
308            $locations = array_map(
309                fn ( array $loc ): string => $loc['label'],
310                $info['data']['ipinfo-source-geoip2']['location'] ?? []
311            );
312
313            $risks = array_map(
314                function ( string $riskType ): string {
315                    $riskType = preg_replace( '/_/', '', $riskType );
316                    $riskType = mb_strtolower( $riskType );
317
318                    // See https://docs.spur.us/data-types?id=risk-enums
319                    // * ipinfo-property-value-risk-callbackproxy
320                    // * ipinfo-property-value-risk-geomismatch
321                    // * ipinfo-property-value-risk-loginbruteforce
322                    // * ipinfo-property-value-risk-tunnel
323                    // * ipinfo-property-value-risk-webscraping
324                    // * ipinfo-property-value-risk-unknown
325                    return $this->msg( "ipinfo-property-value-risk-$riskType" )->text();
326                },
327                $info['data']['ipinfo-source-ipoid']['risks'] ?? []
328            );
329
330            $connectionTypes = array_map(
331                function ( string $connectionType ): string {
332                    $connectionType = mb_strtolower( $connectionType );
333
334                    // See https://docs.spur.us/data-types?id=client-enums
335                    // * ipinfo-property-value-connectiontype-desktop
336                    // * ipinfo-property-value-connectiontype-headless
337                    // * ipinfo-property-value-connectiontype-iot
338                    // * ipinfo-property-value-connectiontype-mobile
339                    // * ipinfo-property-value-connectiontype-unknown
340                    return $this->msg( "ipinfo-property-value-connectionType-$connectionType" )->text();
341                },
342                $info['data']['ipinfo-source-ipoid']['connectionTypes'] ?? []
343            );
344
345            $userCount = $info['data']['ipinfo-source-ipoid']['numUsersOnThisIP'] ?? null;
346
347            $tableRows[] = [
348                'revId' => $record->getRevisionId(),
349                'logId' => $record->getLogId(),
350                'location' => implode( $commaMsg, $locations ),
351                'isp' => $info['data']['ipinfo-source-geoip2']['isp'] ?? '',
352                'asn' => $info['data']['ipinfo-source-geoip2']['asn'] ?? '',
353                'organization' => $info['data']['ipinfo-source-geoip2']['organization'] ?? '',
354                'ipversion' => IPUtils::isIPv4( $record->getIp() ) ? $ipv4Msg : $ipv6Msg,
355                'behaviors' => $info['data']['ipinfo-source-ipoid']['behaviors'] ?? '',
356                'risks' => $risks,
357                'connectiontypes' => $connectionTypes,
358                'tunneloperators' => $info['data']['ipinfo-source-ipoid']['tunneloperators'] ?? [],
359                'proxies' => $info['data']['ipinfo-source-ipoid']['proxies'] ?? [],
360                'usercount' => $userCount !== null ? $this->getLanguage()->formatNum( $userCount ) : ''
361            ];
362        }
363
364        $sortField = $this->getRequest()->getRawVal( 'wpSortField' );
365        $sortDirection = $this->getRequest()->getRawVal( 'wpSortDirection' );
366        foreach ( $tableHeaders as &$header ) {
367            if ( $header['sortable'] ) {
368                $header['isSorted'] = $sortField === $header['name'] && $sortDirection !== null;
369                if ( $header['isSorted'] ) {
370                    // Possible CSS classes that may be used here:
371                    // - ext-ipinfo-special-ipinfo__column--asc
372                    // - ext-ipinfo-special-ipinfo__column--desc
373                    $header['sortIconClass'] = "ext-ipinfo-special-ipinfo__column--{$sortDirection}";
374                    $header['ariaSort'] = $sortDirection === self::SORT_ASC ? 'ascending' : 'descending';
375                } else {
376                    $header['sortIconClass'] = 'ext-ipinfo-special-ipinfo__column--unsorted';
377                }
378
379                $header['nextSortDirection'] = $sortDirection === self::SORT_ASC ? self::SORT_DESC :
380                    self::SORT_ASC;
381            }
382        }
383
384        if ( $sortField !== null && $sortDirection !== null ) {
385            usort(
386                $tableRows,
387                static function ( array $row, array $otherRow ) use ( $sortDirection, $sortField ): int {
388                    return $sortDirection === self::SORT_ASC
389                        ? $row[$sortField] <=> $otherRow[$sortField]
390                        : $otherRow[$sortField] <=> $row[$sortField];
391                }
392            );
393        }
394
395        $out->addHTML(
396            $this->templateParser->processTemplate( 'IPInfo', [
397                'caption' => $this->msg( 'ipinfo-special-ipinfo-table-caption', $this->targetUser->getName() )->text(),
398                // Describe the functionality of sorting buttons to assistive technologies
399                // such as screen readers.
400                'sortExplainerCaption' => $this->msg( 'ipinfo-special-ipinfo-sort-explainer' )->text(),
401                'target' => $this->targetUser->getName(),
402                'headers' => $tableHeaders,
403                'rows' => $tableRows,
404            ] )
405        );
406    }
407}