MediaWiki  master
MWHttpRequest.php
Go to the documentation of this file.
1 <?php
25 use Psr\Log\LoggerAwareInterface;
26 use Psr\Log\LoggerInterface;
27 use Psr\Log\NullLogger;
29 
37 abstract 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 Exception( "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  $commandLineMode = MediaWikiServices::getInstance()->getMainConfig()->get( 'CommandLineMode' );
290  $localVirtualHosts = MediaWikiServices::getInstance()->getMainConfig()->get(
291  MainConfigNames::LocalVirtualHosts );
292  if ( $commandLineMode ) {
293  return false;
294  }
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 
572  public function setCookieJar( CookieJar $jar ) {
573  $this->cookieJar = $jar;
574  }
575 
581  public function getCookieJar() {
582  if ( !$this->respHeaders ) {
583  $this->parseHeader();
584  }
585 
586  return $this->cookieJar;
587  }
588 
598  public function setCookie( $name, $value, array $attr = [] ) {
599  if ( !$this->cookieJar ) {
600  $this->cookieJar = new CookieJar;
601  }
602 
603  if ( $this->parsedUrl && !isset( $attr['domain'] ) ) {
604  $attr['domain'] = $this->parsedUrl['host'];
605  }
606 
607  $this->cookieJar->setCookie( $name, $value, $attr );
608  }
609 
613  protected function parseCookies() {
614  if ( !$this->cookieJar ) {
615  $this->cookieJar = new CookieJar;
616  }
617 
618  if ( isset( $this->respHeaders['set-cookie'] ) ) {
619  $url = parse_url( $this->getFinalUrl() );
620  if ( !isset( $url['host'] ) ) {
621  $this->status->fatal( 'http-invalid-url', $url );
622  } else {
623  foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
624  $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
625  }
626  }
627  }
628  }
629 
646  public function getFinalUrl() {
647  $headers = $this->getResponseHeaders();
648 
649  // return full url (fix for incorrect but handled relative location)
650  if ( isset( $headers['location'] ) ) {
651  $locations = $headers['location'];
652  $domain = '';
653  $foundRelativeURI = false;
654  $countLocations = count( $locations );
655 
656  for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
657  $url = parse_url( $locations[$i] );
658 
659  if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
660  $domain = $url['scheme'] . '://' . $url['host'];
661  break; // found correct URI (with host)
662  } else {
663  $foundRelativeURI = true;
664  }
665  }
666 
667  if ( !$foundRelativeURI ) {
668  return $locations[$countLocations - 1];
669  }
670  if ( $domain ) {
671  return $domain . $locations[$countLocations - 1];
672  }
673  $url = parse_url( $this->url );
674  if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
675  return $url['scheme'] . '://' . $url['host'] .
676  $locations[$countLocations - 1];
677  }
678  }
679 
680  return $this->url;
681  }
682 
688  public function canFollowRedirects() {
689  return true;
690  }
691 
704  public function setOriginalRequest( $originalRequest ) {
705  if ( $originalRequest instanceof WebRequest ) {
706  $originalRequest = [
707  'ip' => $originalRequest->getIP(),
708  'userAgent' => $originalRequest->getHeader( 'User-Agent' ),
709  ];
710  } elseif (
711  !is_array( $originalRequest )
712  || array_diff( [ 'ip', 'userAgent' ], array_keys( $originalRequest ) )
713  ) {
714  throw new InvalidArgumentException( __METHOD__ . ': $originalRequest must be a '
715  . "WebRequest or an array with 'ip' and 'userAgent' keys" );
716  }
717 
718  $this->reqHeaders['X-Forwarded-For'] = $originalRequest['ip'];
719  $this->reqHeaders['X-Original-User-Agent'] = $originalRequest['userAgent'];
720  }
721 
738  public static function isValidURI( $uri ) {
739  return (bool)preg_match(
740  '/^https?:\/\/[^\/\s]\S*$/D',
741  $uri
742  );
743  }
744 }
const PROTO_HTTP
Definition: Defines.php:191
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.
$matches
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.
string $profileName
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 stripping il...
Definition: WebRequest.php:50
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:58
Profiler base class that defines the interface and some shared functionality.
Definition: Profiler.php:37
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:73
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:85
Provide Request Telemetry information.
$content
Definition: router.php:76
$header