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