MediaWiki master
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 if ( !array_key_exists( 'timeout', $options )
106 || !array_key_exists( 'connectTimeout', $options ) ) {
107 throw new InvalidArgumentException( "timeout and connectionTimeout options are required" );
108 }
109 $this->url = wfExpandUrl( $url, PROTO_HTTP );
110 $this->parsedUrl = wfParseUrl( $this->url );
111
112 $this->logger = $options['logger'] ?? new NullLogger();
113 $this->timeout = $options['timeout'];
114 $this->connectTimeout = $options['connectTimeout'];
115
116 if ( !$this->parsedUrl || !self::isValidURI( $this->url ) ) {
117 $this->status = StatusValue::newFatal( 'http-invalid-url', $url );
118 } else {
119 $this->status = StatusValue::newGood( 100 ); // continue
120 }
121
122 if ( isset( $options['userAgent'] ) ) {
123 $this->setUserAgent( $options['userAgent'] );
124 }
125 if ( isset( $options['username'] ) && isset( $options['password'] ) ) {
126 $this->setHeader(
127 'Authorization',
128 'Basic ' . base64_encode( $options['username'] . ':' . $options['password'] )
129 );
130 }
131 if ( isset( $options['originalRequest'] ) ) {
132 $this->setOriginalRequest( $options['originalRequest'] );
133 }
134
135 $members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo",
136 "method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ];
137
138 foreach ( $members as $o ) {
139 if ( isset( $options[$o] ) ) {
140 // ensure that MWHttpRequest::method is always
141 // uppercased. T38137
142 if ( $o == 'method' ) {
143 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
144 $options[$o] = strtoupper( $options[$o] );
145 }
146 $this->$o = $options[$o];
147 }
148 }
149
150 if ( $this->noProxy ) {
151 $this->proxy = ''; // noProxy takes precedence
152 }
153
154 // Profile based on what's calling us
155 $this->profiler = $profiler;
156 $this->profileName = $caller;
157 }
158
162 public function setLogger( LoggerInterface $logger ) {
163 $this->logger = $logger;
164 }
165
171 public static function canMakeRequests() {
172 return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
173 }
174
180 public function getContent() {
181 return $this->content;
182 }
183
190 public function setData( array $args ) {
191 $this->postData = $args;
192 }
193
200 public function addTelemetry( TelemetryHeadersInterface $telemetry ): void {
201 foreach ( $telemetry->getRequestHeaders() as $header => $value ) {
202 $this->setHeader( $header, $value );
203 }
204 }
205
211 protected function proxySetup() {
212 $httpProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
213 MainConfigNames::HTTPProxy );
214 $localHTTPProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
215 MainConfigNames::LocalHTTPProxy );
216 // If proxies are disabled, clear any other proxy
217 if ( $this->noProxy ) {
218 $this->proxy = '';
219 return;
220 }
221
222 // If there is an explicit proxy already set, use it
223 if ( $this->proxy ) {
224 return;
225 }
226
227 // Otherwise, fallback to $wgLocalHTTPProxy for local URLs
228 // or $wgHTTPProxy for everything else
229 if ( self::isLocalURL( $this->url ) ) {
230 if ( $localHTTPProxy !== false ) {
231 $this->setReverseProxy( $localHTTPProxy );
232 }
233 } else {
234 $this->proxy = (string)$httpProxy;
235 }
236 }
237
248 protected function setReverseProxy( string $proxy ) {
249 $parsedProxy = wfParseUrl( $proxy );
250 if ( $parsedProxy === false ) {
251 throw new InvalidArgumentException( "Invalid reverseProxy configured: $proxy" );
252 }
253 // Set the current host in the Host header
254 $this->setHeader( 'Host', $this->parsedUrl['host'] );
255 // Replace scheme, host and port in the request
256 $this->parsedUrl['scheme'] = $parsedProxy['scheme'];
257 $this->parsedUrl['host'] = $parsedProxy['host'];
258 if ( isset( $parsedProxy['port'] ) ) {
259 $this->parsedUrl['port'] = $parsedProxy['port'];
260 } else {
261 unset( $this->parsedUrl['port'] );
262 }
263 $this->url = wfAssembleUrl( $this->parsedUrl );
264 // Mark that we're already using a proxy
265 $this->noProxy = true;
266 }
267
274 private static function isLocalURL( $url ) {
275 if ( MW_ENTRY_POINT === 'cli' ) {
276 return false;
277 }
278 $localVirtualHosts = MediaWikiServices::getInstance()->getMainConfig()->get(
279 MainConfigNames::LocalVirtualHosts );
280
281 // Extract host part
282 $matches = [];
283 if ( preg_match( '!^https?://([\w.-]+)[/:].*$!', $url, $matches ) ) {
284 $host = $matches[1];
285 // Split up dotwise
286 $domainParts = explode( '.', $host );
287 // Check if this domain or any superdomain is listed as a local virtual host
288 $domainParts = array_reverse( $domainParts );
289
290 $domain = '';
291 $countParts = count( $domainParts );
292 for ( $i = 0; $i < $countParts; $i++ ) {
293 $domainPart = $domainParts[$i];
294 if ( $i == 0 ) {
295 $domain = $domainPart;
296 } else {
297 $domain = $domainPart . '.' . $domain;
298 }
299
300 if ( in_array( $domain, $localVirtualHosts ) ) {
301 return true;
302 }
303 }
304 }
305
306 return false;
307 }
308
312 public function setUserAgent( $UA ) {
313 $this->setHeader( 'User-Agent', $UA );
314 }
315
321 public function setHeader( $name, $value ) {
322 // I feel like I should normalize the case here...
323 $this->reqHeaders[$name] = $value;
324 }
325
330 protected function getHeaderList() {
331 $list = [];
332
333 if ( $this->cookieJar ) {
334 $this->reqHeaders['Cookie'] =
335 $this->cookieJar->serializeToHttpRequest(
336 $this->parsedUrl['path'],
337 $this->parsedUrl['host']
338 );
339 }
340
341 foreach ( $this->reqHeaders as $name => $value ) {
342 $list[] = "$name: $value";
343 }
344
345 return $list;
346 }
347
366 public function setCallback( $callback ) {
367 $this->doSetCallback( $callback );
368 }
369
377 protected function doSetCallback( $callback ) {
378 if ( $callback === null ) {
379 $callback = [ $this, 'read' ];
380 } elseif ( !is_callable( $callback ) ) {
381 $this->status->fatal( 'http-internal-error' );
382 throw new InvalidArgumentException( __METHOD__ . ': invalid callback' );
383 }
384 $this->callback = $callback;
385 }
386
396 public function read( $fh, $content ) {
397 $this->content .= $content;
398 return strlen( $content );
399 }
400
407 public function execute() {
408 throw new LogicException( 'children must override this' );
409 }
410
411 protected function prepare() {
412 $this->content = "";
413
414 if ( strtoupper( $this->method ) == "HEAD" ) {
415 $this->headersOnly = true;
416 }
417
418 $this->proxySetup(); // set up any proxy as needed
419
420 if ( !$this->callback ) {
421 $this->doSetCallback( null );
422 }
423
424 if ( !isset( $this->reqHeaders['User-Agent'] ) ) {
425 $http = MediaWikiServices::getInstance()->getHttpRequestFactory();
426 $this->setUserAgent( $http->getUserAgent() );
427 }
428 }
429
435 protected function parseHeader() {
436 $lastname = "";
437
438 // Failure without (valid) headers gets a response status of zero
439 if ( !$this->status->isOK() ) {
440 $this->respStatus = '0 Error';
441 }
442
443 foreach ( $this->headerList as $header ) {
444 if ( preg_match( "#^HTTP/([0-9.]+) (.*)#", $header, $match ) ) {
445 $this->respVersion = $match[1];
446 $this->respStatus = $match[2];
447 } elseif ( preg_match( "#^[ \t]#", $header ) ) {
448 $last = count( $this->respHeaders[$lastname] ) - 1;
449 $this->respHeaders[$lastname][$last] .= "\r\n$header";
450 } elseif ( preg_match( "#^([^:]*):[\t ]*(.*)#", $header, $match ) ) {
451 $this->respHeaders[strtolower( $match[1] )][] = $match[2];
452 $lastname = strtolower( $match[1] );
453 }
454 }
455
456 $this->parseCookies();
457 }
458
466 protected function setStatus() {
467 if ( !$this->respHeaders ) {
468 $this->parseHeader();
469 }
470
471 if ( (int)$this->respStatus > 0 && (int)$this->respStatus < 400 ) {
472 $this->status->setResult( true, (int)$this->respStatus );
473 } else {
474 [ $code, $message ] = explode( " ", $this->respStatus, 2 );
475 $this->status->setResult( false, (int)$this->respStatus );
476 $this->status->fatal( "http-bad-status", $code, $message );
477 }
478 }
479
487 public function getStatus() {
488 if ( !$this->respHeaders ) {
489 $this->parseHeader();
490 }
491
492 return (int)$this->respStatus;
493 }
494
500 public function isRedirect() {
501 if ( !$this->respHeaders ) {
502 $this->parseHeader();
503 }
504
505 $status = (int)$this->respStatus;
506
507 if ( $status >= 300 && $status <= 303 ) {
508 return true;
509 }
510
511 return false;
512 }
513
523 public function getResponseHeaders() {
524 if ( !$this->respHeaders ) {
525 $this->parseHeader();
526 }
527
528 return $this->respHeaders;
529 }
530
537 public function getResponseHeader( $header ) {
538 if ( !$this->respHeaders ) {
539 $this->parseHeader();
540 }
541
542 if ( isset( $this->respHeaders[strtolower( $header )] ) ) {
543 $v = $this->respHeaders[strtolower( $header )];
544 return $v[count( $v ) - 1];
545 }
546
547 return null;
548 }
549
557 public function setCookieJar( CookieJar $jar ) {
558 $this->cookieJar = $jar;
559 }
560
566 public function getCookieJar() {
567 if ( !$this->respHeaders ) {
568 $this->parseHeader();
569 }
570
571 return $this->cookieJar;
572 }
573
583 public function setCookie( $name, $value, array $attr = [] ) {
584 if ( !$this->cookieJar ) {
585 $this->cookieJar = new CookieJar;
586 }
587
588 if ( $this->parsedUrl && !isset( $attr['domain'] ) ) {
589 $attr['domain'] = $this->parsedUrl['host'];
590 }
591
592 $this->cookieJar->setCookie( $name, $value, $attr );
593 }
594
598 protected function parseCookies() {
599 if ( !$this->cookieJar ) {
600 $this->cookieJar = new CookieJar;
601 }
602
603 if ( isset( $this->respHeaders['set-cookie'] ) ) {
604 $url = parse_url( $this->getFinalUrl() );
605 if ( !isset( $url['host'] ) ) {
606 $this->status->fatal( 'http-invalid-url', $url );
607 } else {
608 foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
609 $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
610 }
611 }
612 }
613 }
614
631 public function getFinalUrl() {
632 $headers = $this->getResponseHeaders();
633
634 // return full url (fix for incorrect but handled relative location)
635 if ( isset( $headers['location'] ) ) {
636 $locations = $headers['location'];
637 $domain = '';
638 $foundRelativeURI = false;
639 $countLocations = count( $locations );
640
641 for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
642 $url = parse_url( $locations[$i] );
643
644 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
645 $domain = $url['scheme'] . '://' . $url['host'];
646 break; // found correct URI (with host)
647 } else {
648 $foundRelativeURI = true;
649 }
650 }
651
652 if ( !$foundRelativeURI ) {
653 return $locations[$countLocations - 1];
654 }
655 if ( $domain ) {
656 return $domain . $locations[$countLocations - 1];
657 }
658 $url = parse_url( $this->url );
659 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
660 return $url['scheme'] . '://' . $url['host'] .
661 $locations[$countLocations - 1];
662 }
663 }
664
665 return $this->url;
666 }
667
673 public function canFollowRedirects() {
674 return true;
675 }
676
689 public function setOriginalRequest( $originalRequest ) {
690 if ( $originalRequest instanceof WebRequest ) {
691 $originalRequest = [
692 'ip' => $originalRequest->getIP(),
693 'userAgent' => $originalRequest->getHeader( 'User-Agent' ),
694 ];
695 } elseif (
696 !is_array( $originalRequest )
697 || array_diff( [ 'ip', 'userAgent' ], array_keys( $originalRequest ) )
698 ) {
699 throw new InvalidArgumentException( __METHOD__ . ': $originalRequest must be a '
700 . "WebRequest or an array with 'ip' and 'userAgent' keys" );
701 }
702
703 $this->reqHeaders['X-Forwarded-For'] = $originalRequest['ip'];
704 $this->reqHeaders['X-Original-User-Agent'] = $originalRequest['userAgent'];
705 }
706
723 public static function isValidURI( $uri ) {
724 return (bool)preg_match(
725 '/^https?:\/\/[^\/\s]\S*$/D',
726 $uri
727 );
728 }
729}
const PROTO_HTTP
Definition Defines.php:202
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).
wfAssembleUrl( $urlParts)
This function will reassemble a URL parsed with wfParseURL.
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...
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.
setCallback( $callback)
Set a read callback to accept data read from the HTTP request.
addTelemetry(TelemetryHeadersInterface $telemetry)
Add Telemetry information to the request.
__construct( $url, array $options, $caller=__METHOD__, Profiler $profiler=null)
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,...
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
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