Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.91% |
211 / 220 |
|
78.57% |
11 / 14 |
CRAP | |
0.00% |
0 / 1 |
SpecialIPInfo | |
95.91% |
211 / 220 |
|
78.57% |
11 / 14 |
36 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
didNotAcceptIPInfoAgreement | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
requiresPost | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSubpageField | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getFormFields | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
4 | |||
alterForm | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getDescription | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMessagePrefix | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDisplayFormat | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getShowAlways | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
onSubmit | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
5.25 | |||
onSuccess | |
96.71% |
147 / 152 |
|
0.00% |
0 / 1 |
14 |
1 | <?php |
2 | namespace MediaWiki\IPInfo\Special; |
3 | |
4 | use MediaWiki\Config\Config; |
5 | use MediaWiki\Config\ServiceOptions; |
6 | use MediaWiki\Html\Html; |
7 | use MediaWiki\Html\TemplateParser; |
8 | use MediaWiki\HTMLForm\HTMLForm; |
9 | use MediaWiki\IPInfo\InfoManager; |
10 | use MediaWiki\IPInfo\InfoRetriever\GeoLite2InfoRetriever; |
11 | use MediaWiki\IPInfo\InfoRetriever\IPoidInfoRetriever; |
12 | use MediaWiki\IPInfo\Rest\Presenter\DefaultPresenter; |
13 | use MediaWiki\IPInfo\TempUserIPLookup; |
14 | use MediaWiki\Linker\Linker; |
15 | use MediaWiki\Message\Message; |
16 | use MediaWiki\Permissions\PermissionManager; |
17 | use MediaWiki\SpecialPage\FormSpecialPage; |
18 | use MediaWiki\Status\Status; |
19 | use MediaWiki\User\Options\UserOptionsLookup; |
20 | use MediaWiki\User\Options\UserOptionsManager; |
21 | use MediaWiki\User\UserIdentity; |
22 | use MediaWiki\User\UserIdentityLookup; |
23 | use MediaWiki\User\UserNameUtils; |
24 | use UserBlockedError; |
25 | use Wikimedia\IPUtils; |
26 | use Wikimedia\ObjectCache\BagOStuff; |
27 | |
28 | /** |
29 | * A special page that displays IP information for all IP addresses used by a temporary user. |
30 | */ |
31 | class 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 | } |