Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
46.80% covered (danger)
46.80%
95 / 203
32.26% covered (danger)
32.26%
10 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
MWHttpRequest
46.80% covered (danger)
46.80%
95 / 203
32.26% covered (danger)
32.26%
10 / 31
1483.80
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.00% covered (warning)
75.00%
24 / 32
0.00% covered (danger)
0.00%
0 / 1
15.64
 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
29.41% covered (danger)
29.41%
5 / 17
0.00% covered (danger)
0.00%
0 / 1
13.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 MediaWiki\Utils\UrlUtils;
26use Psr\Log\LoggerAwareInterface;
27use Psr\Log\LoggerInterface;
28use Psr\Log\NullLogger;
29use Wikimedia\Http\TelemetryHeadersInterface;
30
31/**
32 * This wrapper class will call out to curl (if available) or fallback
33 * to regular PHP if necessary for handling internal HTTP requests.
34 *
35 * Renamed from HttpRequest to MWHttpRequest to avoid conflict with
36 * PHP's HTTP extension.
37 */
38abstract class MWHttpRequest implements LoggerAwareInterface {
39    public const SUPPORTS_FILE_POSTS = false;
40
41    /**
42     * @var int|string
43     */
44    protected $timeout = 'default';
45
46    /** @var string|null */
47    protected $content;
48    /** @var bool|null */
49    protected $headersOnly = null;
50    /** @var array|null */
51    protected $postData = null;
52    /** @var string|null */
53    protected $proxy = null;
54    /** @var bool */
55    protected $noProxy = false;
56    /** @var bool */
57    protected $sslVerifyHost = true;
58    /** @var bool */
59    protected $sslVerifyCert = true;
60    /** @var string|null */
61    protected $caInfo = null;
62    /** @var string */
63    protected $method = "GET";
64    /** @var array */
65    protected $reqHeaders = [];
66    /** @var string */
67    protected $url;
68    /** @var array|false */
69    protected $parsedUrl;
70    /** @var callable */
71    protected $callback;
72    /** @var int */
73    protected $maxRedirects = 5;
74    /** @var bool */
75    protected $followRedirects = false;
76    /** @var int */
77    protected $connectTimeout;
78
79    /**
80     * @var CookieJar
81     */
82    protected $cookieJar;
83
84    /** @var array */
85    protected $headerList = [];
86    /** @var string */
87    protected $respVersion = "0.9";
88    /** @var string */
89    protected $respStatus = "200 Ok";
90    /** @var string[][] */
91    protected $respHeaders = [];
92
93    /** @var StatusValue */
94    protected $status;
95
96    /**
97     * @var Profiler
98     */
99    protected $profiler;
100
101    /**
102     * @var string
103     */
104    protected $profileName;
105
106    /**
107     * @var LoggerInterface
108     */
109    protected $logger;
110
111    private UrlUtils $urlUtils;
112
113    /**
114     * @param string $url Url to use. If protocol-relative, will be expanded to an http:// URL
115     * @param array $options extra params to pass (see HttpRequestFactory::create())
116     * @phpcs:ignore Generic.Files.LineLength
117     * @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
118     * @param string $caller The method making this request, for profiling @phan-mandatory-param
119     * @param Profiler|null $profiler An instance of the profiler for profiling, or null
120     * @throws Exception
121     */
122    public function __construct(
123        $url, array $options, $caller = __METHOD__, ?Profiler $profiler = null
124    ) {
125        $this->urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
126        if ( !array_key_exists( 'timeout', $options )
127            || !array_key_exists( 'connectTimeout', $options ) ) {
128            throw new InvalidArgumentException( "timeout and connectionTimeout options are required" );
129        }
130        $this->url = $this->urlUtils->expand( $url, PROTO_HTTP ) ?? false;
131        $this->parsedUrl = $this->urlUtils->parse( (string)$this->url ) ?? false;
132
133        $this->logger = $options['logger'] ?? new NullLogger();
134        $this->timeout = $options['timeout'];
135        $this->connectTimeout = $options['connectTimeout'];
136
137        if ( !$this->parsedUrl || !self::isValidURI( $this->url ) ) {
138            $this->status = StatusValue::newFatal( 'http-invalid-url', $url );
139        } else {
140            $this->status = StatusValue::newGood( 100 ); // continue
141        }
142
143        if ( isset( $options['userAgent'] ) ) {
144            $this->setUserAgent( $options['userAgent'] );
145        }
146        if ( isset( $options['username'] ) && isset( $options['password'] ) ) {
147            $this->setHeader(
148                'Authorization',
149                'Basic ' . base64_encode( $options['username'] . ':' . $options['password'] )
150            );
151        }
152        if ( isset( $options['originalRequest'] ) ) {
153            $this->setOriginalRequest( $options['originalRequest'] );
154        }
155
156        $members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo",
157                "method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ];
158
159        foreach ( $members as $o ) {
160            if ( isset( $options[$o] ) ) {
161                // ensure that MWHttpRequest::method is always
162                // uppercased. T38137
163                if ( $o == 'method' ) {
164                    // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
165                    $options[$o] = strtoupper( $options[$o] );
166                }
167                $this->$o = $options[$o];
168            }
169        }
170
171        if ( $this->noProxy ) {
172            $this->proxy = ''; // noProxy takes precedence
173        }
174
175        // Profile based on what's calling us
176        $this->profiler = $profiler;
177        $this->profileName = $caller;
178    }
179
180    public function setLogger( LoggerInterface $logger ) {
181        $this->logger = $logger;
182    }
183
184    /**
185     * Simple function to test if we can make any sort of requests at all, using
186     * cURL or fopen()
187     * @return bool
188     */
189    public static function canMakeRequests() {
190        return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
191    }
192
193    /**
194     * Get the body, or content, of the response to the request
195     *
196     * @return string
197     */
198    public function getContent() {
199        return $this->content;
200    }
201
202    /**
203     * Set the parameters of the request
204     *
205     * @param array $args
206     * @todo overload the args param
207     */
208    public function setData( array $args ) {
209        $this->postData = $args;
210    }
211
212    /**
213     * Add Telemetry information to the request
214     *
215     * @param TelemetryHeadersInterface $telemetry
216     * @return void
217     */
218    public function addTelemetry( TelemetryHeadersInterface $telemetry ): void {
219        foreach ( $telemetry->getRequestHeaders() as $header => $value ) {
220            $this->setHeader( $header, $value );
221        }
222    }
223
224    /**
225     * Take care of setting up the proxy (do nothing if "noProxy" is set)
226     *
227     * @return void
228     */
229    protected function proxySetup() {
230        $httpProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
231            MainConfigNames::HTTPProxy );
232        $localHTTPProxy = MediaWikiServices::getInstance()->getMainConfig()->get(
233            MainConfigNames::LocalHTTPProxy );
234        // If proxies are disabled, clear any other proxy
235        if ( $this->noProxy ) {
236            $this->proxy = '';
237            return;
238        }
239
240        // If there is an explicit proxy already set, use it
241        if ( $this->proxy ) {
242            return;
243        }
244
245        // Otherwise, fallback to $wgLocalHTTPProxy for local URLs
246        // or $wgHTTPProxy for everything else
247        if ( self::isLocalURL( $this->url ) ) {
248            if ( $localHTTPProxy !== false ) {
249                $this->setReverseProxy( $localHTTPProxy );
250            }
251        } else {
252            $this->proxy = (string)$httpProxy;
253        }
254    }
255
256    /**
257     * Enable use of a reverse proxy in which the hostname is
258     * passed as a "Host" header, and the request is sent to the
259     * proxy's host:port instead.
260     *
261     * Note that any custom port in the request URL will be lost
262     * and cookies and redirects may not work properly.
263     *
264     * @param string $proxy URL of proxy
265     */
266    protected function setReverseProxy( string $proxy ) {
267        $parsedProxy = $this->urlUtils->parse( $proxy );
268        if ( $parsedProxy === null ) {
269            throw new InvalidArgumentException( "Invalid reverseProxy configured: $proxy" );
270        }
271        // Set the current host in the Host header
272        $this->setHeader( 'Host', $this->parsedUrl['host'] );
273        // Replace scheme, host and port in the request
274        $this->parsedUrl['scheme'] = $parsedProxy['scheme'];
275        $this->parsedUrl['host'] = $parsedProxy['host'];
276        if ( isset( $parsedProxy['port'] ) ) {
277            $this->parsedUrl['port'] = $parsedProxy['port'];
278        } else {
279            unset( $this->parsedUrl['port'] );
280        }
281        $this->url = UrlUtils::assemble( $this->parsedUrl );
282        // Mark that we're already using a proxy
283        $this->noProxy = true;
284    }
285
286    /**
287     * Check if the URL can be served by a local endpoint
288     *
289     * @param string $url Full url to check
290     * @return bool
291     */
292    private static function isLocalURL( $url ) {
293        $localVirtualHosts = MediaWikiServices::getInstance()->getMainConfig()->get(
294            MainConfigNames::LocalVirtualHosts );
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
324    /**
325     * @param string $UA
326     */
327    public function setUserAgent( $UA ) {
328        $this->setHeader( 'User-Agent', $UA );
329    }
330
331    /**
332     * Set an arbitrary header
333     * @param string $name
334     * @param string $value
335     */
336    public function setHeader( $name, $value ) {
337        // I feel like I should normalize the case here...
338        $this->reqHeaders[$name] = $value;
339    }
340
341    /**
342     * Get an array of the headers
343     * @return array
344     */
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
363    /**
364     * Set a read callback to accept data read from the HTTP request.
365     * By default, data is appended to an internal buffer which can be
366     * retrieved through $req->getContent().
367     *
368     * To handle data as it comes in -- especially for large files that
369     * would not fit in memory -- you can instead set your own callback,
370     * in the form function($resource, $buffer) where the first parameter
371     * is the low-level resource being read (implementation specific),
372     * and the second parameter is the data buffer.
373     *
374     * You MUST return the number of bytes handled in the buffer; if fewer
375     * bytes are reported handled than were passed to you, the HTTP fetch
376     * will be aborted.
377     *
378     * @param callable|null $callback
379     * @throws InvalidArgumentException
380     */
381    public function setCallback( $callback ) {
382        $this->doSetCallback( $callback );
383    }
384
385    /**
386     * Worker function for setting callbacks.  Calls can originate both internally and externally
387     * via setCallback).  Defaults to the internal read callback if $callback is null.
388     *
389     * @param callable|null $callback
390     * @throws InvalidArgumentException
391     */
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
402    /**
403     * A generic callback to read the body of the response from a remote
404     * server.
405     *
406     * @param resource $fh
407     * @param string $content
408     * @return int
409     * @internal
410     */
411    public function read( $fh, $content ) {
412        $this->content .= $content;
413        return strlen( $content );
414    }
415
416    /**
417     * Take care of whatever is necessary to perform the URI request.
418     *
419     * @return Status
420     * @note currently returns Status for B/C
421     */
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
445    /**
446     * Parses the headers, including the HTTP status code and any
447     * Set-Cookie headers.  This function expects the headers to be
448     * found in an array in the member variable headerList.
449     */
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
474    /**
475     * Sets HTTPRequest status member to a fatal value with the error
476     * message if the returned integer value of the status code was
477     * not successful (1-299) or a redirect (300-399).
478     * See RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
479     * for a list of status codes.
480     */
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
495    /**
496     * Get the integer value of the HTTP status code (e.g. 200 for "200 Ok")
497     * (see RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
498     * for a list of status codes.)
499     *
500     * @return int
501     */
502    public function getStatus() {
503        if ( !$this->respHeaders ) {
504            $this->parseHeader();
505        }
506
507        return (int)$this->respStatus;
508    }
509
510    /**
511     * Returns true if the last status code was a redirect.
512     *
513     * @return bool
514     */
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
529    /**
530     * Returns an associative array of response headers after the
531     * request has been executed.  Because some headers
532     * (e.g. Set-Cookie) can appear more than once the, each value of
533     * the associative array is an array of the values given.
534     * Header names are always in lowercase.
535     *
536     * @return array
537     */
538    public function getResponseHeaders() {
539        if ( !$this->respHeaders ) {
540            $this->parseHeader();
541        }
542
543        return $this->respHeaders;
544    }
545
546    /**
547     * Returns the value of the given response header.
548     *
549     * @param string $header case-insensitive
550     * @return string|null
551     */
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
565    /**
566     * Tells the MWHttpRequest object to use this pre-loaded CookieJar.
567     *
568     * To read response cookies from the jar, getCookieJar must be called first.
569     */
570    public function setCookieJar( CookieJar $jar ) {
571        $this->cookieJar = $jar;
572    }
573
574    /**
575     * Returns the cookie jar in use.
576     *
577     * @return CookieJar
578     */
579    public function getCookieJar() {
580        if ( !$this->respHeaders ) {
581            $this->parseHeader();
582        }
583
584        return $this->cookieJar;
585    }
586
587    /**
588     * Sets a cookie. Used before a request to set up any individual
589     * cookies. Used internally after a request to parse the
590     * Set-Cookie headers.
591     * @see Cookie::set
592     * @param string $name
593     * @param string $value
594     * @param array $attr
595     */
596    public function setCookie( $name, $value, array $attr = [] ) {
597        if ( !$this->cookieJar ) {
598            $this->cookieJar = new CookieJar;
599        }
600
601        if ( $this->parsedUrl && !isset( $attr['domain'] ) ) {
602            $attr['domain'] = $this->parsedUrl['host'];
603        }
604
605        $this->cookieJar->setCookie( $name, $value, $attr );
606    }
607
608    /**
609     * Parse the cookies in the response headers and store them in the cookie jar.
610     */
611    protected function parseCookies() {
612        if ( !$this->cookieJar ) {
613            $this->cookieJar = new CookieJar;
614        }
615
616        if ( isset( $this->respHeaders['set-cookie'] ) ) {
617            $url = parse_url( $this->getFinalUrl() );
618            if ( !isset( $url['host'] ) ) {
619                $this->status->fatal( 'http-invalid-url', $this->getFinalUrl() );
620            } else {
621                foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
622                    $this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
623                }
624            }
625        }
626    }
627
628    /**
629     * Returns the final URL after all redirections.
630     *
631     * Relative values of the "Location" header are incorrect as
632     * stated in RFC, however they do happen and modern browsers
633     * support them.  This function loops backwards through all
634     * locations in order to build the proper absolute URI - Marooned
635     * at wikia-inc.com
636     *
637     * Note that the multiple Location: headers are an artifact of
638     * CURL -- they shouldn't actually get returned this way. Rewrite
639     * this when T31232 is taken care of (high-level redirect
640     * handling rewrite).
641     *
642     * @return string
643     */
644    public function getFinalUrl() {
645        $headers = $this->getResponseHeaders();
646
647        // return full url (fix for incorrect but handled relative location)
648        if ( isset( $headers['location'] ) ) {
649            $locations = $headers['location'];
650            $domain = '';
651            $foundRelativeURI = false;
652            $countLocations = count( $locations );
653
654            for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
655                $url = parse_url( $locations[$i] );
656
657                if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
658                    $domain = $url['scheme'] . '://' . $url['host'];
659                    break; // found correct URI (with host)
660                } else {
661                    $foundRelativeURI = true;
662                }
663            }
664
665            if ( !$foundRelativeURI ) {
666                return $locations[$countLocations - 1];
667            }
668            if ( $domain ) {
669                return $domain . $locations[$countLocations - 1];
670            }
671            $url = parse_url( $this->url );
672            if ( isset( $url['scheme'] ) && isset( $url['host'] ) ) {
673                return $url['scheme'] . '://' . $url['host'] .
674                    $locations[$countLocations - 1];
675            }
676        }
677
678        return $this->url;
679    }
680
681    /**
682     * Returns true if the backend can follow redirects. Overridden by the
683     * child classes.
684     * @return bool
685     */
686    public function canFollowRedirects() {
687        return true;
688    }
689
690    /**
691     * Set information about the original request. This can be useful for
692     * endpoints/API modules which act as a proxy for some service, and
693     * throttling etc. needs to happen in that service.
694     * Calling this will result in the X-Forwarded-For and X-Original-User-Agent
695     * headers being set.
696     * @param WebRequest|array $originalRequest When in array form, it's
697     *   expected to have the keys 'ip' and 'userAgent'.
698     * @note IP/user agent is personally identifiable information, and should
699     *   only be set when the privacy policy of the request target is
700     *   compatible with that of the MediaWiki installation.
701     */
702    public function setOriginalRequest( $originalRequest ) {
703        if ( $originalRequest instanceof WebRequest ) {
704            $originalRequest = [
705                'ip' => $originalRequest->getIP(),
706                'userAgent' => $originalRequest->getHeader( 'User-Agent' ),
707            ];
708        } elseif (
709            !is_array( $originalRequest )
710            || array_diff( [ 'ip', 'userAgent' ], array_keys( $originalRequest ) )
711        ) {
712            throw new InvalidArgumentException( __METHOD__ . ': $originalRequest must be a '
713                . "WebRequest or an array with 'ip' and 'userAgent' keys" );
714        }
715
716        $this->reqHeaders['X-Forwarded-For'] = $originalRequest['ip'];
717        $this->reqHeaders['X-Original-User-Agent'] = $originalRequest['userAgent'];
718    }
719
720    /**
721     * Check that the given URI is a valid one.
722     *
723     * This hardcodes a small set of protocols only, because we want to
724     * deterministically reject protocols not supported by all HTTP-transport
725     * methods.
726     *
727     * "file://" specifically must not be allowed, for security reasons
728     * (see <https://www.mediawiki.org/wiki/Special:Code/MediaWiki/r67684>).
729     *
730     * @todo FIXME this is wildly inaccurate and fails to actually check most stuff
731     *
732     * @since 1.34
733     * @param string $uri URI to check for validity
734     * @return bool
735     */
736    public static function isValidURI( $uri ) {
737        return (bool)preg_match(
738            '/^https?:\/\/[^\/\s]\S*$/D',
739            $uri
740        );
741    }
742}