Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.52% |
157 / 161 |
|
66.67% |
8 / 12 |
CRAP | |
0.00% |
0 / 1 |
| UrlUtils | |
97.52% |
157 / 161 |
|
66.67% |
8 / 12 |
93 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
14 | |||
| expand | |
97.22% |
35 / 36 |
|
0.00% |
0 / 1 |
20 | |||
| getServer | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| getCanonicalServer | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| assemble | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
11 | |||
| removeDotSegments | |
100.00% |
39 / 39 |
|
100.00% |
1 / 1 |
18 | |||
| validProtocols | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| validAbsoluteProtocols | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| validProtocolsInternal | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
6.04 | |||
| parse | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
12 | |||
| expandIRI | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
| matchesDomainList | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Utils; |
| 4 | |
| 5 | use BadMethodCallException; |
| 6 | use InvalidArgumentException; |
| 7 | use MediaWiki\Debug\MWDebug; |
| 8 | use MediaWiki\MainConfigSchema; |
| 9 | |
| 10 | /** |
| 11 | * A service to expand, parse, and otherwise manipulate URLs. |
| 12 | * |
| 13 | * @since 1.39 |
| 14 | * @newable |
| 15 | */ |
| 16 | class 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 ) { |