Chris@0: 80, Chris@0: 'https' => 443, Chris@0: ]; Chris@0: Chris@0: /** Chris@0: * @var string Chris@0: */ Chris@0: private $scheme = ''; Chris@0: Chris@0: /** Chris@0: * @var string Chris@0: */ Chris@0: private $userInfo = ''; Chris@0: Chris@0: /** Chris@0: * @var string Chris@0: */ Chris@0: private $host = ''; Chris@0: Chris@0: /** Chris@0: * @var int Chris@0: */ Chris@0: private $port; Chris@0: Chris@0: /** Chris@0: * @var string Chris@0: */ Chris@0: private $path = ''; Chris@0: Chris@0: /** Chris@0: * @var string Chris@0: */ Chris@0: private $query = ''; Chris@0: Chris@0: /** Chris@0: * @var string Chris@0: */ Chris@0: private $fragment = ''; Chris@0: Chris@0: /** Chris@0: * generated uri string cache Chris@0: * @var string|null Chris@0: */ Chris@0: private $uriString; Chris@0: Chris@0: /** Chris@0: * @param string $uri Chris@0: * @throws InvalidArgumentException on non-string $uri argument Chris@0: */ Chris@0: public function __construct($uri = '') Chris@0: { Chris@17: if ('' === $uri) { Chris@17: return; Chris@17: } Chris@17: Chris@0: if (! is_string($uri)) { Chris@0: throw new InvalidArgumentException(sprintf( Chris@0: 'URI passed to constructor must be a string; received "%s"', Chris@17: is_object($uri) ? get_class($uri) : gettype($uri) Chris@0: )); Chris@0: } Chris@0: Chris@17: $this->parseUri($uri); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Operations to perform on clone. Chris@0: * Chris@0: * Since cloning usually is for purposes of mutation, we reset the Chris@0: * $uriString property so it will be re-calculated. Chris@0: */ Chris@0: public function __clone() Chris@0: { Chris@0: $this->uriString = null; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function __toString() Chris@0: { Chris@0: if (null !== $this->uriString) { Chris@0: return $this->uriString; Chris@0: } Chris@0: Chris@0: $this->uriString = static::createUriString( Chris@0: $this->scheme, Chris@0: $this->getAuthority(), Chris@0: $this->getPath(), // Absolute URIs should use a "/" for an empty path Chris@0: $this->query, Chris@0: $this->fragment Chris@0: ); Chris@0: Chris@0: return $this->uriString; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getScheme() Chris@0: { Chris@0: return $this->scheme; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getAuthority() Chris@0: { Chris@16: if ('' === $this->host) { Chris@0: return ''; Chris@0: } Chris@0: Chris@0: $authority = $this->host; Chris@16: if ('' !== $this->userInfo) { Chris@0: $authority = $this->userInfo . '@' . $authority; Chris@0: } Chris@0: Chris@0: if ($this->isNonStandardPort($this->scheme, $this->host, $this->port)) { Chris@0: $authority .= ':' . $this->port; Chris@0: } Chris@0: Chris@0: return $authority; Chris@0: } Chris@0: Chris@0: /** Chris@12: * Retrieve the user-info part of the URI. Chris@12: * Chris@12: * This value is percent-encoded, per RFC 3986 Section 3.2.1. Chris@12: * Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getUserInfo() Chris@0: { Chris@0: return $this->userInfo; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getHost() Chris@0: { Chris@0: return $this->host; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getPort() Chris@0: { Chris@0: return $this->isNonStandardPort($this->scheme, $this->host, $this->port) Chris@0: ? $this->port Chris@0: : null; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getPath() Chris@0: { Chris@0: return $this->path; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getQuery() Chris@0: { Chris@0: return $this->query; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getFragment() Chris@0: { Chris@0: return $this->fragment; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function withScheme($scheme) Chris@0: { Chris@0: if (! is_string($scheme)) { Chris@0: throw new InvalidArgumentException(sprintf( Chris@0: '%s expects a string argument; received %s', Chris@0: __METHOD__, Chris@17: is_object($scheme) ? get_class($scheme) : gettype($scheme) Chris@0: )); Chris@0: } Chris@0: Chris@0: $scheme = $this->filterScheme($scheme); Chris@0: Chris@0: if ($scheme === $this->scheme) { Chris@0: // Do nothing if no change was made. Chris@12: return $this; Chris@0: } Chris@0: Chris@0: $new = clone $this; Chris@0: $new->scheme = $scheme; Chris@0: Chris@0: return $new; Chris@0: } Chris@0: Chris@0: /** Chris@12: * Create and return a new instance containing the provided user credentials. Chris@12: * Chris@12: * The value will be percent-encoded in the new instance, but with measures Chris@12: * taken to prevent double-encoding. Chris@12: * Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function withUserInfo($user, $password = null) Chris@0: { Chris@0: if (! is_string($user)) { Chris@0: throw new InvalidArgumentException(sprintf( Chris@0: '%s expects a string user argument; received %s', Chris@0: __METHOD__, Chris@17: is_object($user) ? get_class($user) : gettype($user) Chris@0: )); Chris@0: } Chris@0: if (null !== $password && ! is_string($password)) { Chris@0: throw new InvalidArgumentException(sprintf( Chris@17: '%s expects a string or null password argument; received %s', Chris@0: __METHOD__, Chris@17: is_object($password) ? get_class($password) : gettype($password) Chris@0: )); Chris@0: } Chris@0: Chris@12: $info = $this->filterUserInfoPart($user); Chris@17: if (null !== $password) { Chris@12: $info .= ':' . $this->filterUserInfoPart($password); Chris@0: } Chris@0: Chris@0: if ($info === $this->userInfo) { Chris@0: // Do nothing if no change was made. Chris@12: return $this; Chris@0: } Chris@0: Chris@0: $new = clone $this; Chris@0: $new->userInfo = $info; Chris@0: Chris@0: return $new; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function withHost($host) Chris@0: { Chris@0: if (! is_string($host)) { Chris@0: throw new InvalidArgumentException(sprintf( Chris@0: '%s expects a string argument; received %s', Chris@0: __METHOD__, Chris@17: is_object($host) ? get_class($host) : gettype($host) Chris@0: )); Chris@0: } Chris@0: Chris@0: if ($host === $this->host) { Chris@0: // Do nothing if no change was made. Chris@12: return $this; Chris@0: } Chris@0: Chris@0: $new = clone $this; Chris@13: $new->host = strtolower($host); Chris@0: Chris@0: return $new; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function withPort($port) Chris@0: { Chris@17: if ($port !== null) { Chris@17: if (! is_numeric($port) || is_float($port)) { Chris@17: throw new InvalidArgumentException(sprintf( Chris@17: 'Invalid port "%s" specified; must be an integer, an integer string, or null', Chris@17: is_object($port) ? get_class($port) : gettype($port) Chris@17: )); Chris@17: } Chris@0: Chris@0: $port = (int) $port; Chris@0: } Chris@0: Chris@0: if ($port === $this->port) { Chris@0: // Do nothing if no change was made. Chris@12: return $this; Chris@0: } Chris@0: Chris@12: if ($port !== null && ($port < 1 || $port > 65535)) { Chris@0: throw new InvalidArgumentException(sprintf( Chris@0: 'Invalid port "%d" specified; must be a valid TCP/UDP port', Chris@0: $port Chris@0: )); Chris@0: } Chris@0: Chris@0: $new = clone $this; Chris@0: $new->port = $port; Chris@0: Chris@0: return $new; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function withPath($path) Chris@0: { Chris@0: if (! is_string($path)) { Chris@0: throw new InvalidArgumentException( Chris@0: 'Invalid path provided; must be a string' Chris@0: ); Chris@0: } Chris@0: Chris@0: if (strpos($path, '?') !== false) { Chris@0: throw new InvalidArgumentException( Chris@0: 'Invalid path provided; must not contain a query string' Chris@0: ); Chris@0: } Chris@0: Chris@0: if (strpos($path, '#') !== false) { Chris@0: throw new InvalidArgumentException( Chris@0: 'Invalid path provided; must not contain a URI fragment' Chris@0: ); Chris@0: } Chris@0: Chris@0: $path = $this->filterPath($path); Chris@0: Chris@0: if ($path === $this->path) { Chris@0: // Do nothing if no change was made. Chris@12: return $this; Chris@0: } Chris@0: Chris@0: $new = clone $this; Chris@0: $new->path = $path; Chris@0: Chris@0: return $new; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function withQuery($query) Chris@0: { Chris@0: if (! is_string($query)) { Chris@0: throw new InvalidArgumentException( Chris@0: 'Query string must be a string' Chris@0: ); Chris@0: } Chris@0: Chris@0: if (strpos($query, '#') !== false) { Chris@0: throw new InvalidArgumentException( Chris@0: 'Query string must not include a URI fragment' Chris@0: ); Chris@0: } Chris@0: Chris@0: $query = $this->filterQuery($query); Chris@0: Chris@0: if ($query === $this->query) { Chris@0: // Do nothing if no change was made. Chris@12: return $this; Chris@0: } Chris@0: Chris@0: $new = clone $this; Chris@0: $new->query = $query; Chris@0: Chris@0: return $new; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function withFragment($fragment) Chris@0: { Chris@0: if (! is_string($fragment)) { Chris@0: throw new InvalidArgumentException(sprintf( Chris@0: '%s expects a string argument; received %s', Chris@0: __METHOD__, Chris@17: is_object($fragment) ? get_class($fragment) : gettype($fragment) Chris@0: )); Chris@0: } Chris@0: Chris@0: $fragment = $this->filterFragment($fragment); Chris@0: Chris@0: if ($fragment === $this->fragment) { Chris@0: // Do nothing if no change was made. Chris@12: return $this; Chris@0: } Chris@0: Chris@0: $new = clone $this; Chris@0: $new->fragment = $fragment; Chris@0: Chris@0: return $new; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parse a URI into its parts, and set the properties Chris@0: * Chris@0: * @param string $uri Chris@0: */ Chris@0: private function parseUri($uri) Chris@0: { Chris@0: $parts = parse_url($uri); Chris@0: Chris@0: if (false === $parts) { Chris@0: throw new \InvalidArgumentException( Chris@0: 'The source URI string appears to be malformed' Chris@0: ); Chris@0: } Chris@0: Chris@0: $this->scheme = isset($parts['scheme']) ? $this->filterScheme($parts['scheme']) : ''; Chris@12: $this->userInfo = isset($parts['user']) ? $this->filterUserInfoPart($parts['user']) : ''; Chris@13: $this->host = isset($parts['host']) ? strtolower($parts['host']) : ''; Chris@0: $this->port = isset($parts['port']) ? $parts['port'] : null; Chris@0: $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : ''; Chris@0: $this->query = isset($parts['query']) ? $this->filterQuery($parts['query']) : ''; Chris@0: $this->fragment = isset($parts['fragment']) ? $this->filterFragment($parts['fragment']) : ''; Chris@0: Chris@0: if (isset($parts['pass'])) { Chris@0: $this->userInfo .= ':' . $parts['pass']; Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Create a URI string from its various parts Chris@0: * Chris@0: * @param string $scheme Chris@0: * @param string $authority Chris@0: * @param string $path Chris@0: * @param string $query Chris@0: * @param string $fragment Chris@0: * @return string Chris@0: */ Chris@0: private static function createUriString($scheme, $authority, $path, $query, $fragment) Chris@0: { Chris@0: $uri = ''; Chris@0: Chris@16: if ('' !== $scheme) { Chris@0: $uri .= sprintf('%s:', $scheme); Chris@0: } Chris@0: Chris@16: if ('' !== $authority) { Chris@0: $uri .= '//' . $authority; Chris@0: } Chris@0: Chris@16: if ('' !== $path && '/' !== substr($path, 0, 1)) { Chris@16: $path = '/' . $path; Chris@0: } Chris@0: Chris@16: $uri .= $path; Chris@16: Chris@16: Chris@16: if ('' !== $query) { Chris@0: $uri .= sprintf('?%s', $query); Chris@0: } Chris@0: Chris@16: if ('' !== $fragment) { Chris@0: $uri .= sprintf('#%s', $fragment); Chris@0: } Chris@0: Chris@0: return $uri; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Is a given port non-standard for the current scheme? Chris@0: * Chris@0: * @param string $scheme Chris@0: * @param string $host Chris@0: * @param int $port Chris@0: * @return bool Chris@0: */ Chris@0: private function isNonStandardPort($scheme, $host, $port) Chris@0: { Chris@16: if ('' === $scheme) { Chris@16: return '' === $host || null !== $port; Chris@0: } Chris@0: Chris@16: if ('' === $host || null === $port) { Chris@0: return false; Chris@0: } Chris@0: Chris@0: return ! isset($this->allowedSchemes[$scheme]) || $port !== $this->allowedSchemes[$scheme]; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Filters the scheme to ensure it is a valid scheme. Chris@0: * Chris@0: * @param string $scheme Scheme name. Chris@0: * Chris@0: * @return string Filtered scheme. Chris@0: */ Chris@0: private function filterScheme($scheme) Chris@0: { Chris@0: $scheme = strtolower($scheme); Chris@0: $scheme = preg_replace('#:(//)?$#', '', $scheme); Chris@0: Chris@16: if ('' === $scheme) { Chris@0: return ''; Chris@0: } Chris@0: Chris@17: if (! isset($this->allowedSchemes[$scheme])) { Chris@0: throw new InvalidArgumentException(sprintf( Chris@0: 'Unsupported scheme "%s"; must be any empty string or in the set (%s)', Chris@0: $scheme, Chris@0: implode(', ', array_keys($this->allowedSchemes)) Chris@0: )); Chris@0: } Chris@0: Chris@0: return $scheme; Chris@0: } Chris@0: Chris@0: /** Chris@12: * Filters a part of user info in a URI to ensure it is properly encoded. Chris@12: * Chris@12: * @param string $part Chris@12: * @return string Chris@12: */ Chris@12: private function filterUserInfoPart($part) Chris@12: { Chris@12: // Note the addition of `%` to initial charset; this allows `|` portion Chris@12: // to match and thus prevent double-encoding. Chris@12: return preg_replace_callback( Chris@12: '/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/u', Chris@12: [$this, 'urlEncodeChar'], Chris@12: $part Chris@12: ); Chris@12: } Chris@12: Chris@12: /** Chris@0: * Filters the path of a URI to ensure it is properly encoded. Chris@0: * Chris@0: * @param string $path Chris@0: * @return string Chris@0: */ Chris@0: private function filterPath($path) Chris@0: { Chris@0: $path = preg_replace_callback( Chris@0: '/(?:[^' . self::CHAR_UNRESERVED . ')(:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u', Chris@0: [$this, 'urlEncodeChar'], Chris@0: $path Chris@0: ); Chris@0: Chris@16: if ('' === $path) { Chris@0: // No path Chris@0: return $path; Chris@0: } Chris@0: Chris@0: if ($path[0] !== '/') { Chris@0: // Relative path Chris@0: return $path; Chris@0: } Chris@0: Chris@0: // Ensure only one leading slash, to prevent XSS attempts. Chris@0: return '/' . ltrim($path, '/'); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Filter a query string to ensure it is propertly encoded. Chris@0: * Chris@0: * Ensures that the values in the query string are properly urlencoded. Chris@0: * Chris@0: * @param string $query Chris@0: * @return string Chris@0: */ Chris@0: private function filterQuery($query) Chris@0: { Chris@16: if ('' !== $query && strpos($query, '?') === 0) { Chris@0: $query = substr($query, 1); Chris@0: } Chris@0: Chris@0: $parts = explode('&', $query); Chris@0: foreach ($parts as $index => $part) { Chris@0: list($key, $value) = $this->splitQueryValue($part); Chris@0: if ($value === null) { Chris@0: $parts[$index] = $this->filterQueryOrFragment($key); Chris@0: continue; Chris@0: } Chris@0: $parts[$index] = sprintf( Chris@0: '%s=%s', Chris@0: $this->filterQueryOrFragment($key), Chris@0: $this->filterQueryOrFragment($value) Chris@0: ); Chris@0: } Chris@0: Chris@0: return implode('&', $parts); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Split a query value into a key/value tuple. Chris@0: * Chris@0: * @param string $value Chris@0: * @return array A value with exactly two elements, key and value Chris@0: */ Chris@0: private function splitQueryValue($value) Chris@0: { Chris@0: $data = explode('=', $value, 2); Chris@17: if (! isset($data[1])) { Chris@0: $data[] = null; Chris@0: } Chris@0: return $data; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Filter a fragment value to ensure it is properly encoded. Chris@0: * Chris@12: * @param string $fragment Chris@0: * @return string Chris@0: */ Chris@0: private function filterFragment($fragment) Chris@0: { Chris@16: if ('' !== $fragment && strpos($fragment, '#') === 0) { Chris@0: $fragment = '%23' . substr($fragment, 1); Chris@0: } Chris@0: Chris@0: return $this->filterQueryOrFragment($fragment); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Filter a query string key or value, or a fragment. Chris@0: * Chris@0: * @param string $value Chris@0: * @return string Chris@0: */ Chris@0: private function filterQueryOrFragment($value) Chris@0: { Chris@0: return preg_replace_callback( Chris@0: '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u', Chris@0: [$this, 'urlEncodeChar'], Chris@0: $value Chris@0: ); Chris@0: } Chris@0: Chris@0: /** Chris@0: * URL encode a character returned by a regex. Chris@0: * Chris@0: * @param array $matches Chris@0: * @return string Chris@0: */ Chris@0: private function urlEncodeChar(array $matches) Chris@0: { Chris@0: return rawurlencode($matches[0]); Chris@0: } Chris@0: }