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