Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.59% covered (warning)
70.59%
36 / 51
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
RemoteApiRequestExecutor
70.59% covered (warning)
70.59%
36 / 51
71.43% covered (warning)
71.43%
5 / 7
26.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
n/a
0 / 0
n/a
0 / 0
1
 execute
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
6.00
 getCentralId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canUseCentralAuth
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getAuthorizedApiUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getCsrfToken
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 doRequest
47.62% covered (danger)
47.62%
10 / 21
0.00% covered (danger)
0.00%
0 / 1
8.59
1<?php
2
3namespace FileImporter\Remote\MediaWiki;
4
5use CentralIdLookup;
6use Exception;
7use FileImporter\Data\SourceUrl;
8use FileImporter\Services\Http\HttpRequestExecutor;
9use MediaWiki\User\UserIdentity;
10use Psr\Log\LoggerAwareInterface;
11use Psr\Log\LoggerInterface;
12use Psr\Log\NullLogger;
13use User;
14
15/**
16 * Use CentralAuth to execute API calls on a sibling wiki.
17 *
18 * @license GPL-2.0-or-later
19 */
20class RemoteApiRequestExecutor implements LoggerAwareInterface {
21
22    /**
23     * @var LoggerInterface
24     */
25    private $logger;
26
27    /**
28     * @var HttpApiLookup
29     */
30    private $httpApiLookup;
31
32    /**
33     * @var HttpRequestExecutor
34     */
35    private $httpRequestExecutor;
36
37    /**
38     * @var CentralAuthTokenProvider
39     */
40    private $centralAuthTokenProvider;
41
42    /**
43     * @var CentralIdLookup
44     */
45    private $centralIdLookup;
46
47    /**
48     * @param HttpApiLookup $httpApiLookup
49     * @param HttpRequestExecutor $httpRequestExecutor
50     * @param CentralAuthTokenProvider $centralAuthTokenProvider
51     * @param CentralIdLookup $centralIdLookup
52     */
53    public function __construct(
54        HttpApiLookup $httpApiLookup,
55        HttpRequestExecutor $httpRequestExecutor,
56        CentralAuthTokenProvider $centralAuthTokenProvider,
57        CentralIdLookup $centralIdLookup
58    ) {
59        $this->httpApiLookup = $httpApiLookup;
60        $this->httpRequestExecutor = $httpRequestExecutor;
61        $this->centralAuthTokenProvider = $centralAuthTokenProvider;
62        $this->centralIdLookup = $centralIdLookup;
63        $this->logger = new NullLogger();
64    }
65
66    /**
67     * @param LoggerInterface $logger
68     * @codeCoverageIgnore
69     */
70    public function setLogger( LoggerInterface $logger ) {
71        $this->logger = $logger;
72    }
73
74    /**
75     * @param SourceUrl $sourceUrl
76     * @param User $user
77     * @param array $params API request params
78     * @param bool $usePost
79     * @return array|null Null in case of an error. Calling code can't understand why, but the error
80     *  is logged.
81     */
82    public function execute(
83        SourceUrl $sourceUrl,
84        User $user,
85        array $params,
86        bool $usePost = false
87    ): ?array {
88        // TODO handle error
89        if ( !$this->canUseCentralAuth( $user ) ) {
90            $this->logger->error( __METHOD__ . ' user can\'t use CentralAuth.' );
91            return null;
92        }
93
94        $result = $this->doRequest( $sourceUrl, $user, $params, $usePost );
95
96        // It's an array of "errors" with errorformat=plaintext, but a single "error" without.
97        // Each error contains "code" and "info" with formatversion=2, but "code" and "*" without.
98        if ( isset( $result['errors'] ) || isset( $result['error'] ) ) {
99            $this->logger->error( 'Remote API responded with an error', [
100                'sourceUrl' => $sourceUrl->getUrl(),
101                'apiParameters' => $params,
102                'response' => $result,
103            ] );
104        }
105
106        return $result;
107    }
108
109    /**
110     * @param UserIdentity $user
111     * @return int
112     */
113    private function getCentralId( UserIdentity $user ): int {
114        return $this->centralIdLookup->centralIdFromLocalUser(
115            $user,
116            CentralIdLookup::AUDIENCE_RAW
117        );
118    }
119
120    /**
121     * @param User $user
122     * @return bool
123     */
124    private function canUseCentralAuth( User $user ): bool {
125        return $user->isSafeToLoad() &&
126            $this->getCentralId( $user ) !== 0;
127    }
128
129    /**
130     * @param SourceUrl $sourceUrl
131     * @param User $user
132     * @return string
133     * @throws Exception
134     */
135    private function getAuthorizedApiUrl(
136        SourceUrl $sourceUrl,
137        User $user
138    ): string {
139        $url = $this->httpApiLookup->getApiUrl( $sourceUrl );
140        return wfAppendQuery( $url, [
141            'centralauthtoken' => $this->centralAuthTokenProvider->getToken( $user ),
142        ] );
143    }
144
145    /**
146     * @param SourceUrl $sourceUrl
147     * @param User $user
148     * @return string|null
149     */
150    public function getCsrfToken( SourceUrl $sourceUrl, User $user ): ?string {
151        try {
152            $tokenRequestUrl = $this->getAuthorizedApiUrl( $sourceUrl, $user );
153        } catch ( Exception $ex ) {
154            $this->logger->error( 'Failed to get centralauthtoken: ' .
155                $ex->getMessage() );
156            return null;
157        }
158        $tokenRequest = $this->httpRequestExecutor->execute( $tokenRequestUrl, [
159            'action' => 'query',
160            'meta' => 'tokens',
161            'type' => 'csrf',
162            'format' => 'json',
163            'formatversion' => 2,
164        ] );
165
166        $tokenData = json_decode( $tokenRequest->getContent(), true );
167        $token = $tokenData['query']['tokens']['csrftoken'] ?? null;
168
169        if ( !$token ) {
170            $this->logger->error( __METHOD__ . ' failed to get CSRF token.' );
171        }
172
173        return $token;
174    }
175
176    /**
177     * @param SourceUrl $sourceUrl
178     * @param User $user
179     * @param array $params API request params
180     * @param bool $usePost
181     * @return array|null Null in case of an error. Calling code can't understand why, but the error
182     *  is logged.
183     */
184    private function doRequest(
185        SourceUrl $sourceUrl,
186        User $user,
187        array $params,
188        bool $usePost
189    ): ?array {
190        /** @var array|null $result */
191        $result = null;
192        /** @var \MWHttpRequest|null $request */
193        $request = null;
194
195        try {
196            $requestUrl = $this->getAuthorizedApiUrl( $sourceUrl, $user );
197            $this->logger->debug( 'Got cross-wiki, authorized API URL: ' . $requestUrl );
198            if ( $usePost ) {
199                $request = $this->httpRequestExecutor->executePost( $requestUrl, $params );
200            } else {
201                $request = $this->httpRequestExecutor->execute( $requestUrl, $params );
202            }
203            $result = json_decode( $request->getContent(), true );
204            if ( $result === null ) {
205                $this->logger->error( __METHOD__ . ' failed to decode response from ' .
206                    $request->getFinalUrl() );
207            }
208        } catch ( Exception $ex ) {
209            if ( !$request ) {
210                $msg = __METHOD__ . ' failed to do remote request to ' . $sourceUrl->getHost() .
211                    ' with params ' . json_encode( $params ) . ': ' . $ex->getMessage();
212            } else {
213                $msg = __METHOD__ . ' failed to do remote request to ' . $request->getFinalUrl() .
214                    ' with params ' . json_encode( $params ) .
215                    ' and response headers ' . json_encode( $request->getResponseHeaders() ) .
216                    ': ' . $ex->getMessage();
217            }
218            $this->logger->error( $msg );
219        }
220
221        return $result;
222    }
223
224}