MediaWiki master
MWHttpRequest.php
Go to the documentation of this file.
1<?php
26use Psr\Log\LoggerAwareInterface;
27use Psr\Log\LoggerInterface;
28use Psr\Log\NullLogger;
30
38abstract class MWHttpRequest implements LoggerAwareInterface {
39 public const SUPPORTS_FILE_POSTS = false;
40
44 protected $timeout = 'default';
45
47 protected $content;
49 protected $headersOnly = null;
51 protected $postData = null;
53 protected $proxy = null;
55 protected $noProxy = false;
57 protected $sslVerifyHost = true;
59 protected $sslVerifyCert = true;
61 protected $caInfo = null;
63 protected $method = "GET";
65 protected $reqHeaders = [];
67 protected $url;
69 protected $parsedUrl;
71 protected $callback;
73 protected $maxRedirects = 5;
75 protected $followRedirects = false;
77 protected $connectTimeout;
78
82 protected $cookieJar;
83
85 protected $headerList = [];
87 protected $respVersion = "0.9";
89 protected $respStatus = "200 Ok";
91 protected $respHeaders = [];
92
94 protected $status;
95
99 protected $profiler;
100
104 protected $profileName;
105
109 protected $logger;
110
111 private UrlUtils $urlUtils;
112
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
183 public function setLogger( LoggerInterface $logger ) {
184 $this->logger = $logger;
185 }
186
192 public static function canMakeRequests() {
193 return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
194 }
195
201 public function getContent() {
202 return $this->content;
203 }
204
211 public function setData( array $args ) {
212 $this->postData = $args;
213 }
214
221 public function addTelemetry( TelemetryHeadersInterface $telemetry ): void {
222 foreach ( $telemetry->getRequestHeaders() as $header => $value ) {
223 $this->setHeader( $header, $value );
224 }
225 }
226
232 protected function proxySetup() {
233 $httpProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
234 MainConfigNames::HTTPProxy );
235 $localHTTPProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
236 MainConfigNames::LocalHTTPProxy );
237 // If proxies are disabled, clear any other proxy
238 if ( $this->noProxy ) {
239 $this->proxy = '';
240 return;
241 }
242
243 // If there is an explicit proxy already set, use it
244 if ( $this->proxy ) {
245 return;
246 }
247
248 // Otherwise, fallback to $wgLocalHTTPProxy for local URLs
249 // or $wgHTTPProxy for everything else
250 if ( self::isLocalURL( $this->url ) ) {
251 if ( $localHTTPProxy !== false ) {
252 $this->setReverseProxy( $localHTTPProxy );
253 }
254 } else {
255 $this->proxy = (string)$httpProxy;
256 }
257 }
258
269 protected function setReverseProxy( string $proxy ) {
270 $parsedProxy = $this->urlUtils->parse( $proxy );
271 if ( $parsedProxy === null ) {
272 throw new InvalidArgumentException( "Invalid reverseProxy configured: $proxy" );
273 }
274 // Set the current host in the Host header
275 $this->setHeader( 'Host', $this->parsedUrl['host'] );
276 // Replace scheme, host and port in the request
277 $this->parsedUrl['scheme'] = $parsedProxy['scheme'];
278 $this->parsedUrl['host'] = $parsedProxy['host'];
279 if ( isset( $parsedProxy['port'] ) ) {
280 $this->parsedUrl['port'] = $parsedProxy['port'];
281 } else {
282 unset( $this->parsedUrl['port'] );
283 }
284 $this->url = UrlUtils::assemble( $this->parsedUrl );
285 // Mark that we're already using a proxy
286 $this->noProxy = true;
287 }
288
295 private static function isLocalURL( $url ) {
296 if ( MW_ENTRY_POINT === 'cli' ) {
297 return false;
298 }
299 $localVirtualHosts = MediaWikiServices::getInstance()->getMainConfig()->get(
300 MainConfigNames::LocalVirtualHosts );
301
302 // Extract host part
303 $matches = [];
304 if ( preg_match( '!^https?://([\w.-]+)[/:].*$!', $url, $matches ) ) {
305 $host = $matches[1];
306 // Split up dotwise
307 $domainParts = explode( '.', $host );
308 // Check if this domain or any superdomain is listed as a local virtual host
309 $domainParts = array_reverse( $domainParts );
310
311 $domain = '';
312 $countParts = count( $domainParts );
313 for ( $i = 0; $i < $countParts; $i++ ) {
314 $domainPart = $domainParts[$i];
315 if ( $i == 0 ) {
316 $domain = $domainPart;
317 } else {
318 $domain = $domainPart . '.' . $domain;
319 }
320
321 if ( in_array( $domain, $localVirtualHosts ) ) {
322 return true;
323 }
324 }
325 }
326
327 return false;
328 }
329
333 public function setUserAgent( $UA ) {
334 $this->setHeader( 'User-Agent', $UA );
335 }
336
342 public function setHeader( $name, $value ) {
343 // I feel like I should normalize the case here...
344 $this->reqHeaders[$name] = $value;
345 }
346
351 protected function getHeaderList() {
352 $list = [];
353
354 if ( $this->cookieJar ) {
355 $this->reqHeaders['Cookie'] =
356 $this->cookieJar->serializeToHttpRequest(
357 $this->parsedUrl['path'],
358 $this->parsedUrl['host']
359 );
360 }
361
362 foreach ( $this->reqHeaders as $name => $value ) {
363 $list[] = "$name: $value";
364 }
365
366 return $list;
367 }
368
387 public function setCallback( $callback ) {
388 $this->doSetCallback( $callback );
389 }
390
398 protected function doSetCallback( $callback ) {
399 if ( $callback === null ) {
400 $callback = [ $this, 'read' ];
401 } elseif ( !is_callable( $callback ) ) {
402 $this->status->fatal( 'http-internal-error' );
403 throw new InvalidArgumentException( __METHOD__ . ': invalid callback' );
404 }
405 $this->callback = $callback;
406 }
407
417 public function read( $fh, $content ) {
418 $this->content .= $content;
419 return strlen( $content );
420 }
421
428 public function execute() {
429 throw new LogicException( 'children must override this' );
430 }
431
432 protected function prepare() {
433 $this->content = "";
434
435 if ( strtoupper( $this->method ) == "HEAD" ) {
436 $this->headersOnly = true;
437 }
438
439 $this->proxySetup(); // set up any proxy as needed
440
441 if ( !$this->callback ) {
442 $this->doSetCallback( null );
443 }
444
445 if ( !isset( $this->reqHeaders['User-Agent'] ) ) {
446 $http = MediaWikiServices::getInstance()->getHttpRequestFactory();
447 $this->setUserAgent( $http->getUserAgent() );
448 }
449 }
450
456 protected function parseHeader() {
457 $lastname = "";
458
459 // Failure without (valid) headers gets a response status of zero
460 if ( !$this->status->isOK() ) {
461 $this->respStatus = '0 Error';
462 }
463
464 foreach ( $this->headerList as $header ) {
465 if ( preg_match( "#^HTTP/([0-9.]+) (.*)#", $header, $match ) ) {
466 $this->respVersion = $match[1];
467 $this->respStatus = $match[2];
468 } elseif ( preg_match( "#^[ \t]#", $header ) ) {
469 $last = count( $this->respHeaders[$lastname] ) - 1;
470 $this->respHeaders[$lastname][$last] .= "\r\n$header";
471 } elseif ( preg_match( "#^([^:]*):[\t ]*(.*)#", $header, $match ) ) {
472 $this->respHeaders[strtolower( $match[1] )][] = $match[2];
473 $lastname = strtolower( $match[1] );
474 }
475 }
476
477 $this->parseCookies();
478 }
479
487 protected function setStatus() {
488 if ( !$this->respHeaders ) {
489 $this->parseHeader();
490 }
491
492 if ( (int)$this->respStatus > 0 && (int)$this->respStatus < 400 ) {
493 $this->status->setResult( true, (int)$this->respStatus );
494 } else {
495 [ $code, $message ] = explode( " ", $this->respStatus, 2 );
496 $this->status->setResult( false, (int)$this->respStatus );
497 $this->status->fatal( "http-bad-status", $code, $message );
498 }
499 }
500
508 public function getStatus() {
509 if ( !$this->respHeaders ) {
510 $this->parseHeader();
511 }
512
513 return (int)$this->respStatus;
514 }
515
521 public function isRedirect() {
522 if ( !$this->respHeaders ) {
523 $this->parseHeader();
524 }
525
526 $status = (int)$this->respStatus;
527
528 if ( $status >= 300 && $status <= 303 ) {
529 return true;
530 }
531
532 return false;
533 }
534
544 public function getResponseHeaders() {
545 if ( !$this->respHeaders ) {
546 $this->parseHeader();
547 }
548
549 return $this->respHeaders;
550 }
551
558 public function getResponseHeader( $header ) {
559 if ( !$this->respHeaders ) {
560 $this->parseHeader();
561 }
562
563 if ( isset( $this->respHeaders[strtolower( $header )] ) ) {
564 $v = $this->respHeaders[strtolower( $header )];
565 return $v[count( $v ) - 1];
566 }
567
568 return null;
569 }
570
578 public function setCookieJar( CookieJar $jar ) {
579 $this->cookieJar = $jar;
580 }
581
587 public function getCookieJar() {
588 if ( !$this->respHeaders ) {
589 $this->parseHeader();
590 }
591
592 return $this->cookieJar;
593 }
594
604 public function setCookie( $name, $value, array $attr = [] ) {
605 if ( !$this->cookieJar ) {
606 $this->cookieJar = new CookieJar;
607 }
608
609 if ( $this->parsedUrl && !isset( $attr['domain'] ) ) {
610 $attr['domain'] = $this->parsedUrl['host'];
611 }
612
613 $this->cookieJar->setCookie( $name, $value, $attr );
614 }
615
619 protected function parseCookies() {
620 if ( !$this->cookieJar ) {
621 $this->cookieJar = new CookieJar;
622 }
623
624 if ( isset( $this->respHeaders['set-cookie'] ) ) {
625 $url = parse_url( $this->getFinalUrl() );
626 if ( !isset( $url['host'] ) ) {
627 $this->status->fatal( 'http-invalid-url', $url );
628 } else {
629 foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
630 $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
631 }
632 }
633 }
634 }
635
652 public function getFinalUrl() {
653 $headers = $this->getResponseHeaders();
654
655 // return full url (fix for incorrect but handled relative location)
656 if ( isset( $headers['location'] ) ) {
657 $locations = $headers['location'];
658 $domain = '';
659 $foundRelativeURI = false;
660 $countLocations = count( $locations );
661
662 for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
663 $url = parse_url( $locations[$i] );
664
665 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
666 $domain = $url['scheme'] . '://' . $url['host'];
667 break; // found correct URI (with host)
668 } else {
669 $foundRelativeURI = true;
670 }
671 }
672
673 if ( !$foundRelativeURI ) {
674 return $locations[$countLocations - 1];
675 }
676 if ( $domain ) {
677 return $domain . $locations[$countLocations - 1];
678 }
679 $url = parse_url( $this->url );
680 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
681 return $url['scheme'] . '://' . $url['host'] .
682 $locations[$countLocations - 1];
683 }
684 }
685
686 return $this->url;
687 }
688
694 public function canFollowRedirects() {
695 return true;
696 }
697
710 public function setOriginalRequest( $originalRequest ) {
711 if ( $originalRequest instanceof WebRequest ) {
712 $originalRequest = [
713 'ip' => $originalRequest->getIP(),
714 'userAgent' => $originalRequest->getHeader( 'User-Agent' ),
715 ];
716 } elseif (
717 !is_array( $originalRequest )
718 || array_diff( [ 'ip', 'userAgent' ], array_keys( $originalRequest ) )
719 ) {
720 throw new InvalidArgumentException( __METHOD__ . ': $originalRequest must be a '
721 . "WebRequest or an array with 'ip' and 'userAgent' keys" );
722 }
723
724 $this->reqHeaders['X-Forwarded-For'] = $originalRequest['ip'];
725 $this->reqHeaders['X-Original-User-Agent'] = $originalRequest['userAgent'];
726 }
727
744 public static function isValidURI( $uri ) {
745 return (bool)preg_match(
746 '/^https?:\/\/[^\/\s]\S*$/D',
747 $uri
748 );
749 }
750}
const PROTO_HTTP
Definition Defines.php:204
wfIniGetBool( $setting)
Safety wrapper around ini_get() for boolean settings.
const MW_ENTRY_POINT
Definition api.php:35
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...
string null $proxy
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.
bool null $headersOnly
doSetCallback( $callback)
Worker function for setting callbacks.
string null $caInfo
__construct( $url, array $options, $caller=__METHOD__, ?Profiler $profiler=null)
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.
setCallback( $callback)
Set a read callback to accept data read from the HTTP request.
addTelemetry(TelemetryHeadersInterface $telemetry)
Add Telemetry information to the request.
array false $parsedUrl
array null $postData
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
string null $content
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,...
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
A service to expand, parse, and otherwise manipulate URLs.
Definition UrlUtils.php:16
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.
$header