MediaWiki master
MWHttpRequest.php
Go to the documentation of this file.
1<?php
12use Psr\Log\LoggerAwareInterface;
13use Psr\Log\LoggerInterface;
14use Psr\Log\NullLogger;
16
24abstract class MWHttpRequest implements LoggerAwareInterface {
25 public const SUPPORTS_FILE_POSTS = false;
26
30 protected $timeout = 'default';
31
33 protected $content;
35 protected $headersOnly = null;
37 protected $postData = null;
39 protected $proxy = null;
41 protected $noProxy = false;
43 protected $sslVerifyHost = true;
45 protected $sslVerifyCert = true;
47 protected $caInfo = null;
49 protected $method = "GET";
51 protected $reqHeaders = [];
53 protected $url;
55 protected $parsedUrl;
57 protected $callback;
59 protected $maxRedirects = 5;
61 protected $followRedirects = false;
63 protected $connectTimeout;
64
68 protected $cookieJar;
69
71 protected $headerList = [];
73 protected $respVersion = "0.9";
75 protected $respStatus = "200 Ok";
77 protected $respHeaders = [];
78
80 protected $status;
81
85 protected $profiler;
86
90 protected $profileName;
91
95 protected $logger;
96
97 private UrlUtils $urlUtils;
98
108 public function __construct(
109 $url, array $options, $caller = __METHOD__, ?Profiler $profiler = null
110 ) {
111 $this->urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
112 if ( !array_key_exists( 'timeout', $options )
113 || !array_key_exists( 'connectTimeout', $options ) ) {
114 throw new InvalidArgumentException( "timeout and connectionTimeout options are required" );
115 }
116 $this->url = $this->urlUtils->expand( $url, PROTO_HTTP ) ?? false;
117 $this->parsedUrl = $this->urlUtils->parse( (string)$this->url ) ?? false;
118
119 $this->logger = $options['logger'] ?? new NullLogger();
120 $this->timeout = $options['timeout'];
121 $this->connectTimeout = $options['connectTimeout'];
122
123 if ( !$this->parsedUrl || !self::isValidURI( $this->url ) ) {
124 $this->status = StatusValue::newFatal( 'http-invalid-url', $url );
125 } else {
126 $this->status = StatusValue::newGood( 100 ); // continue
127 }
128
129 if ( isset( $options['userAgent'] ) ) {
130 $this->setUserAgent( $options['userAgent'] );
131 }
132 if ( isset( $options['username'] ) && isset( $options['password'] ) ) {
133 $this->setHeader(
134 'Authorization',
135 'Basic ' . base64_encode( $options['username'] . ':' . $options['password'] )
136 );
137 }
138 if ( isset( $options['originalRequest'] ) ) {
139 $this->setOriginalRequest( $options['originalRequest'] );
140 }
141
142 $members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo",
143 "method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ];
144
145 foreach ( $members as $o ) {
146 if ( isset( $options[$o] ) ) {
147 // ensure that MWHttpRequest::method is always
148 // uppercased. T38137
149 if ( $o == 'method' ) {
150 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
151 $options[$o] = strtoupper( $options[$o] );
152 }
153 $this->$o = $options[$o];
154 }
155 }
156
157 if ( $this->noProxy ) {
158 $this->proxy = ''; // noProxy takes precedence
159 }
160
161 // Profile based on what's calling us
162 $this->profiler = $profiler;
163 $this->profileName = $caller;
164 }
165
166 public function setLogger( LoggerInterface $logger ): void {
167 $this->logger = $logger;
168 }
169
175 public static function canMakeRequests() {
176 return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
177 }
178
184 public function getContent() {
185 return $this->content;
186 }
187
194 public function setData( array $args ) {
195 $this->postData = $args;
196 }
197
204 public function addTelemetry( TelemetryHeadersInterface $telemetry ): void {
205 foreach ( $telemetry->getRequestHeaders() as $header => $value ) {
206 $this->setHeader( $header, $value );
207 }
208 }
209
215 protected function proxySetup() {
216 $httpProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
217 MainConfigNames::HTTPProxy );
218 $localHTTPProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
219 MainConfigNames::LocalHTTPProxy );
220 // If proxies are disabled, clear any other proxy
221 if ( $this->noProxy ) {
222 $this->proxy = '';
223 return;
224 }
225
226 // If there is an explicit proxy already set, use it
227 if ( $this->proxy ) {
228 return;
229 }
230
231 // Otherwise, fallback to $wgLocalHTTPProxy for local URLs
232 // or $wgHTTPProxy for everything else
233 if ( self::isLocalURL( $this->url ) ) {
234 if ( $localHTTPProxy !== false ) {
235 $this->setReverseProxy( $localHTTPProxy );
236 }
237 } else {
238 $this->proxy = (string)$httpProxy;
239 }
240 }
241
252 protected function setReverseProxy( string $proxy ) {
253 $parsedProxy = $this->urlUtils->parse( $proxy );
254 if ( $parsedProxy === null ) {
255 throw new InvalidArgumentException( "Invalid reverseProxy configured: $proxy" );
256 }
257 // Set the current host in the Host header
258 $this->setHeader( 'Host', $this->parsedUrl['host'] );
259 // Replace scheme, host and port in the request
260 $this->parsedUrl['scheme'] = $parsedProxy['scheme'];
261 $this->parsedUrl['host'] = $parsedProxy['host'];
262 if ( isset( $parsedProxy['port'] ) ) {
263 $this->parsedUrl['port'] = $parsedProxy['port'];
264 } else {
265 unset( $this->parsedUrl['port'] );
266 }
267 $this->url = UrlUtils::assemble( $this->parsedUrl );
268 // Mark that we're already using a proxy
269 $this->noProxy = true;
270 }
271
278 private static function isLocalURL( $url ) {
279 $localVirtualHosts = MediaWikiServices::getInstance()->getMainConfig()->get(
280 MainConfigNames::LocalVirtualHosts );
281
282 // Extract host part
283 $matches = [];
284 if ( preg_match( '!^https?://([\w.-]+)[/:].*$!', $url, $matches ) ) {
285 $host = $matches[1];
286 // Split up dotwise
287 $domainParts = explode( '.', $host );
288 // Check if this domain or any superdomain is listed as a local virtual host
289 $domainParts = array_reverse( $domainParts );
290
291 $domain = '';
292 $countParts = count( $domainParts );
293 for ( $i = 0; $i < $countParts; $i++ ) {
294 $domainPart = $domainParts[$i];
295 if ( $i == 0 ) {
296 $domain = $domainPart;
297 } else {
298 $domain = $domainPart . '.' . $domain;
299 }
300
301 if ( in_array( $domain, $localVirtualHosts ) ) {
302 return true;
303 }
304 }
305 }
306
307 return false;
308 }
309
313 public function setUserAgent( $UA ) {
314 $this->setHeader( 'User-Agent', $UA );
315 }
316
322 public function setHeader( $name, $value ) {
323 // I feel like I should normalize the case here...
324 $this->reqHeaders[$name] = $value;
325 }
326
345 public function setCallback( $callback ) {
346 $this->doSetCallback( $callback );
347 }
348
356 protected function doSetCallback( $callback ) {
357 if ( $callback === null ) {
358 $callback = $this->read( ... );
359 } elseif ( !is_callable( $callback ) ) {
360 $this->status->fatal( 'http-internal-error' );
361 throw new InvalidArgumentException( __METHOD__ . ': invalid callback' );
362 }
363 $this->callback = $callback;
364 }
365
375 public function read( $fh, $content ) {
376 $this->content .= $content;
377 return strlen( $content );
378 }
379
386 public function execute() {
387 throw new LogicException( 'children must override this' );
388 }
389
390 protected function prepare() {
391 $this->content = "";
392
393 if ( strtoupper( $this->method ) == "HEAD" ) {
394 $this->headersOnly = true;
395 }
396
397 $this->proxySetup(); // set up any proxy as needed
398
399 if ( !$this->callback ) {
400 $this->doSetCallback( null );
401 }
402
403 if ( !isset( $this->reqHeaders['User-Agent'] ) ) {
404 $http = MediaWikiServices::getInstance()->getHttpRequestFactory();
405 $this->setUserAgent( $http->getUserAgent() );
406 }
407 }
408
414 protected function parseHeader() {
415 $lastname = "";
416
417 // Failure without (valid) headers gets a response status of zero
418 if ( !$this->status->isOK() ) {
419 $this->respStatus = '0 Error';
420 }
421
422 foreach ( $this->headerList as $header ) {
423 if ( preg_match( "#^HTTP/([0-9.]+) (.*)#", $header, $match ) ) {
424 $this->respVersion = $match[1];
425 $this->respStatus = $match[2];
426 } elseif ( preg_match( "#^[ \t]#", $header ) ) {
427 $last = count( $this->respHeaders[$lastname] ) - 1;
428 $this->respHeaders[$lastname][$last] .= "\r\n$header";
429 } elseif ( preg_match( "#^([^:]*):[\t ]*(.*)#", $header, $match ) ) {
430 $this->respHeaders[strtolower( $match[1] )][] = $match[2];
431 $lastname = strtolower( $match[1] );
432 }
433 }
434
435 $this->parseCookies();
436 }
437
445 protected function setStatus() {
446 if ( !$this->respHeaders ) {
447 $this->parseHeader();
448 }
449
450 if ( (int)$this->respStatus > 0 && (int)$this->respStatus < 400 ) {
451 $this->status->setResult( true, (int)$this->respStatus );
452 } else {
453 [ $code, $message ] = explode( " ", $this->respStatus, 2 );
454 $this->status->setResult( false, (int)$this->respStatus );
455 $this->status->fatal( "http-bad-status", $code, $message );
456 }
457 }
458
466 public function getStatus() {
467 if ( !$this->respHeaders ) {
468 $this->parseHeader();
469 }
470
471 return (int)$this->respStatus;
472 }
473
479 public function isRedirect() {
480 if ( !$this->respHeaders ) {
481 $this->parseHeader();
482 }
483
484 $status = (int)$this->respStatus;
485
486 if ( $status >= 300 && $status <= 303 ) {
487 return true;
488 }
489
490 return false;
491 }
492
502 public function getResponseHeaders() {
503 if ( !$this->respHeaders ) {
504 $this->parseHeader();
505 }
506
507 return $this->respHeaders;
508 }
509
516 public function getResponseHeader( $header ) {
517 if ( !$this->respHeaders ) {
518 $this->parseHeader();
519 }
520
521 if ( isset( $this->respHeaders[strtolower( $header )] ) ) {
522 $v = $this->respHeaders[strtolower( $header )];
523 return $v[count( $v ) - 1];
524 }
525
526 return null;
527 }
528
534 public function setCookieJar( CookieJar $jar ) {
535 $this->cookieJar = $jar;
536 }
537
543 public function getCookieJar() {
544 if ( !$this->respHeaders ) {
545 $this->parseHeader();
546 }
547
548 return $this->cookieJar;
549 }
550
560 public function setCookie( $name, $value, array $attr = [] ) {
561 $this->cookieJar ??= new CookieJar;
562
563 if ( $this->parsedUrl && !isset( $attr['domain'] ) ) {
564 $attr['domain'] = $this->parsedUrl['host'];
565 }
566
567 $this->cookieJar->setCookie( $name, $value, $attr );
568 }
569
573 protected function parseCookies() {
574 $this->cookieJar ??= new CookieJar;
575
576 if ( isset( $this->respHeaders['set-cookie'] ) ) {
577 $url = parse_url( $this->getFinalUrl() );
578 if ( !isset( $url['host'] ) ) {
579 $this->status->fatal( 'http-invalid-url', $this->getFinalUrl() );
580 } else {
581 foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
582 $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
583 }
584 }
585 }
586 }
587
604 public function getFinalUrl() {
605 $headers = $this->getResponseHeaders();
606
607 // return full url (fix for incorrect but handled relative location)
608 if ( isset( $headers['location'] ) ) {
609 $locations = $headers['location'];
610 $domain = '';
611 $foundRelativeURI = false;
612 $countLocations = count( $locations );
613
614 for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
615 $url = parse_url( $locations[$i] );
616
617 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
618 $domain = $url['scheme'] . '://' . $url['host'];
619 break; // found correct URI (with host)
620 } else {
621 $foundRelativeURI = true;
622 }
623 }
624
625 if ( !$foundRelativeURI ) {
626 return $locations[$countLocations - 1];
627 }
628 if ( $domain ) {
629 return $domain . $locations[$countLocations - 1];
630 }
631 $url = parse_url( $this->url );
632 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
633 return $url['scheme'] . '://' . $url['host'] .
634 $locations[$countLocations - 1];
635 }
636 }
637
638 return $this->url;
639 }
640
646 public function canFollowRedirects() {
647 return true;
648 }
649
662 public function setOriginalRequest( $originalRequest ) {
663 if ( $originalRequest instanceof WebRequest ) {
664 $originalRequest = [
665 'ip' => $originalRequest->getIP(),
666 'userAgent' => $originalRequest->getHeader( 'User-Agent' ),
667 ];
668 } elseif (
669 !is_array( $originalRequest )
670 || array_diff( [ 'ip', 'userAgent' ], array_keys( $originalRequest ) )
671 ) {
672 throw new InvalidArgumentException( __METHOD__ . ': $originalRequest must be a '
673 . "WebRequest or an array with 'ip' and 'userAgent' keys" );
674 }
675
676 $this->reqHeaders['X-Forwarded-For'] = $originalRequest['ip'];
677 $this->reqHeaders['X-Original-User-Agent'] = $originalRequest['userAgent'];
678 }
679
696 public static function isValidURI( $uri ) {
697 return (bool)preg_match(
698 '/^https?:\/\/[^\/\s]\S*$/D',
699 $uri
700 );
701 }
702}
const PROTO_HTTP
Definition Defines.php:217
wfIniGetBool( $setting)
Safety wrapper around ini_get() for boolean settings.
Cookie jar to use with MWHttpRequest.
Definition CookieJar.php:11
setCookie(string $name, string $value, array $attr)
Set a cookie in the cookie jar.
Definition CookieJar.php:23
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
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:44
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:22
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Provide Request Telemetry information.