Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
46.80% |
95 / 203 |
|
32.26% |
10 / 31 |
CRAP | |
0.00% |
0 / 1 |
MWHttpRequest | |
46.80% |
95 / 203 |
|
32.26% |
10 / 31 |
1483.80 | |
0.00% |
0 / 1 |
__construct | |
75.00% |
24 / 32 |
|
0.00% |
0 / 1 |
15.64 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
canMakeRequests | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getContent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setData | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
addTelemetry | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
proxySetup | |
61.54% |
8 / 13 |
|
0.00% |
0 / 1 |
6.42 | |||
setReverseProxy | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
3.05 | |||
isLocalURL | |
29.41% |
5 / 17 |
|
0.00% |
0 / 1 |
13.79 | |||
setUserAgent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setHeader | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getHeaderList | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
setCallback | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
doSetCallback | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
read | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
prepare | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
parseHeader | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
setStatus | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
getStatus | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
isRedirect | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
getResponseHeaders | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
getResponseHeader | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
setCookieJar | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCookieJar | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
setCookie | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
parseCookies | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
5.05 | |||
getFinalUrl | |
14.29% |
3 / 21 |
|
0.00% |
0 / 1 |
60.01 | |||
canFollowRedirects | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setOriginalRequest | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
isValidURI | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | use MediaWiki\MainConfigNames; |
22 | use MediaWiki\MediaWikiServices; |
23 | use MediaWiki\Request\WebRequest; |
24 | use MediaWiki\Status\Status; |
25 | use MediaWiki\Utils\UrlUtils; |
26 | use Psr\Log\LoggerAwareInterface; |
27 | use Psr\Log\LoggerInterface; |
28 | use Psr\Log\NullLogger; |
29 | use Wikimedia\Http\TelemetryHeadersInterface; |
30 | |
31 | /** |
32 | * This wrapper class will call out to curl (if available) or fallback |
33 | * to regular PHP if necessary for handling internal HTTP requests. |
34 | * |
35 | * Renamed from HttpRequest to MWHttpRequest to avoid conflict with |
36 | * PHP's HTTP extension. |
37 | */ |
38 | abstract class MWHttpRequest implements LoggerAwareInterface { |
39 | public const SUPPORTS_FILE_POSTS = false; |
40 | |
41 | /** |
42 | * @var int|string |
43 | */ |
44 | protected $timeout = 'default'; |
45 | |
46 | /** @var string|null */ |
47 | protected $content; |
48 | /** @var bool|null */ |
49 | protected $headersOnly = null; |
50 | /** @var array|null */ |
51 | protected $postData = null; |
52 | /** @var string|null */ |
53 | protected $proxy = null; |
54 | /** @var bool */ |
55 | protected $noProxy = false; |
56 | /** @var bool */ |
57 | protected $sslVerifyHost = true; |
58 | /** @var bool */ |
59 | protected $sslVerifyCert = true; |
60 | /** @var string|null */ |
61 | protected $caInfo = null; |
62 | /** @var string */ |
63 | protected $method = "GET"; |
64 | /** @var array */ |
65 | protected $reqHeaders = []; |
66 | /** @var string */ |
67 | protected $url; |
68 | /** @var array|false */ |
69 | protected $parsedUrl; |
70 | /** @var callable */ |
71 | protected $callback; |
72 | /** @var int */ |
73 | protected $maxRedirects = 5; |
74 | /** @var bool */ |
75 | protected $followRedirects = false; |
76 | /** @var int */ |
77 | protected $connectTimeout; |
78 | |
79 | /** |
80 | * @var CookieJar |
81 | */ |
82 | protected $cookieJar; |
83 | |
84 | /** @var array */ |
85 | protected $headerList = []; |
86 | /** @var string */ |
87 | protected $respVersion = "0.9"; |
88 | /** @var string */ |
89 | protected $respStatus = "200 Ok"; |
90 | /** @var string[][] */ |
91 | protected $respHeaders = []; |
92 | |
93 | /** @var StatusValue */ |
94 | protected $status; |
95 | |
96 | /** |
97 | * @var Profiler |
98 | */ |
99 | protected $profiler; |
100 | |
101 | /** |
102 | * @var string |
103 | */ |
104 | protected $profileName; |
105 | |
106 | /** |
107 | * @var LoggerInterface |
108 | */ |
109 | protected $logger; |
110 | |
111 | private UrlUtils $urlUtils; |
112 | |
113 | /** |
114 | * @param string $url Url to use. If protocol-relative, will be expanded to an http:// URL |
115 | * @param array $options extra params to pass (see HttpRequestFactory::create()) |
116 | * @phpcs:ignore Generic.Files.LineLength |
117 | * @phan-param array{timeout?:int|string,connectTimeout?:int|string,postData?:array,proxy?:string,noProxy?:bool,sslVerifyHost?:bool,sslVerifyCert?:bool,caInfo?:string,maxRedirects?:int,followRedirects?:bool,userAgent?:string,logger?:LoggerInterface,username?:string,password?:string,originalRequest?:WebRequest|array{ip:string,userAgent:string},method?:string} $options |
118 | * @param string $caller The method making this request, for profiling @phan-mandatory-param |
119 | * @param Profiler|null $profiler An instance of the profiler for profiling, or null |
120 | * @throws Exception |
121 | */ |
122 | public function __construct( |
123 | $url, array $options, $caller = __METHOD__, ?Profiler $profiler = null |
124 | ) { |
125 | $this->urlUtils = MediaWikiServices::getInstance()->getUrlUtils(); |
126 | if ( !array_key_exists( 'timeout', $options ) |
127 | || !array_key_exists( 'connectTimeout', $options ) ) { |
128 | throw new InvalidArgumentException( "timeout and connectionTimeout options are required" ); |
129 | } |
130 | $this->url = $this->urlUtils->expand( $url, PROTO_HTTP ) ?? false; |
131 | $this->parsedUrl = $this->urlUtils->parse( (string)$this->url ) ?? false; |
132 | |
133 | $this->logger = $options['logger'] ?? new NullLogger(); |
134 | $this->timeout = $options['timeout']; |
135 | $this->connectTimeout = $options['connectTimeout']; |
136 | |
137 | if ( !$this->parsedUrl || !self::isValidURI( $this->url ) ) { |
138 | $this->status = StatusValue::newFatal( 'http-invalid-url', $url ); |
139 | } else { |
140 | $this->status = StatusValue::newGood( 100 ); // continue |
141 | } |
142 | |
143 | if ( isset( $options['userAgent'] ) ) { |
144 | $this->setUserAgent( $options['userAgent'] ); |
145 | } |
146 | if ( isset( $options['username'] ) && isset( $options['password'] ) ) { |
147 | $this->setHeader( |
148 | 'Authorization', |
149 | 'Basic ' . base64_encode( $options['username'] . ':' . $options['password'] ) |
150 | ); |
151 | } |
152 | if ( isset( $options['originalRequest'] ) ) { |
153 | $this->setOriginalRequest( $options['originalRequest'] ); |
154 | } |
155 | |
156 | $members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo", |
157 | "method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ]; |
158 | |
159 | foreach ( $members as $o ) { |
160 | if ( isset( $options[$o] ) ) { |
161 | // ensure that MWHttpRequest::method is always |
162 | // uppercased. T38137 |
163 | if ( $o == 'method' ) { |
164 | // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive |
165 | $options[$o] = strtoupper( $options[$o] ); |
166 | } |
167 | $this->$o = $options[$o]; |
168 | } |
169 | } |
170 | |
171 | if ( $this->noProxy ) { |
172 | $this->proxy = ''; // noProxy takes precedence |
173 | } |
174 | |
175 | // Profile based on what's calling us |
176 | $this->profiler = $profiler; |
177 | $this->profileName = $caller; |
178 | } |
179 | |
180 | public function setLogger( LoggerInterface $logger ) { |
181 | $this->logger = $logger; |
182 | } |
183 | |
184 | /** |
185 | * Simple function to test if we can make any sort of requests at all, using |
186 | * cURL or fopen() |
187 | * @return bool |
188 | */ |
189 | public static function canMakeRequests() { |
190 | return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' ); |
191 | } |
192 | |
193 | /** |
194 | * Get the body, or content, of the response to the request |
195 | * |
196 | * @return string |
197 | */ |
198 | public function getContent() { |
199 | return $this->content; |
200 | } |
201 | |
202 | /** |
203 | * Set the parameters of the request |
204 | * |
205 | * @param array $args |
206 | * @todo overload the args param |
207 | */ |
208 | public function setData( array $args ) { |
209 | $this->postData = $args; |
210 | } |
211 | |
212 | /** |
213 | * Add Telemetry information to the request |
214 | * |
215 | * @param TelemetryHeadersInterface $telemetry |
216 | * @return void |
217 | */ |
218 | public function addTelemetry( TelemetryHeadersInterface $telemetry ): void { |
219 | foreach ( $telemetry->getRequestHeaders() as $header => $value ) { |
220 | $this->setHeader( $header, $value ); |
221 | } |
222 | } |
223 | |
224 | /** |
225 | * Take care of setting up the proxy (do nothing if "noProxy" is set) |
226 | * |
227 | * @return void |
228 | */ |
229 | protected function proxySetup() { |
230 | $httpProxy = MediaWikiServices::getInstance()->getMainConfig()->get( |
231 | MainConfigNames::HTTPProxy ); |
232 | $localHTTPProxy = MediaWikiServices::getInstance()->getMainConfig()->get( |
233 | MainConfigNames::LocalHTTPProxy ); |
234 | // If proxies are disabled, clear any other proxy |
235 | if ( $this->noProxy ) { |
236 | $this->proxy = ''; |
237 | return; |
238 | } |
239 | |
240 | // If there is an explicit proxy already set, use it |
241 | if ( $this->proxy ) { |
242 | return; |
243 | } |
244 | |
245 | // Otherwise, fallback to $wgLocalHTTPProxy for local URLs |
246 | // or $wgHTTPProxy for everything else |
247 | if ( self::isLocalURL( $this->url ) ) { |
248 | if ( $localHTTPProxy !== false ) { |
249 | $this->setReverseProxy( $localHTTPProxy ); |
250 | } |
251 | } else { |
252 | $this->proxy = (string)$httpProxy; |
253 | } |
254 | } |
255 | |
256 | /** |
257 | * Enable use of a reverse proxy in which the hostname is |
258 | * passed as a "Host" header, and the request is sent to the |
259 | * proxy's host:port instead. |
260 | * |
261 | * Note that any custom port in the request URL will be lost |
262 | * and cookies and redirects may not work properly. |
263 | * |
264 | * @param string $proxy URL of proxy |
265 | */ |
266 | protected function setReverseProxy( string $proxy ) { |
267 | $parsedProxy = $this->urlUtils->parse( $proxy ); |
268 | if ( $parsedProxy === null ) { |
269 | throw new InvalidArgumentException( "Invalid reverseProxy configured: $proxy" ); |
270 | } |
271 | // Set the current host in the Host header |
272 | $this->setHeader( 'Host', $this->parsedUrl['host'] ); |
273 | // Replace scheme, host and port in the request |
274 | $this->parsedUrl['scheme'] = $parsedProxy['scheme']; |
275 | $this->parsedUrl['host'] = $parsedProxy['host']; |
276 | if ( isset( $parsedProxy['port'] ) ) { |
277 | $this->parsedUrl['port'] = $parsedProxy['port']; |
278 | } else { |
279 | unset( $this->parsedUrl['port'] ); |
280 | } |
281 | $this->url = UrlUtils::assemble( $this->parsedUrl ); |
282 | // Mark that we're already using a proxy |
283 | $this->noProxy = true; |
284 | } |
285 | |
286 | /** |
287 | * Check if the URL can be served by a local endpoint |
288 | * |
289 | * @param string $url Full url to check |
290 | * @return bool |
291 | */ |
292 | private static function isLocalURL( $url ) { |
293 | $localVirtualHosts = MediaWikiServices::getInstance()->getMainConfig()->get( |
294 | MainConfigNames::LocalVirtualHosts ); |
295 | |
296 | // Extract host part |
297 | $matches = []; |
298 | if ( preg_match( '!^https?://([\w.-]+)[/:].*$!', $url, $matches ) ) { |
299 | $host = $matches[1]; |
300 | // Split up dotwise |
301 | $domainParts = explode( '.', $host ); |
302 | // Check if this domain or any superdomain is listed as a local virtual host |
303 | $domainParts = array_reverse( $domainParts ); |
304 | |
305 | $domain = ''; |
306 | $countParts = count( $domainParts ); |
307 | for ( $i = 0; $i < $countParts; $i++ ) { |
308 | $domainPart = $domainParts[$i]; |
309 | if ( $i == 0 ) { |
310 | $domain = $domainPart; |
311 | } else { |
312 | $domain = $domainPart . '.' . $domain; |
313 | } |
314 | |
315 | if ( in_array( $domain, $localVirtualHosts ) ) { |
316 | return true; |
317 | } |
318 | } |
319 | } |
320 | |
321 | return false; |
322 | } |
323 | |
324 | /** |
325 | * @param string $UA |
326 | */ |
327 | public function setUserAgent( $UA ) { |
328 | $this->setHeader( 'User-Agent', $UA ); |
329 | } |
330 | |
331 | /** |
332 | * Set an arbitrary header |
333 | * @param string $name |
334 | * @param string $value |
335 | */ |
336 | public function setHeader( $name, $value ) { |
337 | // I feel like I should normalize the case here... |
338 | $this->reqHeaders[$name] = $value; |
339 | } |
340 | |
341 | /** |
342 | * Get an array of the headers |
343 | * @return array |
344 | */ |
345 | protected function getHeaderList() { |
346 | $list = []; |
347 | |
348 | if ( $this->cookieJar ) { |
349 | $this->reqHeaders['Cookie'] = |
350 | $this->cookieJar->serializeToHttpRequest( |
351 | $this->parsedUrl['path'], |
352 | $this->parsedUrl['host'] |
353 | ); |
354 | } |
355 | |
356 | foreach ( $this->reqHeaders as $name => $value ) { |
357 | $list[] = "$name: $value"; |
358 | } |
359 | |
360 | return $list; |
361 | } |
362 | |
363 | /** |
364 | * Set a read callback to accept data read from the HTTP request. |
365 | * By default, data is appended to an internal buffer which can be |
366 | * retrieved through $req->getContent(). |
367 | * |
368 | * To handle data as it comes in -- especially for large files that |
369 | * would not fit in memory -- you can instead set your own callback, |
370 | * in the form function($resource, $buffer) where the first parameter |
371 | * is the low-level resource being read (implementation specific), |
372 | * and the second parameter is the data buffer. |
373 | * |
374 | * You MUST return the number of bytes handled in the buffer; if fewer |
375 | * bytes are reported handled than were passed to you, the HTTP fetch |
376 | * will be aborted. |
377 | * |
378 | * @param callable|null $callback |
379 | * @throws InvalidArgumentException |
380 | */ |
381 | public function setCallback( $callback ) { |
382 | $this->doSetCallback( $callback ); |
383 | } |
384 | |
385 | /** |
386 | * Worker function for setting callbacks. Calls can originate both internally and externally |
387 | * via setCallback). Defaults to the internal read callback if $callback is null. |
388 | * |
389 | * @param callable|null $callback |
390 | * @throws InvalidArgumentException |
391 | */ |
392 | protected function doSetCallback( $callback ) { |
393 | if ( $callback === null ) { |
394 | $callback = [ $this, 'read' ]; |
395 | } elseif ( !is_callable( $callback ) ) { |
396 | $this->status->fatal( 'http-internal-error' ); |
397 | throw new InvalidArgumentException( __METHOD__ . ': invalid callback' ); |
398 | } |
399 | $this->callback = $callback; |
400 | } |
401 | |
402 | /** |
403 | * A generic callback to read the body of the response from a remote |
404 | * server. |
405 | * |
406 | * @param resource $fh |
407 | * @param string $content |
408 | * @return int |
409 | * @internal |
410 | */ |
411 | public function read( $fh, $content ) { |
412 | $this->content .= $content; |
413 | return strlen( $content ); |
414 | } |
415 | |
416 | /** |
417 | * Take care of whatever is necessary to perform the URI request. |
418 | * |
419 | * @return Status |
420 | * @note currently returns Status for B/C |
421 | */ |
422 | public function execute() { |
423 | throw new LogicException( 'children must override this' ); |
424 | } |
425 | |
426 | protected function prepare() { |
427 | $this->content = ""; |
428 | |
429 | if ( strtoupper( $this->method ) == "HEAD" ) { |
430 | $this->headersOnly = true; |
431 | } |
432 | |
433 | $this->proxySetup(); // set up any proxy as needed |
434 | |
435 | if ( !$this->callback ) { |
436 | $this->doSetCallback( null ); |
437 | } |
438 | |
439 | if ( !isset( $this->reqHeaders['User-Agent'] ) ) { |
440 | $http = MediaWikiServices::getInstance()->getHttpRequestFactory(); |
441 | $this->setUserAgent( $http->getUserAgent() ); |
442 | } |
443 | } |
444 | |
445 | /** |
446 | * Parses the headers, including the HTTP status code and any |
447 | * Set-Cookie headers. This function expects the headers to be |
448 | * found in an array in the member variable headerList. |
449 | */ |
450 | protected function parseHeader() { |
451 | $lastname = ""; |
452 | |
453 | // Failure without (valid) headers gets a response status of zero |
454 | if ( !$this->status->isOK() ) { |
455 | $this->respStatus = '0 Error'; |
456 | } |
457 | |
458 | foreach ( $this->headerList as $header ) { |
459 | if ( preg_match( "#^HTTP/([0-9.]+) (.*)#", $header, $match ) ) { |
460 | $this->respVersion = $match[1]; |
461 | $this->respStatus = $match[2]; |
462 | } elseif ( preg_match( "#^[ \t]#", $header ) ) { |
463 | $last = count( $this->respHeaders[$lastname] ) - 1; |
464 | $this->respHeaders[$lastname][$last] .= "\r\n$header"; |
465 | } elseif ( preg_match( "#^([^:]*):[\t ]*(.*)#", $header, $match ) ) { |
466 | $this->respHeaders[strtolower( $match[1] )][] = $match[2]; |
467 | $lastname = strtolower( $match[1] ); |
468 | } |
469 | } |
470 | |
471 | $this->parseCookies(); |
472 | } |
473 | |
474 | /** |
475 | * Sets HTTPRequest status member to a fatal value with the error |
476 | * message if the returned integer value of the status code was |
477 | * not successful (1-299) or a redirect (300-399). |
478 | * See RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html |
479 | * for a list of status codes. |
480 | */ |
481 | protected function setStatus() { |
482 | if ( !$this->respHeaders ) { |
483 | $this->parseHeader(); |
484 | } |
485 | |
486 | if ( (int)$this->respStatus > 0 && (int)$this->respStatus < 400 ) { |
487 | $this->status->setResult( true, (int)$this->respStatus ); |
488 | } else { |
489 | [ $code, $message ] = explode( " ", $this->respStatus, 2 ); |
490 | $this->status->setResult( false, (int)$this->respStatus ); |
491 | $this->status->fatal( "http-bad-status", $code, $message ); |
492 | } |
493 | } |
494 | |
495 | /** |
496 | * Get the integer value of the HTTP status code (e.g. 200 for "200 Ok") |
497 | * (see RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html |
498 | * for a list of status codes.) |
499 | * |
500 | * @return int |
501 | */ |
502 | public function getStatus() { |
503 | if ( !$this->respHeaders ) { |
504 | $this->parseHeader(); |
505 | } |
506 | |
507 | return (int)$this->respStatus; |
508 | } |
509 | |
510 | /** |
511 | * Returns true if the last status code was a redirect. |
512 | * |
513 | * @return bool |
514 | */ |
515 | public function isRedirect() { |
516 | if ( !$this->respHeaders ) { |
517 | $this->parseHeader(); |
518 | } |
519 | |
520 | $status = (int)$this->respStatus; |
521 | |
522 | if ( $status >= 300 && $status <= 303 ) { |
523 | return true; |
524 | } |
525 | |
526 | return false; |
527 | } |
528 | |
529 | /** |
530 | * Returns an associative array of response headers after the |
531 | * request has been executed. Because some headers |
532 | * (e.g. Set-Cookie) can appear more than once the, each value of |
533 | * the associative array is an array of the values given. |
534 | * Header names are always in lowercase. |
535 | * |
536 | * @return array |
537 | */ |
538 | public function getResponseHeaders() { |
539 | if ( !$this->respHeaders ) { |
540 | $this->parseHeader(); |
541 | } |
542 | |
543 | return $this->respHeaders; |
544 | } |
545 | |
546 | /** |
547 | * Returns the value of the given response header. |
548 | * |
549 | * @param string $header case-insensitive |
550 | * @return string|null |
551 | */ |
552 | public function getResponseHeader( $header ) { |
553 | if ( !$this->respHeaders ) { |
554 | $this->parseHeader(); |
555 | } |
556 | |
557 | if ( isset( $this->respHeaders[strtolower( $header )] ) ) { |
558 | $v = $this->respHeaders[strtolower( $header )]; |
559 | return $v[count( $v ) - 1]; |
560 | } |
561 | |
562 | return null; |
563 | } |
564 | |
565 | /** |
566 | * Tells the MWHttpRequest object to use this pre-loaded CookieJar. |
567 | * |
568 | * To read response cookies from the jar, getCookieJar must be called first. |
569 | */ |
570 | public function setCookieJar( CookieJar $jar ) { |
571 | $this->cookieJar = $jar; |
572 | } |
573 | |
574 | /** |
575 | * Returns the cookie jar in use. |
576 | * |
577 | * @return CookieJar |
578 | */ |
579 | public function getCookieJar() { |
580 | if ( !$this->respHeaders ) { |
581 | $this->parseHeader(); |
582 | } |
583 | |
584 | return $this->cookieJar; |
585 | } |
586 | |
587 | /** |
588 | * Sets a cookie. Used before a request to set up any individual |
589 | * cookies. Used internally after a request to parse the |
590 | * Set-Cookie headers. |
591 | * @see Cookie::set |
592 | * @param string $name |
593 | * @param string $value |
594 | * @param array $attr |
595 | */ |
596 | public function setCookie( $name, $value, array $attr = [] ) { |
597 | if ( !$this->cookieJar ) { |
598 | $this->cookieJar = new CookieJar; |
599 | } |
600 | |
601 | if ( $this->parsedUrl && !isset( $attr['domain'] ) ) { |
602 | $attr['domain'] = $this->parsedUrl['host']; |
603 | } |
604 | |
605 | $this->cookieJar->setCookie( $name, $value, $attr ); |
606 | } |
607 | |
608 | /** |
609 | * Parse the cookies in the response headers and store them in the cookie jar. |
610 | */ |
611 | protected function parseCookies() { |
612 | if ( !$this->cookieJar ) { |
613 | $this->cookieJar = new CookieJar; |
614 | } |
615 | |
616 | if ( isset( $this->respHeaders['set-cookie'] ) ) { |
617 | $url = parse_url( $this->getFinalUrl() ); |
618 | if ( !isset( $url['host'] ) ) { |
619 | $this->status->fatal( 'http-invalid-url', $this->getFinalUrl() ); |
620 | } else { |
621 | foreach ( $this->respHeaders['set-cookie'] as $cookie ) { |
622 | $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] ); |
623 | } |
624 | } |
625 | } |
626 | } |
627 | |
628 | /** |
629 | * Returns the final URL after all redirections. |
630 | * |
631 | * Relative values of the "Location" header are incorrect as |
632 | * stated in RFC, however they do happen and modern browsers |
633 | * support them. This function loops backwards through all |
634 | * locations in order to build the proper absolute URI - Marooned |
635 | * at wikia-inc.com |
636 | * |
637 | * Note that the multiple Location: headers are an artifact of |
638 | * CURL -- they shouldn't actually get returned this way. Rewrite |
639 | * this when T31232 is taken care of (high-level redirect |
640 | * handling rewrite). |
641 | * |
642 | * @return string |
643 | */ |
644 | public function getFinalUrl() { |
645 | $headers = $this->getResponseHeaders(); |
646 | |
647 | // return full url (fix for incorrect but handled relative location) |
648 | if ( isset( $headers['location'] ) ) { |
649 | $locations = $headers['location']; |
650 | $domain = ''; |
651 | $foundRelativeURI = false; |
652 | $countLocations = count( $locations ); |
653 | |
654 | for ( $i = $countLocations - 1; $i >= 0; $i-- ) { |
655 | $url = parse_url( $locations[$i] ); |
656 | |
657 | if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) { |
658 | $domain = $url['scheme'] . '://' . $url['host']; |
659 | break; // found correct URI (with host) |
660 | } else { |
661 | $foundRelativeURI = true; |
662 | } |
663 | } |
664 | |
665 | if ( !$foundRelativeURI ) { |
666 | return $locations[$countLocations - 1]; |
667 | } |
668 | if ( $domain ) { |
669 | return $domain . $locations[$countLocations - 1]; |
670 | } |
671 | $url = parse_url( $this->url ); |
672 | if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) { |
673 | return $url['scheme'] . '://' . $url['host'] . |
674 | $locations[$countLocations - 1]; |
675 | } |
676 | } |
677 | |
678 | return $this->url; |
679 | } |
680 | |
681 | /** |
682 | * Returns true if the backend can follow redirects. Overridden by the |
683 | * child classes. |
684 | * @return bool |
685 | */ |
686 | public function canFollowRedirects() { |
687 | return true; |
688 | } |
689 | |
690 | /** |
691 | * Set information about the original request. This can be useful for |
692 | * endpoints/API modules which act as a proxy for some service, and |
693 | * throttling etc. needs to happen in that service. |
694 | * Calling this will result in the X-Forwarded-For and X-Original-User-Agent |
695 | * headers being set. |
696 | * @param WebRequest|array $originalRequest When in array form, it's |
697 | * expected to have the keys 'ip' and 'userAgent'. |
698 | * @note IP/user agent is personally identifiable information, and should |
699 | * only be set when the privacy policy of the request target is |
700 | * compatible with that of the MediaWiki installation. |
701 | */ |
702 | public function setOriginalRequest( $originalRequest ) { |
703 | if ( $originalRequest instanceof WebRequest ) { |
704 | $originalRequest = [ |
705 | 'ip' => $originalRequest->getIP(), |
706 | 'userAgent' => $originalRequest->getHeader( 'User-Agent' ), |
707 | ]; |
708 | } elseif ( |
709 | !is_array( $originalRequest ) |
710 | || array_diff( [ 'ip', 'userAgent' ], array_keys( $originalRequest ) ) |
711 | ) { |
712 | throw new InvalidArgumentException( __METHOD__ . ': $originalRequest must be a ' |
713 | . "WebRequest or an array with 'ip' and 'userAgent' keys" ); |
714 | } |
715 | |
716 | $this->reqHeaders['X-Forwarded-For'] = $originalRequest['ip']; |
717 | $this->reqHeaders['X-Original-User-Agent'] = $originalRequest['userAgent']; |
718 | } |
719 | |
720 | /** |
721 | * Check that the given URI is a valid one. |
722 | * |
723 | * This hardcodes a small set of protocols only, because we want to |
724 | * deterministically reject protocols not supported by all HTTP-transport |
725 | * methods. |
726 | * |
727 | * "file://" specifically must not be allowed, for security reasons |
728 | * (see <https://www.mediawiki.org/wiki/Special:Code/MediaWiki/r67684>). |
729 | * |
730 | * @todo FIXME this is wildly inaccurate and fails to actually check most stuff |
731 | * |
732 | * @since 1.34 |
733 | * @param string $uri URI to check for validity |
734 | * @return bool |
735 | */ |
736 | public static function isValidURI( $uri ) { |
737 | return (bool)preg_match( |
738 | '/^https?:\/\/[^\/\s]\S*$/D', |
739 | $uri |
740 | ); |
741 | } |
742 | } |