MediaWiki master
MWHttpRequest.php
Go to the documentation of this file.
1<?php
7namespace MediaWiki\Http;
8
9use InvalidArgumentException;
10use LogicException;
17use Psr\Log\LoggerAwareInterface;
18use Psr\Log\LoggerInterface;
19use Psr\Log\NullLogger;
20use StatusValue;
23
31abstract class MWHttpRequest implements LoggerAwareInterface {
32 public const SUPPORTS_FILE_POSTS = false;
33
37 protected $timeout = 'default';
38
40 protected $content;
42 protected $headersOnly = null;
44 protected $postData = null;
46 protected $proxy = null;
48 protected $noProxy = false;
50 protected $sslVerifyHost = true;
52 protected $sslVerifyCert = true;
54 protected $caInfo = null;
56 protected $method = "GET";
58 protected $reqHeaders = [];
60 protected $url;
62 protected $parsedUrl;
64 protected $callback;
66 protected $maxRedirects = 5;
68 protected $followRedirects = false;
70 protected $connectTimeout;
71
75 protected $cookieJar;
76
78 protected $headerList = [];
80 protected $respVersion = "0.9";
82 protected $respStatus = "200 Ok";
84 protected $respHeaders = [];
85
86 protected readonly StatusValue $status;
87
88 protected readonly string $profileName;
89
90 protected LoggerInterface $logger;
91
92 private readonly UrlUtils $urlUtils;
93
103 public function __construct(
104 $url,
105 array $options,
106 string $caller = __METHOD__,
107 protected readonly ?Profiler $profiler = null,
108 ) {
109 $this->urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
110 if ( !array_key_exists( 'timeout', $options )
111 || !array_key_exists( 'connectTimeout', $options ) ) {
112 throw new InvalidArgumentException( "timeout and connectionTimeout options are required" );
113 }
114 $this->url = $this->urlUtils->expand( $url, PROTO_HTTP ) ?? false;
115 $this->parsedUrl = $this->urlUtils->parse( (string)$this->url ) ?? false;
116
117 $this->logger = $options['logger'] ?? new NullLogger();
118 $this->timeout = $options['timeout'];
119 $this->connectTimeout = $options['connectTimeout'];
120
121 if ( !$this->parsedUrl || !self::isValidURI( $this->url ) ) {
122 $this->status = StatusValue::newFatal( 'http-invalid-url', $url );
123 } else {
124 $this->status = StatusValue::newGood( 100 ); // continue
125 }
126
127 if ( isset( $options['userAgent'] ) ) {
128 $this->setUserAgent( $options['userAgent'] );
129 }
130 if ( isset( $options['username'] ) && isset( $options['password'] ) ) {
131 $this->setHeader(
132 'Authorization',
133 'Basic ' . base64_encode( $options['username'] . ':' . $options['password'] )
134 );
135 }
136 if ( isset( $options['originalRequest'] ) ) {
137 $this->setOriginalRequest( $options['originalRequest'] );
138 }
139
140 $members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo",
141 "method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ];
142
143 foreach ( $members as $o ) {
144 if ( isset( $options[$o] ) ) {
145 // ensure that MWHttpRequest::method is always
146 // uppercased. T38137
147 if ( $o == 'method' ) {
148 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
149 $options[$o] = strtoupper( $options[$o] );
150 }
151 $this->$o = $options[$o];
152 }
153 }
154
155 if ( $this->noProxy ) {
156 $this->proxy = ''; // noProxy takes precedence
157 }
158
159 // Profile based on what's calling us
160 $this->profileName = $caller;
161 }
162
163 public function setLogger( LoggerInterface $logger ): void {
164 $this->logger = $logger;
165 }
166
172 public static function canMakeRequests() {
173 return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
174 }
175
181 public function getContent() {
182 return $this->content;
183 }
184
191 public function setData( array $args ) {
192 $this->postData = $args;
193 }
194
201 public function addTelemetry( TelemetryHeadersInterface $telemetry ): void {
202 foreach ( $telemetry->getRequestHeaders() as $header => $value ) {
203 $this->setHeader( $header, $value );
204 }
205 }
206
212 protected function proxySetup() {
213 $httpProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
214 MainConfigNames::HTTPProxy );
215 $localHTTPProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
216 MainConfigNames::LocalHTTPProxy );
217 // If proxies are disabled, clear any other proxy
218 if ( $this->noProxy ) {
219 $this->proxy = '';
220 return;
221 }
222
223 // If there is an explicit proxy already set, use it
224 if ( $this->proxy ) {
225 return;
226 }
227
228 // Otherwise, fallback to $wgLocalHTTPProxy for local URLs
229 // or $wgHTTPProxy for everything else
230 if ( self::isLocalURL( $this->url ) ) {
231 if ( $localHTTPProxy !== false ) {
232 $this->setReverseProxy( $localHTTPProxy );
233 }
234 } else {
235 $this->proxy = (string)$httpProxy;
236 }
237 }
238
249 protected function setReverseProxy( string $proxy ) {
250 $parsedProxy = $this->urlUtils->parse( $proxy );
251 if ( $parsedProxy === null ) {
252 throw new InvalidArgumentException( "Invalid reverseProxy configured: $proxy" );
253 }
254 // Set the current host in the Host header
255 $this->setHeader( 'Host', $this->parsedUrl['host'] );
256 // Replace scheme, host and port in the request
257 $this->parsedUrl['scheme'] = $parsedProxy['scheme'];
258 $this->parsedUrl['host'] = $parsedProxy['host'];
259 if ( isset( $parsedProxy['port'] ) ) {
260 $this->parsedUrl['port'] = $parsedProxy['port'];
261 } else {
262 unset( $this->parsedUrl['port'] );
263 }
264 $this->url = UrlUtils::assemble( $this->parsedUrl );
265 // Mark that we're already using a proxy
266 $this->noProxy = true;
267 }
268
275 private static function isLocalURL( $url ) {
276 $localVirtualHosts = MediaWikiServices::getInstance()->getMainConfig()->get(
277 MainConfigNames::LocalVirtualHosts );
278
279 // Extract host part
280 $matches = [];
281 if ( preg_match( '!^https?://([\w.-]+)[/:].*$!', $url, $matches ) ) {
282 $host = $matches[1];
283 // Split up dotwise
284 $domainParts = explode( '.', $host );
285 // Check if this domain or any superdomain is listed as a local virtual host
286 $domainParts = array_reverse( $domainParts );
287
288 $domain = '';
289 $countParts = count( $domainParts );
290 for ( $i = 0; $i < $countParts; $i++ ) {
291 $domainPart = $domainParts[$i];
292 if ( $i == 0 ) {
293 $domain = $domainPart;
294 } else {
295 $domain = $domainPart . '.' . $domain;
296 }
297
298 if ( in_array( $domain, $localVirtualHosts ) ) {
299 return true;
300 }
301 }
302 }
303
304 return false;
305 }
306
310 public function setUserAgent( $UA ) {
311 $this->setHeader( 'User-Agent', $UA );
312 }
313
319 public function setHeader( $name, $value ) {
320 // I feel like I should normalize the case here...
321 $this->reqHeaders[$name] = $value;
322 }
323
342 public function setCallback( $callback ) {
343 $this->doSetCallback( $callback );
344 }
345
353 protected function doSetCallback( $callback ) {
354 if ( $callback === null ) {
355 $callback = $this->read( ... );
356 } elseif ( !is_callable( $callback ) ) {
357 $this->status->fatal( 'http-internal-error' );
358 throw new InvalidArgumentException( __METHOD__ . ': invalid callback' );
359 }
360 $this->callback = $callback;
361 }
362
372 public function read( $fh, $content ) {
373 $this->content .= $content;
374 return strlen( $content );
375 }
376
383 public function execute() {
384 throw new LogicException( 'children must override this' );
385 }
386
387 protected function prepare() {
388 $this->content = "";
389
390 if ( strtoupper( $this->method ) == "HEAD" ) {
391 $this->headersOnly = true;
392 }
393
394 $this->proxySetup(); // set up any proxy as needed
395
396 if ( !$this->callback ) {
397 $this->doSetCallback( null );
398 }
399
400 if ( !isset( $this->reqHeaders['User-Agent'] ) ) {
401 $http = MediaWikiServices::getInstance()->getHttpRequestFactory();
402 $this->setUserAgent( $http->getUserAgent() );
403 }
404 }
405
411 protected function parseHeader() {
412 $lastname = "";
413
414 // Failure without (valid) headers gets a response status of zero
415 if ( !$this->status->isOK() ) {
416 $this->respStatus = '0 Error';
417 }
418
419 foreach ( $this->headerList as $header ) {
420 if ( preg_match( "#^HTTP/([0-9.]+) (.*)#", $header, $match ) ) {
421 $this->respVersion = $match[1];
422 $this->respStatus = $match[2];
423 } elseif ( preg_match( "#^[ \t]#", $header ) ) {
424 $last = count( $this->respHeaders[$lastname] ) - 1;
425 $this->respHeaders[$lastname][$last] .= "\r\n$header";
426 } elseif ( preg_match( "#^([^:]*):[\t ]*(.*)#", $header, $match ) ) {
427 $this->respHeaders[strtolower( $match[1] )][] = $match[2];
428 $lastname = strtolower( $match[1] );
429 }
430 }
431
432 $this->parseCookies();
433 }
434
442 protected function setStatus() {
443 if ( !$this->respHeaders ) {
444 $this->parseHeader();
445 }
446
447 if ( (int)$this->respStatus > 0 && (int)$this->respStatus < 400 ) {
448 $this->status->setResult( true, (int)$this->respStatus );
449 } else {
450 [ $code, $message ] = explode( " ", $this->respStatus, 2 );
451 $this->status->setResult( false, (int)$this->respStatus );
452 $this->status->fatal( "http-bad-status", $code, $message );
453 }
454 }
455
463 public function getStatus() {
464 if ( !$this->respHeaders ) {
465 $this->parseHeader();
466 }
467
468 return (int)$this->respStatus;
469 }
470
476 public function isRedirect() {
477 if ( !$this->respHeaders ) {
478 $this->parseHeader();
479 }
480
481 $status = (int)$this->respStatus;
482
483 if ( $status >= 300 && $status <= 303 ) {
484 return true;
485 }
486
487 return false;
488 }
489
499 public function getResponseHeaders() {
500 if ( !$this->respHeaders ) {
501 $this->parseHeader();
502 }
503
504 return $this->respHeaders;
505 }
506
513 public function getResponseHeader( $header ) {
514 if ( !$this->respHeaders ) {
515 $this->parseHeader();
516 }
517
518 if ( isset( $this->respHeaders[strtolower( $header )] ) ) {
519 $v = $this->respHeaders[strtolower( $header )];
520 return $v[count( $v ) - 1];
521 }
522
523 return null;
524 }
525
531 public function setCookieJar( CookieJar $jar ) {
532 $this->cookieJar = $jar;
533 }
534
540 public function getCookieJar() {
541 if ( !$this->respHeaders ) {
542 $this->parseHeader();
543 }
544
545 return $this->cookieJar;
546 }
547
557 public function setCookie( $name, $value, array $attr = [] ) {
558 $this->cookieJar ??= new CookieJar;
559
560 if ( $this->parsedUrl && !isset( $attr['domain'] ) ) {
561 $attr['domain'] = $this->parsedUrl['host'];
562 }
563
564 $this->cookieJar->setCookie( $name, $value, $attr );
565 }
566
570 protected function parseCookies() {
571 $this->cookieJar ??= new CookieJar;
572
573 if ( isset( $this->respHeaders['set-cookie'] ) ) {
574 $url = parse_url( $this->getFinalUrl() );
575 if ( !isset( $url['host'] ) ) {
576 $this->status->fatal( 'http-invalid-url', $this->getFinalUrl() );
577 } else {
578 foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
579 $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
580 }
581 }
582 }
583 }
584
601 public function getFinalUrl() {
602 $headers = $this->getResponseHeaders();
603
604 // return full url (fix for incorrect but handled relative location)
605 if ( isset( $headers['location'] ) ) {
606 $locations = $headers['location'];
607 $domain = '';
608 $foundRelativeURI = false;
609 $countLocations = count( $locations );
610
611 for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
612 $url = parse_url( $locations[$i] );
613
614 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
615 $domain = $url['scheme'] . '://' . $url['host'];
616 break; // found correct URI (with host)
617 } else {
618 $foundRelativeURI = true;
619 }
620 }
621
622 if ( !$foundRelativeURI ) {
623 return $locations[$countLocations - 1];
624 }
625 if ( $domain ) {
626 return $domain . $locations[$countLocations - 1];
627 }
628 $url = parse_url( $this->url );
629 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
630 return $url['scheme'] . '://' . $url['host'] .
631 $locations[$countLocations - 1];
632 }
633 }
634
635 return $this->url;
636 }
637
643 public function canFollowRedirects() {
644 return true;
645 }
646
659 public function setOriginalRequest( $originalRequest ) {
660 if ( $originalRequest instanceof WebRequest ) {
661 $originalRequest = [
662 'ip' => $originalRequest->getIP(),
663 'userAgent' => $originalRequest->getHeader( 'User-Agent' ),
664 ];
665 } elseif (
666 !is_array( $originalRequest )
667 || array_diff( [ 'ip', 'userAgent' ], array_keys( $originalRequest ) )
668 ) {
669 throw new InvalidArgumentException( __METHOD__ . ': $originalRequest must be a '
670 . "WebRequest or an array with 'ip' and 'userAgent' keys" );
671 }
672
673 $this->reqHeaders['X-Forwarded-For'] = $originalRequest['ip'];
674 $this->reqHeaders['X-Original-User-Agent'] = $originalRequest['userAgent'];
675 }
676
693 public static function isValidURI( $uri ) {
694 return (bool)preg_match(
695 '/^https?:\/\/[^\/\s]\S*$/D',
696 $uri
697 );
698 }
699}
700
702class_alias( MWHttpRequest::class, 'MWHttpRequest' );
const PROTO_HTTP
Definition Defines.php:217
wfIniGetBool( $setting)
Safety wrapper around ini_get() for boolean settings.
This wrapper class will call out to curl (if available) or fallback to regular PHP if necessary for h...
isRedirect()
Returns true if the last status code was a redirect.
getResponseHeader( $header)
Returns the value of the given response header.
addTelemetry(TelemetryHeadersInterface $telemetry)
Add Telemetry information to the request.
getCookieJar()
Returns the cookie jar in use.
getContent()
Get the body, or content, of the response to the request.
static canMakeRequests()
Simple function to test if we can make any sort of requests at all, using cURL or fopen()
getResponseHeaders()
Returns an associative array of response headers after the request has been executed.
canFollowRedirects()
Returns true if the backend can follow redirects.
setLogger(LoggerInterface $logger)
setCookie( $name, $value, array $attr=[])
Sets a cookie.
parseCookies()
Parse the cookies in the response headers and store them in the cookie jar.
proxySetup()
Take care of setting up the proxy (do nothing if "noProxy" is set)
setData(array $args)
Set the parameters of the request.
setStatus()
Sets HTTPRequest status member to a fatal value with the error message if the returned integer value ...
readonly StatusValue $status
static isValidURI( $uri)
Check that the given URI is a valid one.
setOriginalRequest( $originalRequest)
Set information about the original request.
setCookieJar(CookieJar $jar)
Tells the MWHttpRequest object to use this pre-loaded CookieJar.
setHeader( $name, $value)
Set an arbitrary header.
doSetCallback( $callback)
Worker function for setting callbacks.
setCallback( $callback)
Set a read callback to accept data read from the HTTP request.
__construct( $url, array $options, string $caller=__METHOD__, protected readonly ?Profiler $profiler=null,)
execute()
Take care of whatever is necessary to perform the URI request.
getStatus()
Get the integer value of the HTTP status code (e.g.
getFinalUrl()
Returns the final URL after all redirections.
setReverseProxy(string $proxy)
Enable use of a reverse proxy in which the hostname is passed as a "Host" header, and the request is ...
parseHeader()
Parses the headers, including the HTTP status code and any Set-Cookie headers.
read( $fh, $content)
A generic callback to read the body of the response from a remote server.
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Profiler base class that defines the interface and some shared functionality.
Definition Profiler.php:26
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:44
A service to expand, parse, and otherwise manipulate URLs.
Definition UrlUtils.php:16
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Cookie jar to use with MWHttpRequest.
Definition CookieJar.php:13
setCookie(string $name, string $value, array $attr)
Set a cookie in the cookie jar.
Definition CookieJar.php:25
Provide Request Telemetry information.