Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.61% covered (danger)
44.61%
91 / 204
32.26% covered (danger)
32.26%
10 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
MWHttpRequest
44.61% covered (danger)
44.61%
91 / 204
32.26% covered (danger)
32.26%
10 / 31
1696.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
74.19% covered (warning)
74.19%
23 / 31
0.00% covered (danger)
0.00%
0 / 1
15.90
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canMakeRequests
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addTelemetry
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 proxySetup
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
6.42
 setReverseProxy
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 isLocalURL
10.53% covered (danger)
10.53%
2 / 19
0.00% covered (danger)
0.00%
0 / 1
31.79
 setUserAgent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeaderList
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 setCallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doSetCallback
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 read
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 execute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prepare
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 parseHeader
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 setStatus
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getStatus
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isRedirect
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getResponseHeaders
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getResponseHeader
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 setCookieJar
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCookieJar
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setCookie
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 parseCookies
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 getFinalUrl
14.29% covered (danger)
14.29%
3 / 21
0.00% covered (danger)
0.00%
0 / 1
60.01
 canFollowRedirects
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOriginalRequest
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 isValidURI
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21use MediaWiki\MainConfigNames;
22use MediaWiki\MediaWikiServices;
23use MediaWiki\Request\WebRequest;
24use MediaWiki\Status\Status;
25use Psr\Log\LoggerAwareInterface;
26use Psr\Log\LoggerInterface;
27use Psr\Log\NullLogger;
28use Wikimedia\Http\TelemetryHeadersInterface;
29
30/**
31 * This wrapper class will call out to curl (if available) or fallback
32 * to regular PHP if necessary for handling internal HTTP requests.
33 *
34 * Renamed from HttpRequest to MWHttpRequest to avoid conflict with
35 * PHP's HTTP extension.
36 */
37abstract class MWHttpRequest implements LoggerAwareInterface {
38    public const SUPPORTS_FILE_POSTS = false;
39
40    /**
41     * @var int|string
42     */
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";
54    /** @var array */
55    protected $reqHeaders = [];
56    protected $url;
57    protected $parsedUrl;
58    /** @var callable */
59    protected $callback;
60    protected $maxRedirects = 5;
61    protected $followRedirects = false;
62    protected $connectTimeout;
63
64    /**
65     * @var CookieJar
66     */
67    protected $cookieJar;
68
69    protected $headerList = [];
70    protected $respVersion = "0.9";
71    protected $respStatus = "200 Ok";
72    /** @var string[][] */
73    protected $respHeaders = [];
74
75    /** @var StatusValue */
76    protected $status;
77
78    /**
79     * @var Profiler
80     */
81    protected $profiler;
82
83    /**
84     * @var string
85     */
86    protected $profileName;
87
88    /**
89     * @var LoggerInterface
90     */
91    protected $logger;
92
93    /**
94     * @param string $url Url to use. If protocol-relative, will be expanded to an http:// URL
95     * @param array $options extra params to pass (see HttpRequestFactory::create())
96     * @phpcs:ignore Generic.Files.LineLength
97     * @phan-param array{timeout?:int|string,connectTimeout?:int|string,postData?:array,proxy?:string,noProxy?:bool,sslVerifyHost?:bool,sslVerifyCert?:bool,caInfo?:string,maxRedirects?:int,followRedirects?:bool,userAgent?:string,logger?:LoggerInterface,username?:string,password?:string,originalRequest?:WebRequest|array{ip:string,userAgent:string},method?:string} $options
98     * @param string $caller The method making this request, for profiling
99     * @param Profiler|null $profiler An instance of the profiler for profiling, or null
100     * @throws Exception
101     */
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
159    /**
160     * @param LoggerInterface $logger
161     */
162    public function setLogger( LoggerInterface $logger ) {
163        $this->logger = $logger;
164    }
165
166    /**
167     * Simple function to test if we can make any sort of requests at all, using
168     * cURL or fopen()
169     * @return bool
170     */
171    public static function canMakeRequests() {
172        return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
173    }
174
175    /**
176     * Get the body, or content, of the response to the request
177     *
178     * @return string
179     */
180    public function getContent() {
181        return $this->content;
182    }
183
184    /**
185     * Set the parameters of the request
186     *
187     * @param array $args
188     * @todo overload the args param
189     */
190    public function setData( array $args ) {
191        $this->postData = $args;
192    }
193
194    /**
195     * Add Telemetry information to the request
196     *
197     * @param TelemetryHeadersInterface $telemetry
198     * @return void
199     */
200    public function addTelemetry( TelemetryHeadersInterface $telemetry ): void {
201        foreach ( $telemetry->getRequestHeaders() as $header => $value ) {
202            $this->setHeader( $header, $value );
203        }
204    }
205
206    /**
207     * Take care of setting up the proxy (do nothing if "noProxy" is set)
208     *
209     * @return void
210     */
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
238    /**
239     * Enable use of a reverse proxy in which the hostname is
240     * passed as a "Host" header, and the request is sent to the
241     * proxy's host:port instead.
242     *
243     * Note that any custom port in the request URL will be lost
244     * and cookies and redirects may not work properly.
245     *
246     * @param string $proxy URL of proxy
247     */
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
268    /**
269     * Check if the URL can be served by localhost
270     *
271     * @param string $url Full url to check
272     * @return bool
273     */
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
309    /**
310     * @param string $UA
311     */
312    public function setUserAgent( $UA ) {
313        $this->setHeader( 'User-Agent', $UA );
314    }
315
316    /**
317     * Set an arbitrary header
318     * @param string $name
319     * @param string $value
320     */
321    public function setHeader( $name, $value ) {
322        // I feel like I should normalize the case here...
323        $this->reqHeaders[$name] = $value;
324    }
325
326    /**
327     * Get an array of the headers
328     * @return array
329     */
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
348    /**
349     * Set a read callback to accept data read from the HTTP request.
350     * By default, data is appended to an internal buffer which can be
351     * retrieved through $req->getContent().
352     *
353     * To handle data as it comes in -- especially for large files that
354     * would not fit in memory -- you can instead set your own callback,
355     * in the form function($resource, $buffer) where the first parameter
356     * is the low-level resource being read (implementation specific),
357     * and the second parameter is the data buffer.
358     *
359     * You MUST return the number of bytes handled in the buffer; if fewer
360     * bytes are reported handled than were passed to you, the HTTP fetch
361     * will be aborted.
362     *
363     * @param callable|null $callback
364     * @throws InvalidArgumentException
365     */
366    public function setCallback( $callback ) {
367        $this->doSetCallback( $callback );
368    }
369
370    /**
371     * Worker function for setting callbacks.  Calls can originate both internally and externally
372     * via setCallback).  Defaults to the internal read callback if $callback is null.
373     *
374     * @param callable|null $callback
375     * @throws InvalidArgumentException
376     */
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
387    /**
388     * A generic callback to read the body of the response from a remote
389     * server.
390     *
391     * @param resource $fh
392     * @param string $content
393     * @return int
394     * @internal
395     */
396    public function read( $fh, $content ) {
397        $this->content .= $content;
398        return strlen( $content );
399    }
400
401    /**
402     * Take care of whatever is necessary to perform the URI request.
403     *
404     * @return Status
405     * @note currently returns Status for B/C
406     */
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
430    /**
431     * Parses the headers, including the HTTP status code and any
432     * Set-Cookie headers.  This function expects the headers to be
433     * found in an array in the member variable headerList.
434     */
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
459    /**
460     * Sets HTTPRequest status member to a fatal value with the error
461     * message if the returned integer value of the status code was
462     * not successful (1-299) or a redirect (300-399).
463     * See RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
464     * for a list of status codes.
465     */
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
480    /**
481     * Get the integer value of the HTTP status code (e.g. 200 for "200 Ok")
482     * (see RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
483     * for a list of status codes.)
484     *
485     * @return int
486     */
487    public function getStatus() {
488        if ( !$this->respHeaders ) {
489            $this->parseHeader();
490        }
491
492        return (int)$this->respStatus;
493    }
494
495    /**
496     * Returns true if the last status code was a redirect.
497     *
498     * @return bool
499     */
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
514    /**
515     * Returns an associative array of response headers after the
516     * request has been executed.  Because some headers
517     * (e.g. Set-Cookie) can appear more than once the, each value of
518     * the associative array is an array of the values given.
519     * Header names are always in lowercase.
520     *
521     * @return array
522     */
523    public function getResponseHeaders() {
524        if ( !$this->respHeaders ) {
525            $this->parseHeader();
526        }
527
528        return $this->respHeaders;
529    }
530
531    /**
532     * Returns the value of the given response header.
533     *
534     * @param string $header case-insensitive
535     * @return string|null
536     */
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
550    /**
551     * Tells the MWHttpRequest object to use this pre-loaded CookieJar.
552     *
553     * To read response cookies from the jar, getCookieJar must be called first.
554     *
555     * @param CookieJar $jar
556     */
557    public function setCookieJar( CookieJar $jar ) {
558        $this->cookieJar = $jar;
559    }
560
561    /**
562     * Returns the cookie jar in use.
563     *
564     * @return CookieJar
565     */
566    public function getCookieJar() {
567        if ( !$this->respHeaders ) {
568            $this->parseHeader();
569        }
570
571        return $this->cookieJar;
572    }
573
574    /**
575     * Sets a cookie. Used before a request to set up any individual
576     * cookies. Used internally after a request to parse the
577     * Set-Cookie headers.
578     * @see Cookie::set
579     * @param string $name
580     * @param string $value
581     * @param array $attr
582     */
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
595    /**
596     * Parse the cookies in the response headers and store them in the cookie jar.
597     */
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
615    /**
616     * Returns the final URL after all redirections.
617     *
618     * Relative values of the "Location" header are incorrect as
619     * stated in RFC, however they do happen and modern browsers
620     * support them.  This function loops backwards through all
621     * locations in order to build the proper absolute URI - Marooned
622     * at wikia-inc.com
623     *
624     * Note that the multiple Location: headers are an artifact of
625     * CURL -- they shouldn't actually get returned this way. Rewrite
626     * this when T31232 is taken care of (high-level redirect
627     * handling rewrite).
628     *
629     * @return string
630     */
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
668    /**
669     * Returns true if the backend can follow redirects. Overridden by the
670     * child classes.
671     * @return bool
672     */
673    public function canFollowRedirects() {
674        return true;
675    }
676
677    /**
678     * Set information about the original request. This can be useful for
679     * endpoints/API modules which act as a proxy for some service, and
680     * throttling etc. needs to happen in that service.
681     * Calling this will result in the X-Forwarded-For and X-Original-User-Agent
682     * headers being set.
683     * @param WebRequest|array $originalRequest When in array form, it's
684     *   expected to have the keys 'ip' and 'userAgent'.
685     * @note IP/user agent is personally identifiable information, and should
686     *   only be set when the privacy policy of the request target is
687     *   compatible with that of the MediaWiki installation.
688     */
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
707    /**
708     * Check that the given URI is a valid one.
709     *
710     * This hardcodes a small set of protocols only, because we want to
711     * deterministically reject protocols not supported by all HTTP-transport
712     * methods.
713     *
714     * "file://" specifically must not be allowed, for security reasons
715     * (see <https://www.mediawiki.org/wiki/Special:Code/MediaWiki/r67684>).
716     *
717     * @todo FIXME this is wildly inaccurate and fails to actually check most stuff
718     *
719     * @since 1.34
720     * @param string $uri URI to check for validity
721     * @return bool
722     */
723    public static function isValidURI( $uri ) {
724        return (bool)preg_match(
725            '/^https?:\/\/[^\/\s]\S*$/D',
726            $uri
727        );
728    }
729}