MediaWiki 1.41.2
MWHttpRequest.php
Go to the documentation of this file.
1<?php
25use Psr\Log\LoggerAwareInterface;
26use Psr\Log\LoggerInterface;
27use Psr\Log\NullLogger;
29
37abstract class MWHttpRequest implements LoggerAwareInterface {
38 public const SUPPORTS_FILE_POSTS = false;
39
43 protected $timeout = 'default';
44
45 protected $content;
46 protected $headersOnly = null;
47 protected $postData = null;
48 protected $proxy = null;
49 protected $noProxy = false;
50 protected $sslVerifyHost = true;
51 protected $sslVerifyCert = true;
52 protected $caInfo = null;
53 protected $method = "GET";
55 protected $reqHeaders = [];
56 protected $url;
57 protected $parsedUrl;
59 protected $callback;
60 protected $maxRedirects = 5;
61 protected $followRedirects = false;
62 protected $connectTimeout;
63
67 protected $cookieJar;
68
69 protected $headerList = [];
70 protected $respVersion = "0.9";
71 protected $respStatus = "200 Ok";
73 protected $respHeaders = [];
74
76 protected $status;
77
81 protected $profiler;
82
86 protected $profileName;
87
91 protected $logger;
92
102 public function __construct(
103 $url, array $options = [], $caller = __METHOD__, Profiler $profiler = null
104 ) {
105 $this->url = wfExpandUrl( $url, PROTO_HTTP );
106 $this->parsedUrl = wfParseUrl( $this->url );
107
108 $this->logger = $options['logger'] ?? new NullLogger();
109
110 if ( !$this->parsedUrl || !self::isValidURI( $this->url ) ) {
111 $this->status = StatusValue::newFatal( 'http-invalid-url', $url );
112 } else {
113 $this->status = StatusValue::newGood( 100 ); // continue
114 }
115
116 if ( isset( $options['timeout'] ) && $options['timeout'] != 'default' ) {
117 $this->timeout = $options['timeout'];
118 } else {
119 // The timeout should always be set by HttpRequestFactory, so this
120 // should only happen if the class was directly constructed
121 wfDeprecated( __METHOD__ . ' without the timeout option', '1.35' );
122 $httpTimeout = MediaWikiServices::getInstance()->getMainConfig()->get(
123 MainConfigNames::HTTPTimeout );
124 $this->timeout = $httpTimeout;
125 }
126 if ( isset( $options['connectTimeout'] ) && $options['connectTimeout'] != 'default' ) {
127 $this->connectTimeout = $options['connectTimeout'];
128 } else {
129 // The timeout should always be set by HttpRequestFactory, so this
130 // should only happen if the class was directly constructed
131 wfDeprecated( __METHOD__ . ' without the connectTimeout option', '1.35' );
132 $httpConnectTimeout = MediaWikiServices::getInstance()->getMainConfig()->get(
133 MainConfigNames::HTTPConnectTimeout );
134 $this->connectTimeout = $httpConnectTimeout;
135 }
136 if ( isset( $options['userAgent'] ) ) {
137 $this->setUserAgent( $options['userAgent'] );
138 }
139 if ( isset( $options['username'] ) && isset( $options['password'] ) ) {
140 $this->setHeader(
141 'Authorization',
142 'Basic ' . base64_encode( $options['username'] . ':' . $options['password'] )
143 );
144 }
145 if ( isset( $options['originalRequest'] ) ) {
146 $this->setOriginalRequest( $options['originalRequest'] );
147 }
148
149 $members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo",
150 "method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ];
151
152 foreach ( $members as $o ) {
153 if ( isset( $options[$o] ) ) {
154 // ensure that MWHttpRequest::method is always
155 // uppercased. T38137
156 if ( $o == 'method' ) {
157 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
158 $options[$o] = strtoupper( $options[$o] );
159 }
160 $this->$o = $options[$o];
161 }
162 }
163
164 if ( $this->noProxy ) {
165 $this->proxy = ''; // noProxy takes precedence
166 }
167
168 // Profile based on what's calling us
169 $this->profiler = $profiler;
170 $this->profileName = $caller;
171 }
172
176 public function setLogger( LoggerInterface $logger ) {
177 $this->logger = $logger;
178 }
179
185 public static function canMakeRequests() {
186 return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
187 }
188
194 public function getContent() {
195 return $this->content;
196 }
197
204 public function setData( array $args ) {
205 $this->postData = $args;
206 }
207
214 public function addTelemetry( TelemetryHeadersInterface $telemetry ): void {
215 foreach ( $telemetry->getRequestHeaders() as $header => $value ) {
216 $this->setHeader( $header, $value );
217 }
218 }
219
225 protected function proxySetup() {
226 $httpProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
227 MainConfigNames::HTTPProxy );
228 $localHTTPProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
229 MainConfigNames::LocalHTTPProxy );
230 // If proxies are disabled, clear any other proxy
231 if ( $this->noProxy ) {
232 $this->proxy = '';
233 return;
234 }
235
236 // If there is an explicit proxy already set, use it
237 if ( $this->proxy ) {
238 return;
239 }
240
241 // Otherwise, fallback to $wgLocalHTTPProxy for local URLs
242 // or $wgHTTPProxy for everything else
243 if ( self::isLocalURL( $this->url ) ) {
244 if ( $localHTTPProxy !== false ) {
245 $this->setReverseProxy( $localHTTPProxy );
246 }
247 } else {
248 $this->proxy = (string)$httpProxy;
249 }
250 }
251
262 protected function setReverseProxy( string $proxy ) {
263 $parsedProxy = wfParseUrl( $proxy );
264 if ( $parsedProxy === false ) {
265 throw new Exception( "Invalid reverseProxy configured: $proxy" );
266 }
267 // Set the current host in the Host header
268 $this->setHeader( 'Host', $this->parsedUrl['host'] );
269 // Replace scheme, host and port in the request
270 $this->parsedUrl['scheme'] = $parsedProxy['scheme'];
271 $this->parsedUrl['host'] = $parsedProxy['host'];
272 if ( isset( $parsedProxy['port'] ) ) {
273 $this->parsedUrl['port'] = $parsedProxy['port'];
274 } else {
275 unset( $this->parsedUrl['port'] );
276 }
277 $this->url = wfAssembleUrl( $this->parsedUrl );
278 // Mark that we're already using a proxy
279 $this->noProxy = true;
280 }
281
288 private static function isLocalURL( $url ) {
289 $commandLineMode = MediaWikiServices::getInstance()->getMainConfig()->get( 'CommandLineMode' );
290 $localVirtualHosts = MediaWikiServices::getInstance()->getMainConfig()->get(
291 MainConfigNames::LocalVirtualHosts );
292 if ( $commandLineMode ) {
293 return false;
294 }
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
327 public function setUserAgent( $UA ) {
328 $this->setHeader( 'User-Agent', $UA );
329 }
330
336 public function setHeader( $name, $value ) {
337 // I feel like I should normalize the case here...
338 $this->reqHeaders[$name] = $value;
339 }
340
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
381 public function setCallback( $callback ) {
382 $this->doSetCallback( $callback );
383 }
384
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
411 public function read( $fh, $content ) {
412 $this->content .= $content;
413 return strlen( $content );
414 }
415
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
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
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
502 public function getStatus() {
503 if ( !$this->respHeaders ) {
504 $this->parseHeader();
505 }
506
507 return (int)$this->respStatus;
508 }
509
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
538 public function getResponseHeaders() {
539 if ( !$this->respHeaders ) {
540 $this->parseHeader();
541 }
542
543 return $this->respHeaders;
544 }
545
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
572 public function setCookieJar( CookieJar $jar ) {
573 $this->cookieJar = $jar;
574 }
575
581 public function getCookieJar() {
582 if ( !$this->respHeaders ) {
583 $this->parseHeader();
584 }
585
586 return $this->cookieJar;
587 }
588
598 public function setCookie( $name, $value, array $attr = [] ) {
599 if ( !$this->cookieJar ) {
600 $this->cookieJar = new CookieJar;
601 }
602
603 if ( $this->parsedUrl && !isset( $attr['domain'] ) ) {
604 $attr['domain'] = $this->parsedUrl['host'];
605 }
606
607 $this->cookieJar->setCookie( $name, $value, $attr );
608 }
609
613 protected function parseCookies() {
614 if ( !$this->cookieJar ) {
615 $this->cookieJar = new CookieJar;
616 }
617
618 if ( isset( $this->respHeaders['set-cookie'] ) ) {
619 $url = parse_url( $this->getFinalUrl() );
620 if ( !isset( $url['host'] ) ) {
621 $this->status->fatal( 'http-invalid-url', $url );
622 } else {
623 foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
624 $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
625 }
626 }
627 }
628 }
629
646 public function getFinalUrl() {
647 $headers = $this->getResponseHeaders();
648
649 // return full url (fix for incorrect but handled relative location)
650 if ( isset( $headers['location'] ) ) {
651 $locations = $headers['location'];
652 $domain = '';
653 $foundRelativeURI = false;
654 $countLocations = count( $locations );
655
656 for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
657 $url = parse_url( $locations[$i] );
658
659 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
660 $domain = $url['scheme'] . '://' . $url['host'];
661 break; // found correct URI (with host)
662 } else {
663 $foundRelativeURI = true;
664 }
665 }
666
667 if ( !$foundRelativeURI ) {
668 return $locations[$countLocations - 1];
669 }
670 if ( $domain ) {
671 return $domain . $locations[$countLocations - 1];
672 }
673 $url = parse_url( $this->url );
674 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
675 return $url['scheme'] . '://' . $url['host'] .
676 $locations[$countLocations - 1];
677 }
678 }
679
680 return $this->url;
681 }
682
688 public function canFollowRedirects() {
689 return true;
690 }
691
704 public function setOriginalRequest( $originalRequest ) {
705 if ( $originalRequest instanceof WebRequest ) {
706 $originalRequest = [
707 'ip' => $originalRequest->getIP(),
708 'userAgent' => $originalRequest->getHeader( 'User-Agent' ),
709 ];
710 } elseif (
711 !is_array( $originalRequest )
712 || array_diff( [ 'ip', 'userAgent' ], array_keys( $originalRequest ) )
713 ) {
714 throw new InvalidArgumentException( __METHOD__ . ': $originalRequest must be a '
715 . "WebRequest or an array with 'ip' and 'userAgent' keys" );
716 }
717
718 $this->reqHeaders['X-Forwarded-For'] = $originalRequest['ip'];
719 $this->reqHeaders['X-Original-User-Agent'] = $originalRequest['userAgent'];
720 }
721
738 public static function isValidURI( $uri ) {
739 return (bool)preg_match(
740 '/^https?:\/\/[^\/\s]\S*$/D',
741 $uri
742 );
743 }
744}
const PROTO_HTTP
Definition Defines.php:191
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
wfIniGetBool( $setting)
Safety wrapper around ini_get() for boolean settings.
wfExpandUrl( $url, $defaultProto=PROTO_CURRENT)
Expand a potentially local URL to a fully-qualified URL using $wgServer (or one of its alternatives).
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
wfAssembleUrl( $urlParts)
This function will reassemble a URL parsed with wfParseURL.
Cookie jar to use with MWHttpRequest.
Definition CookieJar.php:25
setCookie( $name, $value, $attr)
Set a cookie in the cookie jar.
Definition CookieJar.php:36
This wrapper class will call out to curl (if available) or fallback to regular PHP if necessary for h...
getContent()
Get the body, or content, of the response to the request.
setLogger(LoggerInterface $logger)
setCookie( $name, $value, array $attr=[])
Sets a cookie.
getResponseHeaders()
Returns an associative array of response headers after the request has been executed.
doSetCallback( $callback)
Worker function for setting callbacks.
setHeader( $name, $value)
Set an arbitrary header.
getCookieJar()
Returns the cookie jar in use.
setReverseProxy(string $proxy)
Enable use of a reverse proxy in which the hostname is passed as a "Host" header, and the request is ...
setOriginalRequest( $originalRequest)
Set information about the original request.
isRedirect()
Returns true if the last status code was a redirect.
read( $fh, $content)
A generic callback to read the body of the response from a remote server.
getFinalUrl()
Returns the final URL after all redirections.
setStatus()
Sets HTTPRequest status member to a fatal value with the error message if the returned integer value ...
parseHeader()
Parses the headers, including the HTTP status code and any Set-Cookie headers.
canFollowRedirects()
Returns true if the backend can follow redirects.
__construct( $url, array $options=[], $caller=__METHOD__, Profiler $profiler=null)
setCallback( $callback)
Set a read callback to accept data read from the HTTP request.
addTelemetry(TelemetryHeadersInterface $telemetry)
Add Telemetry information to the request.
static canMakeRequests()
Simple function to test if we can make any sort of requests at all, using cURL or fopen()
static isValidURI( $uri)
Check that the given URI is a valid one.
getStatus()
Get the integer value of the HTTP status code (e.g.
StatusValue $status
const SUPPORTS_FILE_POSTS
CookieJar $cookieJar
string[][] $respHeaders
execute()
Take care of whatever is necessary to perform the URI request.
getResponseHeader( $header)
Returns the value of the given response header.
LoggerInterface $logger
proxySetup()
Take care of setting up the proxy (do nothing if "noProxy" is set)
setData(array $args)
Set the parameters of the request.
parseCookies()
Parse the cookies in the response headers and store them in the cookie jar.
int string $timeout
getHeaderList()
Get an array of the headers.
callable $callback
Profiler $profiler
setCookieJar(CookieJar $jar)
Tells the MWHttpRequest object to use this pre-loaded CookieJar.
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form stripping il...
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:58
Profiler base class that defines the interface and some shared functionality.
Definition Profiler.php:37
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Provide Request Telemetry information.
$content
Definition router.php:76
$header