Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.37% covered (success)
97.37%
74 / 76
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
IPInfoHandler
97.37% covered (success)
97.37%
74 / 76
80.00% covered (warning)
80.00%
4 / 5
19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getInfo
n/a
0 / 0
n/a
0 / 0
0
 run
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
15
 logAccess
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getParamSettings
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 getBodyParamSettings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\IPInfo\Rest\Handler;
4
5use JobQueueGroup;
6use MediaWiki\IPInfo\AccessLevelTrait;
7use MediaWiki\IPInfo\InfoManager;
8use MediaWiki\IPInfo\Jobs\LogIPInfoAccessJob;
9use MediaWiki\IPInfo\Rest\Presenter\DefaultPresenter;
10use MediaWiki\IPInfo\TempUserIPLookup;
11use MediaWiki\Languages\LanguageFallback;
12use MediaWiki\Permissions\PermissionManager;
13use MediaWiki\Registration\ExtensionRegistry;
14use MediaWiki\Rest\LocalizedHttpException;
15use MediaWiki\Rest\Response;
16use MediaWiki\Rest\SimpleHandler;
17use MediaWiki\Rest\TokenAwareHandlerTrait;
18use MediaWiki\User\Options\UserOptionsLookup;
19use MediaWiki\User\UserFactory;
20use MediaWiki\User\UserIdentity;
21use MediaWiki\User\UserIdentityUtils;
22use Wikimedia\Message\MessageValue;
23use Wikimedia\ParamValidator\ParamValidator;
24
25abstract class IPInfoHandler extends SimpleHandler {
26
27    use AccessLevelTrait;
28    use TokenAwareHandlerTrait;
29
30    /**
31     * An array of contexts and the data
32     * that should be available in those contexts
33     *
34     * @var array
35     */
36    protected const VIEWING_CONTEXTS = [
37        'popup' => [
38            'country',
39            'countryNames',
40            'location',
41            'organization',
42            'numActiveBlocks',
43            'numLocalEdits',
44            'numRecentEdits',
45        ],
46        'infobox' => [
47            'country',
48            'countryNames',
49            'location',
50            'connectionType',
51            'userType',
52            'asn',
53            'isp',
54            'organization',
55            'proxyType',
56            'behaviors',
57            'risks',
58            'connectionTypes',
59            'tunnelOperators',
60            'proxies',
61            'numUsersOnThisIP',
62            'numIPAddresses',
63            'numActiveBlocks',
64            'numLocalEdits',
65            'numRecentEdits',
66            'numDeletedEdits',
67            'version',
68        ]
69    ];
70
71    protected InfoManager $infoManager;
72
73    protected PermissionManager $permissionManager;
74
75    protected UserOptionsLookup $userOptionsLookup;
76
77    protected UserFactory $userFactory;
78
79    protected DefaultPresenter $presenter;
80
81    protected JobQueueGroup $jobQueueGroup;
82
83    protected LanguageFallback $languageFallback;
84
85    protected UserIdentityUtils $userIdentityUtils;
86
87    protected TempUserIPLookup $tempUserIPLookup;
88
89    private ExtensionRegistry $extensionRegistry;
90
91    public function __construct(
92        InfoManager $infoManager,
93        PermissionManager $permissionManager,
94        UserOptionsLookup $userOptionsLookup,
95        UserFactory $userFactory,
96        DefaultPresenter $presenter,
97        JobQueueGroup $jobQueueGroup,
98        LanguageFallback $languageFallback,
99        UserIdentityUtils $userIdentityUtils,
100        TempUserIPLookup $tempUserIPLookup,
101        ?ExtensionRegistry $extensionRegistry = null
102    ) {
103        $this->infoManager = $infoManager;
104        $this->permissionManager = $permissionManager;
105        $this->userOptionsLookup = $userOptionsLookup;
106        $this->userFactory = $userFactory;
107        $this->presenter = $presenter;
108        $this->jobQueueGroup = $jobQueueGroup;
109        $this->languageFallback = $languageFallback;
110        $this->userIdentityUtils = $userIdentityUtils;
111        $this->tempUserIPLookup = $tempUserIPLookup;
112        $this->extensionRegistry = $extensionRegistry ?? ExtensionRegistry::getInstance();
113    }
114
115    /**
116     * Get information about an IP address (or IP addresses) associated with some entity,
117     * given an ID for the entity.
118     *
119     * Concrete subclasses handle for specific entity types, e.g. revision, log entry, etc.
120     *
121     * @param int $id
122     * @return array[]
123     *  Each array in this array has the following structure:
124     *  - 'subject': IP address
125     *  - 'data': array of arrays, each with the following structure:
126     *    - data source as a string => data array
127     *  TODO: Task to codify this better
128     */
129    abstract protected function getInfo( int $id ): array;
130
131    /**
132     * Get information about an IP address, based on the ID of some related entity
133     * (e.g. a revision, log entry, etc.)
134     *
135     * @param int $id
136     * @return Response
137     */
138    public function run( int $id ): Response {
139        $isBetaFeaturesLoaded = $this->extensionRegistry->isLoaded( 'BetaFeatures' );
140        // Disallow access to API if BetaFeatures is enabled but the feature is not
141        if ( $isBetaFeaturesLoaded &&
142            !$this->userOptionsLookup->getOption( $this->getAuthority()->getUser(), 'ipinfo-beta-feature-enable' ) ) {
143            throw new LocalizedHttpException(
144                new MessageValue( 'ipinfo-rest-access-denied' ),
145                $this->getAuthority()->getUser()->isRegistered() ? 403 : 401
146            );
147        }
148
149        if (
150            !$this->permissionManager->userHasRight( $this->getAuthority()->getUser(), 'ipinfo' ) ||
151            !$this->userOptionsLookup->getOption( $this->getAuthority()->getUser(), 'ipinfo-use-agreement' )
152        ) {
153            throw new LocalizedHttpException(
154                new MessageValue( 'ipinfo-rest-access-denied' ),
155                $this->getAuthority()->getUser()->isRegistered() ? 403 : 401
156            );
157        }
158        $user = $this->userFactory->newFromUserIdentity( $this->getAuthority()->getUser() );
159
160        // Users with blocks on their accounts shouldn't be allowed to view ip info
161        if ( $user->getBlock() ) {
162            throw new LocalizedHttpException(
163                new MessageValue( 'ipinfo-rest-access-denied-blocked-user' ),
164                403
165            );
166        }
167
168        // Validate the CSRF token. We shouldn't need to allow anon CSRF tokens.
169        $this->validateToken();
170
171        $info = $this->getInfo( $id );
172        $userLang = strtolower( $this->getValidatedParams()['language'] );
173        $langCodes = array_unique( array_merge(
174            [ $userLang ],
175            $this->languageFallback->getAll( $userLang )
176        ) );
177
178        // Only show data required for the context
179        $dataContext = $this->getValidatedParams()['dataContext'];
180        foreach ( $info as $index => $set ) {
181            if ( !isset( $set['data'] ) ) {
182                continue;
183            }
184            $info[$index] += [ 'language-fallback' => $langCodes ];
185            foreach ( $set['data'] as $provider => $dataset ) {
186                foreach ( $dataset as $datum => $value ) {
187                    if ( !in_array( $datum, self::VIEWING_CONTEXTS[$dataContext] ?? [] ) ) {
188                        unset( $info[$index]['data'][$provider][$datum] );
189                    }
190                }
191            }
192        }
193
194        foreach ( $info as $index => $set ) {
195            if ( !isset( $set['subject'] ) ) {
196                continue;
197            }
198            $this->logAccess( $this->getAuthority()->getUser(), $set['subject'], $dataContext );
199        }
200
201        $response = $this->getResponseFactory()->createJson( [ 'info' => $info ] );
202        $response->setHeader( 'Cache-Control', 'private, max-age=86400' );
203        return $response;
204    }
205
206    /**
207     * Log that the IP information was accessed. See also LogIPInfoAccessJob.
208     *
209     * @param UserIdentity $accessingUser
210     * @param string $targetName Name of the user whose information was accessed
211     * @param string $dataContext 'infobox' or 'popup'
212     */
213    protected function logAccess( $accessingUser, $targetName, $dataContext ): void {
214        $level = $this->highestAccessLevel(
215            $this->permissionManager->getUserPermissions( $accessingUser )
216        );
217        $this->jobQueueGroup->push(
218            LogIPInfoAccessJob::newSpecification( $accessingUser, $targetName, $dataContext, $level )
219        );
220    }
221
222    /** @inheritDoc */
223    public function getParamSettings() {
224        return [
225                'id' => [
226                    self::PARAM_SOURCE => 'path',
227                    ParamValidator::PARAM_TYPE => 'integer',
228                    ParamValidator::PARAM_REQUIRED => true,
229                ],
230                'dataContext' => [
231                    self::PARAM_SOURCE => 'query',
232                    ParamValidator::PARAM_TYPE => 'string',
233                    ParamValidator::PARAM_REQUIRED => true,
234                ],
235                'language' => [
236                    self::PARAM_SOURCE => 'query',
237                    ParamValidator::PARAM_TYPE => 'string',
238                    ParamValidator::PARAM_REQUIRED => true,
239                ],
240            ];
241    }
242
243    public function getBodyParamSettings(): array {
244        return $this->getTokenParamDefinition();
245    }
246
247}