Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.52% covered (success)
97.52%
157 / 161
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
UrlUtils
97.52% covered (success)
97.52%
157 / 161
66.67% covered (warning)
66.67%
8 / 12
93
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
14
 expand
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
20
 getServer
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getCanonicalServer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assemble
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
11
 removeDotSegments
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
18
 validProtocols
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validAbsoluteProtocols
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validProtocolsInternal
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 parse
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
12
 expandIRI
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 matchesDomainList
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3namespace MediaWiki\Utils;
4
5use BadMethodCallException;
6use InvalidArgumentException;
7use MediaWiki\Debug\MWDebug;
8use MediaWiki\MainConfigSchema;
9
10/**
11 * A service to expand, parse, and otherwise manipulate URLs.
12 *
13 * @since 1.39
14 * @newable
15 */
16class UrlUtils {
17    public const SERVER = 'server';
18    public const CANONICAL_SERVER = 'canonicalServer';
19    public const INTERNAL_SERVER = 'internalServer';
20    public const FALLBACK_PROTOCOL = 'fallbackProtocol';
21    public const HTTPS_PORT = 'httpsPort';
22    public const VALID_PROTOCOLS = 'validProtocols';
23
24    public const ALL_PROTOCOLS = 'ALL_PROTOCOLS';
25
26    /** @var ?string */
27    private $server = null;
28
29    /** @var ?string */
30    private $canonicalServer = null;
31
32    /** @var ?string */
33    private $internalServer = null;
34    /** @var string */
35    private $fallbackProtocol = 'http';
36
37    /** @var int */
38    private $httpsPort = 443;
39
40    /** @var array|string */
41    private $validProtocols = MainConfigSchema::UrlProtocols['default'];
42
43    /** @var ?string */
44    private $validProtocolsCache = null;
45
46    /** @var ?string */
47    private $validAbsoluteProtocolsCache = null;
48
49    /**
50     * @stable to call
51     * @param array $options All keys are optional, but SERVER is required in practice as otherwise
52     *   expand(), getServer(), expandIRI(), and matchesDomainList() may throw. The optionality is
53     *   for backwards-compatibile access to UrlUtils::assemble (was ported from static global
54     *   function to instance method), and to ease stubbing in unit tests.
55     *
56     * Recognized keys:
57     *   * self::SERVER: The protocol and server portion of the URLs to expand, with no other parts
58     *     (port, path, etc.). Example: 'https://example.com'. Protocol-relative URLs are
59     *     allowed.
60     *   * self::CANONICAL_SERVER: If SERVER is protocol-relative, this can be set to a
61     *     fully-qualified version for use when PROTO_CANONICAL is passed to expand(). Defaults
62     *     to SERVER, with 'http:' prepended if SERVER is protocol-relative.
63     *   * self::INTERNAL_SERVER: An alternative to SERVER that's used when PROTO_INTERNAL is
64     *     passed to expand(). It's intended for sites that have a different server name exposed
65     *     to CDNs. Defaults to SERVER.
66     *   * self::FALLBACK_PROTOCOL: Used by expand() when no $defaultProto parameter is provided.
67     *     Defaults to 'http'. The instance created by ServiceWiring sets this to 'https' if the
68     *     current request is detected to be via HTTPS, and 'http' otherwise.
69     *   * self::HTTPS_PORT: Defaults to 443. Used when a protocol-relative URL is expanded to
70     *     https.
71     *   * self::VALID_PROTOCOLS: An array of recognized URL protocols, or self::ALL_PROTOCOLS to
72     *     allow any protocol. The default can be found in MainConfigSchema::UrlProtocols['default'].
73     */
74    public function __construct( array $options = [] ) {
75        foreach ( $options as $key => $value ) {
76            switch ( $key ) {
77                case self::SERVER:
78                case self::CANONICAL_SERVER:
79                case self::INTERNAL_SERVER:
80                case self::FALLBACK_PROTOCOL:
81                case self::HTTPS_PORT:
82                case self::VALID_PROTOCOLS:
83                    $this->$key = $value;
84                    break;
85
86                default:
87                    throw new InvalidArgumentException( "Unrecognized option \"$key\"" );
88            }
89        }
90
91        if ( $this->server !== null ) {
92            if ( $this->canonicalServer === null || $this->canonicalServer === false ) {
93                $this->canonicalServer = $this->expand( $this->server, PROTO_HTTP );
94            }
95            if ( $this->internalServer === null || $this->internalServer === false ) {
96                $this->internalServer = $this->server;
97            }
98        }
99    }
100
101    /**
102     * Expand a potentially local URL to a fully-qualified URL using $wgServer
103     * (or one of its alternatives).
104     *
105     * The meaning of the PROTO_* constants is as follows:
106     * PROTO_HTTP: Output a URL starting with http://
107     * PROTO_HTTPS: Output a URL starting with https://
108     * PROTO_RELATIVE: Output a URL starting with // (protocol-relative URL)
109     * PROTO_FALLBACK: Output a URL starting with the FALLBACK_PROTOCOL option
110     * PROTO_CURRENT: Legacy alias for PROTO_FALLBACK
111     * PROTO_CANONICAL: For URLs without a domain, like /w/index.php, use CANONICAL_SERVER.  For
112     *   protocol-relative URLs, use the protocol of CANONICAL_SERVER
113     * PROTO_INTERNAL: Like PROTO_CANONICAL, but uses INTERNAL_SERVER instead of CANONICAL_SERVER
114     *
115     * If $url specifies a protocol, or $url is domain-relative and $wgServer
116     * specifies a protocol, PROTO_HTTP, PROTO_HTTPS, PROTO_RELATIVE and
117     * PROTO_CURRENT do not change that.
118     *
119     * Parent references (/../) in the path are resolved (as in ::removeDotSegments).
120     *
121     * @todo this won't work with current-path-relative URLs like "subdir/foo.html", etc.
122     *
123     * @throws BadMethodCallException if no server was passed to the constructor
124     * @param string $url An URL; can be absolute (e.g. http://example.com/foo/bar),
125     *    protocol-relative (//example.com/foo/bar) or domain-relative (/foo/bar).
126     * @param string|int|null $defaultProto One of the PROTO_* constants, as described above.
127     * @return ?string Fully-qualified URL, current-path-relative URL or null if
128     *    no valid URL can be constructed
129     */
130    public function expand( string $url, $defaultProto = PROTO_FALLBACK ): ?string {
131        if ( $defaultProto === PROTO_CANONICAL ) {
132            $serverUrl = $this->canonicalServer;
133        } elseif ( $defaultProto === PROTO_INTERNAL ) {
134            $serverUrl = $this->internalServer;
135        } else {
136            $serverUrl = $this->server;
137            if ( $defaultProto === PROTO_FALLBACK ) {
138                $defaultProto = $this->fallbackProtocol . '://';
139            }
140        }
141
142        if ( str_starts_with( $url, '/' ) ) {
143            if ( $serverUrl === null ) {
144                throw new BadMethodCallException( 'Cannot call expand() if the appropriate ' .
145                    'SERVER/CANONICAL_SERVER/INTERNAL_SERVER option was not passed to the ' .
146                    'constructor' );
147            }
148
149            // Analyze $serverUrl to obtain its protocol
150            $bits = $this->parse( $serverUrl );
151            $serverProto = $bits && $bits['scheme'] != '' ? $bits['scheme'] . '://' : null;
152
153            if ( $defaultProto === PROTO_CANONICAL || $defaultProto === PROTO_INTERNAL ) {
154                // Fall back to HTTP in the ridiculous case that CanonicalServer or InternalServer
155                // doesn't have a protocol
156                $defaultProto = $serverProto ?? PROTO_HTTP;
157            }
158
159            // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal T308355
160            $defaultProtoWithoutSlashes = $defaultProto === PROTO_FALLBACK ? '' : substr( $defaultProto, 0, -2 );
161
162            if ( str_starts_with( $url, '//' ) ) {
163                $url = $defaultProtoWithoutSlashes . $url;
164            } else {
165                // If $serverUrl is protocol-relative, prepend $defaultProtoWithoutSlashes,
166                // otherwise leave it alone.
167                if ( $serverProto ) {
168                    $url = $serverUrl . $url;
169                } else {
170                    // If an HTTPS URL is synthesized from a protocol-relative Server, allow the
171                    // user to override the port number (T67184)
172                    if ( $defaultProto === PROTO_HTTPS && $this->httpsPort != 443 ) {
173                        if ( isset( $bits['port'] ) ) {
174                            throw new InvalidArgumentException(
175                                'A protocol-relative server may not contain a port number' );
176                        }
177                        $url = "$defaultProtoWithoutSlashes$serverUrl:{$this->httpsPort}$url";
178                    } else {
179                        $url = "$defaultProtoWithoutSlashes$serverUrl$url";
180                    }
181                }
182            }
183        }
184
185        $bits = $this->parse( $url );
186
187        if ( $bits && isset( $bits['path'] ) ) {
188            $bits['path'] = self::removeDotSegments( $bits['path'] );
189            return self::assemble( $bits );
190        } elseif ( $bits ) {
191            # No path to expand
192            return $url;
193        } elseif ( !str_starts_with( $url, '/' ) ) {
194            # URL is a relative path
195            return self::removeDotSegments( $url );
196        }
197
198        # Expanded URL is not valid.
199        return null;
200    }
201
202    /**
203     * Get the wiki's "server", i.e. the protocol and host part of the URL, with a
204     * protocol specified using a PROTO_* constant as in expand()
205     *
206     * @throws BadMethodCallException if no server was passed to the constructor
207     * @param string|int|null $proto One of the PROTO_* constants.
208     * @return ?string The URL, or null on failure
209     */
210    public function getServer( $proto ): ?string {
211        $url = $this->expand( '/', $proto );
212        if ( $url === null ) {
213            return null;
214        }
215        return substr( $url, 0, -1 );
216    }
217
218    /**
219     * Get the canonical server, i.e. the canonical protocol and host part of
220     * the wiki's URL.
221     */
222    public function getCanonicalServer(): string {
223        // @phan-suppress-next-line PhanTypeMismatchReturnNullable -- throw if unconfigured
224        return $this->canonicalServer;
225    }
226
227    /**
228     * This function will reassemble a URL parsed with parse().  This is useful if you need to edit
229     * part of a URL and put it back together.
230     *
231     * This is the basic structure used (brackets contain keys for $urlParts):
232     * [scheme][delimiter][user]:[pass]@[host]:[port][path]?[query]#[fragment]
233     *
234     * @since 1.41
235     * @param array $urlParts URL parts, as output from parse()
236     * @return string URL assembled from its component parts
237     */
238    public static function assemble( array $urlParts ): string {
239        $result = '';
240
241        if ( isset( $urlParts['delimiter'] ) ) {
242            if ( isset( $urlParts['scheme'] ) ) {
243                $result .= $urlParts['scheme'];
244            }
245
246            $result .= $urlParts['delimiter'];
247        }
248
249        if ( isset( $urlParts['host'] ) ) {
250            if ( isset( $urlParts['user'] ) ) {
251                $result .= $urlParts['user'];
252                if ( isset( $urlParts['pass'] ) ) {
253                    $result .= ':' . $urlParts['pass'];
254                }
255                $result .= '@';
256            }
257
258            $result .= $urlParts['host'];
259
260            if ( isset( $urlParts['port'] ) ) {
261                $result .= ':' . $urlParts['port'];
262            }
263        }
264
265        if ( isset( $urlParts['path'] ) ) {
266            $result .= $urlParts['path'];
267        }
268
269        if ( isset( $urlParts['query'] ) && $urlParts['query'] !== '' ) {
270            $result .= '?' . $urlParts['query'];
271        }
272
273        if ( isset( $urlParts['fragment'] ) ) {
274            $result .= '#' . $urlParts['fragment'];
275        }
276
277        return $result;
278    }
279
280    /**
281     * Remove all dot-segments in the provided URL path.  For example, '/a/./b/../c/' becomes
282     * '/a/c/'.  For details on the algorithm, please see RFC3986 section 5.2.4.
283     *
284     * @since 1.41
285     * @param string $urlPath URL path, potentially containing dot-segments
286     * @return string URL path with all dot-segments removed
287     */
288    public static function removeDotSegments( string $urlPath ): string {
289        $output = '';
290        $inputOffset = 0;
291        $inputLength = strlen( $urlPath );
292
293        while ( $inputOffset < $inputLength ) {
294            $trimOutput = false;
295            if ( substr_compare( $urlPath, './', $inputOffset, 2 ) === 0 ) {
296                # Step A, remove leading "./"
297                $inputOffset += 2;
298            } elseif ( substr_compare( $urlPath, '../', $inputOffset, 3 ) === 0 ) {
299                # Step A, remove leading "../"
300                $inputOffset += 3;
301            } elseif ( $inputOffset + 2 === $inputLength && str_ends_with( $urlPath, '/.' ) ) {
302                # Step B, replace leading "/.$" with "/"
303                $inputOffset++;
304                $urlPath[$inputOffset] = '/';
305            } elseif ( substr_compare( $urlPath, '/./', $inputOffset, 3 ) === 0 ) {
306                # Step B, replace leading "/./" with "/"
307                $inputOffset += 2;
308            } elseif ( $inputOffset + 3 === $inputLength && str_ends_with( $urlPath, '/..' ) ) {
309                # Step C, replace leading "/..$" with "/" and
310                # remove last path component in output
311                $inputOffset += 2;
312                $urlPath[$inputOffset] = '/';
313                $trimOutput = true;
314            } elseif ( substr_compare( $urlPath, '/../', $inputOffset, 4 ) === 0 ) {
315                # Step C, replace leading "/../" with "/" and
316                # remove last path component in output
317                $inputOffset += 3;
318                $trimOutput = true;
319            } elseif ( $inputOffset + 1 === $inputLength && str_ends_with( $urlPath, '.' ) ) {
320                # Step D, remove "^.$"
321                $inputOffset++;
322            } elseif ( $inputOffset + 2 === $inputLength && str_ends_with( $urlPath, '..' ) ) {
323                # Step D, remove "^..$"
324                $inputOffset += 2;
325            } else {
326                # Step E, move leading path segment to output
327                if ( $urlPath[$inputOffset] === '/' ) {
328                    $slashPos = strpos( $urlPath, '/', $inputOffset + 1 );
329                } else {
330                    $slashPos = strpos( $urlPath, '/', $inputOffset );
331                }
332                if ( $slashPos === false ) {