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 $this->url = wfExpandUrl( $url, PROTO_HTTP );
106 $this->parsedUrl = wfParseUrl( $this->url );
107
108 $this->logger = $options['logger'] ?? new NullLogger();
109
110 if ( !$this->parsedUrl || !self::isValidURI( $this->url ) ) {
111 $this->status = StatusValue::newFatal( 'http-invalid-url', $url );
112 } else {
113 $this->status = StatusValue::newGood( 100 ); // continue
114 }
115
116 if ( isset( $options['timeout'] ) && $options['timeout'] != 'default' ) {
117 $this->timeout = $options['timeout'];
118 } else {
119 // The timeout should always be set by HttpRequestFactory, so this
120 // should only happen if the class was directly constructed
121 wfDeprecated( __METHOD__ . ' without the timeout option', '1.35' );
122 $httpTimeout = MediaWikiServices::getInstance()->getMainConfig()->get(
123 MainConfigNames::HTTPTimeout );
124 $this->timeout = $httpTimeout;
125 }
126 if ( isset( $options['connectTimeout'] ) && $options['connectTimeout'] != 'default' ) {
127 $this->connectTimeout = $options['connectTimeout'];
128 } else {
129 // The timeout should always be set by HttpRequestFactory, so this
130 // should only happen if the class was directly constructed
131 wfDeprecated( __METHOD__ . ' without the connectTimeout option', '1.35' );
132 $httpConnectTimeout = MediaWikiServices::getInstance()->getMainConfig()->get(
133 MainConfigNames::HTTPConnectTimeout );
134 $this->connectTimeout = $httpConnectTimeout;
135 }
136 if ( isset( $options['userAgent'] ) ) {
137 $this->setUserAgent( $options['userAgent'] );
138 }
139 if ( isset( $options['username'] ) && isset( $options['password'] ) ) {
140 $this->setHeader(
141 'Authorization',
142 'Basic ' . base64_encode( $options['username'] . ':' . $options['password'] )
143 );
144 }
145 if ( isset( $options['originalRequest'] ) ) {
146 $this->setOriginalRequest( $options['originalRequest'] );
147 }
148
149 $members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo",
150 "method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ];
151
152 foreach ( $members as $o ) {
153 if ( isset( $options[$o] ) ) {
154 // ensure that MWHttpRequest::method is always
155 // uppercased. T38137
156 if ( $o == 'method' ) {
157 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
158 $options[$o] = strtoupper( $options[$o] );
159 }
160 $this->$o = $options[$o];
161 }
162 }
163
164 if ( $this->noProxy ) {
165 $this->proxy = ''; // noProxy takes precedence
166 }
167
168 // Profile based on what's calling us
169 $this->profiler = $profiler;
170 $this->profileName = $caller;
171 }
172
176 public function setLogger( LoggerInterface $logger ) {
177 $this->logger = $logger;
178 }
179
185 public static function canMakeRequests() {
186 return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
187 }
188
194 public function getContent() {
195 return $this->content;
196 }
197
204 public function setData( array $args ) {
205 $this->postData = $args;
206 }
207
214 public function addTelemetry( TelemetryHeadersInterface $telemetry ): void {
215 foreach ( $telemetry->getRequestHeaders() as $header => $value ) {
216 $this->setHeader( $header, $value );
217 }
218 }
219
225 protected function proxySetup() {
226 $httpProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
227 MainConfigNames::HTTPProxy );
228 $localHTTPProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
229 MainConfigNames::LocalHTTPProxy );
230 // If proxies are disabled, clear any other proxy
231 if ( $this->noProxy ) {
232 $this->proxy = '';
233 return;
234 }
235
236 // If there is an explicit proxy already set, use it
237 if ( $this->proxy ) {
238 return;
239 }
240
241 // Otherwise, fallback to $wgLocalHTTPProxy for local URLs
242 // or $wgHTTPProxy for everything else
243 if ( self::isLocalURL( $this->url ) ) {
244 if ( $localHTTPProxy !== false ) {
245 $this->setReverseProxy( $localHTTPProxy );
246 }
247 } else {
248 $this->proxy = (string)$httpProxy;
249 }
250 }
251
262 protected function setReverseProxy( string $proxy ) {
263 $parsedProxy = wfParseUrl( $proxy );
264 if ( $parsedProxy === false ) {
265 throw new InvalidArgumentException( "Invalid reverseProxy configured: $proxy" );
266 }
267 // Set the current host in the Host header
268 $this->setHeader( 'Host', $this->parsedUrl['host'] );
269 // Replace scheme, host and port in the request
270 $this->parsedUrl['scheme'] = $parsedProxy['scheme'];
271 $this->parsedUrl['host'] = $parsedProxy['host'];
272 if ( isset( $parsedProxy['port'] ) ) {
273 $this->parsedUrl['port'] = $parsedProxy['port'];
274 } else {
275 unset( $this->parsedUrl['port'] );
276 }
277 $this->url = wfAssembleUrl( $this->parsedUrl );
278 // Mark that we're already using a proxy
279 $this->noProxy = true;
280 }
281
288 private static function isLocalURL( $url ) {
289 if ( MW_ENTRY_POINT === 'cli' ) {
290 return false;
291 }
292 $localVirtualHosts = MediaWikiServices::getInstance()->getMainConfig()->get(
293 MainConfigNames::LocalVirtualHosts );
294
295 // Extract host part
296 $matches = [];
297 if ( preg_match( '!^https?://([\w.-]+)[/:].*$!', $url, $matches ) ) {
298 $host = $matches[1];
299 // Split up dotwise
300 $domainParts = explode( '.', $host );
301 // Check if this domain or any superdomain is listed as a local virtual host
302 $domainParts = array_reverse( $domainParts );
303
304 $domain = '';
305 $countParts = count( $domainParts );
306 for ( $i = 0; $i < $countParts; $i++ ) {
307 $domainPart = $domainParts[$i];
308 if ( $i == 0 ) {
309 $domain = $domainPart;
310 } else {
311 $domain = $domainPart . '.' . $domain;
312 }
313
314 if ( in_array( $domain, $localVirtualHosts ) ) {
315 return true;
316 }
317 }
318 }
319
320 return false;
321 }
322
326 public function setUserAgent( $UA ) {
327 $this->setHeader( 'User-Agent', $UA );
328 }
329
335 public function setHeader( $name, $value ) {
336 // I feel like I should normalize the case here...
337 $this->reqHeaders[$name] = $value;
338 }
339
344 protected function getHeaderList() {
345 $list = [];
346
347 if ( $this->cookieJar ) {
348 $this->reqHeaders['Cookie'] =
349 $this->cookieJar->serializeToHttpRequest(
350 $this->parsedUrl['path'],
351 $this->parsedUrl['host']
352 );
353 }
354
355 foreach ( $this->reqHeaders as $name => $value ) {
356 $list[] = "$name: $value";
357 }
358
359 return $list;
360 }
361
380 public function setCallback( $callback ) {
381 $this->doSetCallback( $callback );
382 }
383
391 protected function doSetCallback( $callback ) {
392 if ( $callback === null ) {
393 $callback = [ $this, 'read' ];
394 } elseif ( !is_callable( $callback ) ) {
395 $this->status->fatal( 'http-internal-error' );
396 throw new InvalidArgumentException( __METHOD__ . ': invalid callback' );
397 }
398 $this->callback = $callback;
399 }
400
410 public function read( $fh, $content ) {
411 $this->content .= $content;
412 return strlen( $content );
413 }
414
421 public function execute() {
422 throw new LogicException( 'children must override this' );
423 }
424
425 protected function prepare() {
426 $this->content = "";
427
428 if ( strtoupper( $this->method ) == "HEAD" ) {
429 $this->headersOnly = true;
430 }
431
432 $this->proxySetup(); // set up any proxy as needed
433
434 if ( !$this->callback ) {
435 $this->doSetCallback( null );
436 }
437
438 if ( !isset( $this->reqHeaders['User-Agent'] ) ) {
439 $http = MediaWikiServices::getInstance()->getHttpRequestFactory();
440 $this->setUserAgent( $http->getUserAgent() );
441 }
442 }
443
449 protected function parseHeader() {
450 $lastname = "";
451
452 // Failure without (valid) headers gets a response status of zero
453 if ( !$this->status->isOK() ) {
454 $this->respStatus = '0 Error';
455 }
456
457 foreach ( $this->headerList as $header ) {
458 if ( preg_match( "#^HTTP/([0-9.]+) (.*)#", $header, $match ) ) {
459 $this->respVersion = $match[1];
460 $this->respStatus = $match[2];
461 } elseif ( preg_match( "#^[ \t]#", $header ) ) {
462 $last = count( $this->respHeaders[$lastname] ) - 1;
463 $this->respHeaders[$lastname][$last] .= "\r\n$header";
464 } elseif ( preg_match( "#^([^:]*):[\t ]*(.*)#", $header, $match ) ) {
465 $this->respHeaders[strtolower( $match[1] )][] = $match[2];
466 $lastname = strtolower( $match[1] );
467 }
468 }
469
470 $this->parseCookies();
471 }
472
480 protected function setStatus() {
481 if ( !$this->respHeaders ) {
482 $this->parseHeader();
483 }
484
485 if ( (int)$this->respStatus > 0 && (int)$this->respStatus < 400 ) {
486 $this->status->setResult( true, (int)$this->respStatus );
487 } else {
488 [ $code, $message ] = explode( " ", $this->respStatus, 2 );
489 $this->status->setResult( false, (int)$this->respStatus );
490 $this->status->fatal( "http-bad-status", $code, $message );
491 }
492 }
493
501 public function getStatus() {
502 if ( !$this->respHeaders ) {
503 $this->parseHeader();
504 }
505
506 return (int)$this->respStatus;
507 }
508
514 public function isRedirect() {
515 if ( !$this->respHeaders ) {
516 $this->parseHeader();
517 }
518
519 $status = (int)$this->respStatus;
520
521 if ( $status >= 300 && $status <= 303 ) {
522 return true;
523 }
524
525 return false;
526 }
527
537 public function getResponseHeaders() {
538 if ( !$this->respHeaders ) {
539 $this->parseHeader();
540 }
541
542 return $this->respHeaders;
543 }
544
551 public function getResponseHeader( $header ) {
552 if ( !$this->respHeaders ) {
553 $this->parseHeader();
554 }
555
556 if ( isset( $this->respHeaders[strtolower( $header )] ) ) {
557 $v = $this->respHeaders[strtolower( $header )];
558 return $v[count( $v ) - 1];
559 }
560
561 return null;
562 }
563
571 public function setCookieJar( CookieJar $jar ) {
572 $this->cookieJar = $jar;
573 }
574
580 public function getCookieJar() {
581 if ( !$this->respHeaders ) {
582 $this->parseHeader();
583 }
584
585 return $this->cookieJar;
586 }
587
597 public function setCookie( $name, $value, array $attr = [] ) {
598 if ( !$this->cookieJar ) {
599 $this->cookieJar = new CookieJar;
600 }
601
602 if ( $this->parsedUrl && !isset( $attr['domain'] ) ) {
603 $attr['domain'] = $this->parsedUrl['host'];
604 }
605
606 $this->cookieJar->setCookie( $name, $value, $attr );
607 }
608
612 protected function parseCookies() {
613 if ( !$this->cookieJar ) {
614 $this->cookieJar = new CookieJar;
615 }
616
617 if ( isset( $this->respHeaders['set-cookie'] ) ) {
618 $url = parse_url( $this->getFinalUrl() );
619 if ( !isset( $url['host'] ) ) {
620 $this->status->fatal( 'http-invalid-url', $url );
621 } else {
622 foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
623 $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
624 }
625 }
626 }
627 }
628
645 public function getFinalUrl() {
646 $headers = $this->getResponseHeaders();
647
648 // return full url (fix for incorrect but handled relative location)
649 if ( isset( $headers['location'] ) ) {
650 $locations = $headers['location'];
651 $domain = '';
652 $foundRelativeURI = false;
653 $countLocations = count( $locations );
654
655 for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
656 $url = parse_url( $locations[$i] );
657
658 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
659 $domain = $url['scheme'] . '://' . $url['host'];
660 break; // found correct URI (with host)
661 } else {
662 $foundRelativeURI = true;
663 }
664 }
665
666 if ( !$foundRelativeURI ) {
667 return $locations[$countLocations - 1];
668 }
669 if ( $domain ) {
670 return $domain . $locations[$countLocations - 1];
671 }
672 $url = parse_url( $this->url );
673 if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
674 return $url['scheme'] . '://' . $url['host'] .
675 $locations[$countLocations - 1];
676 }
677 }
678
679 return $this->url;
680 }
681
687 public function canFollowRedirects() {
688 return true;
689 }
690
703 public function setOriginalRequest( $originalRequest ) {
704 if ( $originalRequest instanceof WebRequest ) {
705 $originalRequest = [
706 'ip' => $originalRequest->getIP(),
707 'userAgent' => $originalRequest->getHeader( 'User-Agent' ),
708 ];
709 } elseif (
710 !is_array( $originalRequest )
711 || array_diff( [ 'ip', 'userAgent' ], array_keys( $originalRequest ) )
712 ) {
713 throw new InvalidArgumentException( __METHOD__ . ': $originalRequest must be a '
714 . "WebRequest or an array with 'ip' and 'userAgent' keys" );
715 }
716
717 $this->reqHeaders['X-Forwarded-For'] = $originalRequest['ip'];
718 $this->reqHeaders['X-Original-User-Agent'] = $originalRequest['userAgent'];
719 }
720
737 public static function isValidURI( $uri ) {
738 return (bool)preg_match(
739 '/^https?:\/\/[^\/\s]\S*$/D',
740 $uri
741 );
742 }
743}
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).
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
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.
__construct( $url, array $options=[], $caller=__METHOD__, Profiler $profiler=null)
setCallback( $callback)
Set a read callback to accept data read from the HTTP request.
addTelemetry(TelemetryHeadersInterface $telemetry)
Add Telemetry information to the request.
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