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