Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
43.13% covered (danger)
43.13%
91 / 211
32.26% covered (danger)
32.26%
10 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
MWHttpRequest
43.13% covered (danger)
43.13%
91 / 211
32.26% covered (danger)
32.26%
10 / 31
1901.88
0.00% covered (danger)
0.00%
0 / 1
 __construct
60.53% covered (warning)
60.53%
23 / 38
0.00% covered (danger)
0.00%
0 / 1
28.84
 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 (optional) 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        $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
173    /**
174     * @param LoggerInterface $logger
175     */
176    public function setLogger( LoggerInterface $logger ) {
177        $this->logger = $logger;
178    }
179
180    /**
181     * Simple function to test if we can make any sort of requests at all, using
182     * cURL or fopen()
183     * @return bool
184     */
185    public static function canMakeRequests() {
186        return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
187    }
188
189    /**
190     * Get the body, or content, of the response to the request
191     *
192     * @return string
193     */
194    public function getContent() {
195        return $this->content;
196    }
197
198    /**
199     * Set the parameters of the request
200     *
201     * @param array $args
202     * @todo overload the args param
203     */
204    public function setData( array $args ) {
205        $this->postData = $args;
206    }
207
208    /**
209     * Add Telemetry information to the request
210     *
211     * @param TelemetryHeadersInterface $telemetry
212     * @return void
213     */
214    public function addTelemetry( TelemetryHeadersInterface $telemetry ): void {
215        foreach ( $telemetry->getRequestHeaders() as $header => $value ) {
216            $this->setHeader( $header, $value );
217        }
218    }
219
220    /**
221     * Take care of setting up the proxy (do nothing if "noProxy" is set)
222     *
223     * @return void
224     */
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
252    /**
253     * Enable use of a reverse proxy in which the hostname is
254     * passed as a "Host" header, and the request is sent to the
255     * proxy's host:port instead.
256     *
257     * Note that any custom port in the request URL will be lost
258     * and cookies and redirects may not work properly.
259     *
260     * @param string $proxy URL of proxy
261     */
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
282    /**
283     * Check if the URL can be served by localhost
284     *
285     * @param string $url Full url to check
286     * @return bool
287     */
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
323    /**
324     * @param string $UA
325     */
326    public function setUserAgent( $UA ) {
327        $this->setHeader( 'User-Agent', $UA );
328    }
329
330    /**
331     * Set an arbitrary header
332     * @param string $name
333     * @param string $value
334     */
335    public function setHeader( $name, $value ) {
336        // I feel like I should normalize the case here...
337        $this->reqHeaders[$name] = $value;
338    }
339
340    /**
341     * Get an array of the headers
342     * @return array
343     */
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
362    /**
363     * Set a read callback to accept data read from the HTTP request.
364     * By default, data is appended to an internal buffer which can be
365     * retrieved through $req->getContent().
366     *
367     * To handle data as it comes in -- especially for large files that
368     * would not fit in memory -- you can instead set your own callback,
369     * in the form function($resource, $buffer) where the first parameter
370     * is the low-level resource being read (implementation specific),
371     * and the second parameter is the data buffer.
372     *
373     * You MUST return the number of bytes handled in the buffer; if fewer
374     * bytes are reported handled than were passed to you, the HTTP fetch
375     * will be aborted.
376     *
377     * @param callable|null $callback
378     * @throws InvalidArgumentException
379     */
380    public function setCallback( $callback ) {
381        $this->doSetCallback( $callback );
382    }
383
384    /**
385     * Worker function for setting callbacks.  Calls can originate both internally and externally
386     * via setCallback).  Defaults to the internal read callback if $callback is null.
387     *
388     * @param callable|null $callback
389     * @throws InvalidArgumentException
390     */
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
401    /**
402     * A generic callback to read the body of the response from a remote
403     * server.
404     *
405     * @param resource $fh
406     * @param string $content
407     * @return int
408     * @internal
409     */
410    public function read( $fh, $content ) {
411        $this->content .= $content;
412        return strlen( $content );
413    }
414
415    /**
416     * Take care of whatever is necessary to perform the URI request.
417     *
418     * @return Status
419     * @note currently returns Status for B/C
420     */
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
444    /**
445     * Parses the headers, including the HTTP status code and any
446     * Set-Cookie headers.  This function expects the headers to be
447     * found in an array in the member variable headerList.
448     */
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
473    /**
474     * Sets HTTPRequest status member to a fatal value with the error
475     * message if the returned integer value of the status code was
476     * not successful (1-299) or a redirect (300-399).
477     * See RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
478     * for a list of status codes.
479     */
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
494    /**
495     * Get the integer value of the HTTP status code (e.g. 200 for "200 Ok")
496     * (see RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
497     * for a list of status codes.)
498     *
499     * @return int
500     */
501    public function getStatus() {
502        if ( !$this->respHeaders ) {
503            $this->parseHeader();
504        }
505
506        return (int)$this->respStatus;
507    }
508
509    /**
510     * Returns true if the last status code was a redirect.
511     *
512     * @return bool
513     */
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
528    /**
529     * Returns an associative array of response headers after the
530     * request has been executed.  Because some headers
531     * (e.g. Set-Cookie) can appear more than once the, each value of
532     * the associative array is an array of the values given.
533     * Header names are always in lowercase.
534     *
535     * @return array
536     */
537    public function getResponseHeaders() {
538        if ( !$this->respHeaders ) {
539            $this->parseHeader();
540        }
541
542        return $this->respHeaders;
543    }
544
545    /**
546     * Returns the value of the given response header.
547     *
548     * @param string $header case-insensitive
549     * @return string|null
550     */
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
564    /**
565     * Tells the MWHttpRequest object to use this pre-loaded CookieJar.
566     *
567     * To read response cookies from the jar, getCookieJar must be called first.
568     *
569     * @param CookieJar $jar
570     */
571    public function setCookieJar( CookieJar $jar ) {
572        $this->cookieJar = $jar;
573    }
574
575    /**
576     * Returns the cookie jar in use.
577     *
578     * @return CookieJar
579     */
580    public function getCookieJar() {
581        if ( !$this->respHeaders ) {
582            $this->parseHeader();
583        }
584
585        return $this->cookieJar;
586    }
587
588    /**
589     * Sets a cookie. Used before a request to set up any individual
590     * cookies. Used internally after a request to parse the
591     * Set-Cookie headers.
592     * @see Cookie::set
593     * @param string $name
594     * @param string $value
595     * @param array $attr
596     */
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
609    /**
610     * Parse the cookies in the response headers and store them in the cookie jar.
611     */
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
629    /**
630     * Returns the final URL after all redirections.
631     *
632     * Relative values of the "Location" header are incorrect as
633     * stated in RFC, however they do happen and modern browsers
634     * support them.  This function loops backwards through all
635     * locations in order to build the proper absolute URI - Marooned
636     * at wikia-inc.com
637     *
638     * Note that the multiple Location: headers are an artifact of
639     * CURL -- they shouldn't actually get returned this way. Rewrite
640     * this when T31232 is taken care of (high-level redirect
641     * handling rewrite).
642     *
643     * @return string
644     */
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
682    /**
683     * Returns true if the backend can follow redirects. Overridden by the
684     * child classes.
685     * @return bool
686     */
687    public function canFollowRedirects() {
688        return true;
689    }
690
691    /**
692     * Set information about the original request. This can be useful for
693     * endpoints/API modules which act as a proxy for some service, and
694     * throttling etc. needs to happen in that service.
695     * Calling this will result in the X-Forwarded-For and X-Original-User-Agent
696     * headers being set.
697     * @param WebRequest|array $originalRequest When in array form, it's
698     *   expected to have the keys 'ip' and 'userAgent'.
699     * @note IP/user agent is personally identifiable information, and should
700     *   only be set when the privacy policy of the request target is
701     *   compatible with that of the MediaWiki installation.
702     */
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
721    /**
722     * Check that the given URI is a valid one.
723     *
724     * This hardcodes a small set of protocols only, because we want to
725     * deterministically reject protocols not supported by all HTTP-transport
726     * methods.
727     *
728     * "file://" specifically must not be allowed, for security reasons
729     * (see <https://www.mediawiki.org/wiki/Special:Code/MediaWiki/r67684>).
730     *
731     * @todo FIXME this is wildly inaccurate and fails to actually check most stuff
732     *
733     * @since 1.34
734     * @param string $uri URI to check for validity
735     * @return bool
736     */
737    public static function isValidURI( $uri ) {
738        return (bool)preg_match(
739            '/^https?:\/\/[^\/\s]\S*$/D',
740            $uri
741        );
742    }
743}