MediaWiki 1.44.0
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
180 public function setLogger( LoggerInterface $logger ): void {
181 $this->logger = $logger;
182 }
183
189 public static function canMakeRequests() {
190 return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
191 }
192
198 public function getContent() {
199 return $this->content;
200 }
201
208 public function setData( array $args ) {
209 $this->postData = $args;
210 }
211
218 public function addTelemetry( TelemetryHeadersInterface $telemetry ): void {
219 foreach ( $telemetry->getRequestHeaders() as $header => $value ) {
220 $this->setHeader( $header, $value );
221 }
222 }
223
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
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
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
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
570 public function setCookieJar( CookieJar $jar ) {
571 $this->cookieJar = $jar;
572 }
573
579 public function getCookieJar() {
580 if ( !$this->respHeaders ) {
581 $this->parseHeader();
582 }
583
584 return $this->cookieJar;
585 }
586
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
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
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
686 public function canFollowRedirects() {
687 return true;
688 }
689
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
736 public static function isValidURI( $uri ) {
737 return (bool)preg_match(
738 '/^https?:\/\/[^\/\s]\S*$/D',
739 $uri
740 );
741 }
742}
const PROTO_HTTP
Definition Defines.php:231
wfIniGetBool( $setting)
Safety wrapper around ini_get() for boolean settings.
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