487 lines
16 KiB
PHP
487 lines
16 KiB
PHP
<?php
|
|
|
|
/**
|
|
* File for Net\Uri class.
|
|
* @package Phrity > Net > Uri
|
|
* @see https://www.rfc-editor.org/rfc/rfc3986
|
|
* @see https://www.php-fig.org/psr/psr-7/#35-psrhttpmessageuriinterface
|
|
*/
|
|
|
|
namespace Phrity\Net;
|
|
|
|
use InvalidArgumentException;
|
|
use Psr\Http\Message\UriInterface;
|
|
|
|
/**
|
|
* Net\Uri class.
|
|
*/
|
|
class Uri implements UriInterface
|
|
{
|
|
public const REQUIRE_PORT = 1; // Always include port, explicit or default
|
|
public const ABSOLUTE_PATH = 2; // Enforce absolute path
|
|
public const NORMALIZE_PATH = 4; // Normalize path
|
|
public const IDNA = 8; // IDNA-convert host
|
|
|
|
private const RE_MAIN = '!^(?P<schemec>(?P<scheme>[^:/?#]+):)?(?P<authorityc>//(?P<authority>[^/?#]*))?'
|
|
. '(?P<path>[^?#]*)(?P<queryc>\?(?P<query>[^#]*))?(?P<fragmentc>#(?P<fragment>.*))?$!';
|
|
private const RE_AUTH = '!^(?P<userinfoc>(?P<user>[^:/?#]+)(?P<passc>:(?P<pass>[^:/?#]+))?@)?'
|
|
. '(?P<host>[^:/?#]*|\[[^/?#]*\])(?P<portc>:(?P<port>[0-9]*))?$!';
|
|
|
|
private static $port_defaults = [
|
|
'acap' => 674,
|
|
'afp' => 548,
|
|
'dict' => 2628,
|
|
'dns' => 53,
|
|
'ftp' => 21,
|
|
'git' => 9418,
|
|
'gopher' => 70,
|
|
'http' => 80,
|
|
'https' => 443,
|
|
'imap' => 143,
|
|
'ipp' => 631,
|
|
'ipps' => 631,
|
|
'irc' => 194,
|
|
'ircs' => 6697,
|
|
'ldap' => 389,
|
|
'ldaps' => 636,
|
|
'mms' => 1755,
|
|
'msrp' => 2855,
|
|
'mtqp' => 1038,
|
|
'nfs' => 111,
|
|
'nntp' => 119,
|
|
'nntps' => 563,
|
|
'pop' => 110,
|
|
'prospero' => 1525,
|
|
'redis' => 6379,
|
|
'rsync' => 873,
|
|
'rtsp' => 554,
|
|
'rtsps' => 322,
|
|
'rtspu' => 5005,
|
|
'sftp' => 22,
|
|
'smb' => 445,
|
|
'snmp' => 161,
|
|
'ssh' => 22,
|
|
'svn' => 3690,
|
|
'telnet' => 23,
|
|
'ventrilo' => 3784,
|
|
'vnc' => 5900,
|
|
'wais' => 210,
|
|
'ws' => 80,
|
|
'wss' => 443,
|
|
];
|
|
|
|
private $scheme;
|
|
private $authority;
|
|
private $host;
|
|
private $port;
|
|
private $user;
|
|
private $pass;
|
|
private $path;
|
|
private $query;
|
|
private $fragment;
|
|
|
|
/**
|
|
* Create new URI instance using a string
|
|
* @param string $uri_string URI as string
|
|
* @throws \InvalidArgumentException If the given URI cannot be parsed
|
|
*/
|
|
public function __construct(string $uri_string = '', int $flags = 0)
|
|
{
|
|
$this->parse($uri_string);
|
|
}
|
|
|
|
|
|
// ---------- PSR-7 getters ---------------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Retrieve the scheme component of the URI.
|
|
* @return string The URI scheme
|
|
*/
|
|
public function getScheme(int $flags = 0): string
|
|
{
|
|
return $this->getComponent('scheme') ?? '';
|
|
}
|
|
|
|
/**
|
|
* Retrieve the authority component of the URI.
|
|
* @return string The URI authority, in "[user-info@]host[:port]" format
|
|
*/
|
|
public function getAuthority(int $flags = 0): string
|
|
{
|
|
$host = $this->formatComponent($this->getHost($flags));
|
|
if ($this->isEmpty($host)) {
|
|
return '';
|
|
}
|
|
$userinfo = $this->formatComponent($this->getUserInfo(), '', '@');
|
|
$port = $this->formatComponent($this->getPort($flags), ':');
|
|
return "{$userinfo}{$host}{$port}";
|
|
}
|
|
|
|
/**
|
|
* Retrieve the user information component of the URI.
|
|
* @return string The URI user information, in "username[:password]" format
|
|
*/
|
|
public function getUserInfo(int $flags = 0): string
|
|
{
|
|
$user = $this->formatComponent($this->getComponent('user'));
|
|
$pass = $this->formatComponent($this->getComponent('pass'), ':');
|
|
return $this->isEmpty($user) ? '' : "{$user}{$pass}";
|
|
}
|
|
|
|
/**
|
|
* Retrieve the host component of the URI.
|
|
* @return string The URI host
|
|
*/
|
|
public function getHost(int $flags = 0): string
|
|
{
|
|
$host = $this->getComponent('host') ?? '';
|
|
if ($flags & self::IDNA) {
|
|
$host = $this->idna($host);
|
|
}
|
|
return $host;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the port component of the URI.
|
|
* @return null|int The URI port
|
|
*/
|
|
public function getPort(int $flags = 0): ?int
|
|
{
|
|
$port = $this->getComponent('port');
|
|
$scheme = $this->getComponent('scheme');
|
|
$default = isset(self::$port_defaults[$scheme]) ? self::$port_defaults[$scheme] : null;
|
|
if ($flags & self::REQUIRE_PORT) {
|
|
return !$this->isEmpty($port) ? $port : $default;
|
|
}
|
|
return $this->isEmpty($port) || $port === $default ? null : $port;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the path component of the URI.
|
|
* @return string The URI path
|
|
*/
|
|
public function getPath(int $flags = 0): string
|
|
{
|
|
$path = $this->getComponent('path') ?? '';
|
|
if ($flags & self::NORMALIZE_PATH) {
|
|
$path = $this->normalizePath($path);
|
|
}
|
|
if ($flags & self::ABSOLUTE_PATH && substr($path, 0, 1) !== '/') {
|
|
$path = "/{$path}";
|
|
}
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the query string of the URI.
|
|
* @return string The URI query string
|
|
*/
|
|
public function getQuery(int $flags = 0): string
|
|
{
|
|
return $this->getComponent('query') ?? '';
|
|
}
|
|
|
|
/**
|
|
* Retrieve the fragment component of the URI.
|
|
* @return string The URI fragment
|
|
*/
|
|
public function getFragment(int $flags = 0): string
|
|
{
|
|
return $this->getComponent('fragment') ?? '';
|
|
}
|
|
|
|
|
|
// ---------- PSR-7 setters ---------------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Return an instance with the specified scheme.
|
|
* @param string $scheme The scheme to use with the new instance
|
|
* @return static A new instance with the specified scheme
|
|
* @throws \InvalidArgumentException for invalid schemes
|
|
* @throws \InvalidArgumentException for unsupported schemes
|
|
*/
|
|
public function withScheme($scheme, int $flags = 0): UriInterface
|
|
{
|
|
$clone = clone $this;
|
|
if ($flags & self::REQUIRE_PORT) {
|
|
$clone->setComponent('port', $this->getPort(self::REQUIRE_PORT));
|
|
$default = isset(self::$port_defaults[$scheme]) ? self::$port_defaults[$scheme] : null;
|
|
}
|
|
$clone->setComponent('scheme', $scheme);
|
|
return $clone;
|
|
}
|
|
|
|
/**
|
|
* Return an instance with the specified user information.
|
|
* @param string $user The user name to use for authority
|
|
* @param null|string $password The password associated with $user
|
|
* @return static A new instance with the specified user information
|
|
*/
|
|
public function withUserInfo($user, $password = null, int $flags = 0): UriInterface
|
|
{
|
|
$clone = clone $this;
|
|
$clone->setComponent('user', $user);
|
|
$clone->setComponent('pass', $password);
|
|
return $clone;
|
|
}
|
|
|
|
/**
|
|
* Return an instance with the specified host.
|
|
* @param string $host The hostname to use with the new instance
|
|
* @return static A new instance with the specified host
|
|
* @throws \InvalidArgumentException for invalid hostnames
|
|
*/
|
|
public function withHost($host, int $flags = 0): UriInterface
|
|
{
|
|
$clone = clone $this;
|
|
if ($flags & self::IDNA) {
|
|
$host = $this->idna($host);
|
|
}
|
|
$clone->setComponent('host', $host);
|
|
return $clone;
|
|
}
|
|
|
|
/**
|
|
* Return an instance with the specified port.
|
|
* @param null|int $port The port to use with the new instance
|
|
* @return static A new instance with the specified port
|
|
* @throws \InvalidArgumentException for invalid ports
|
|
*/
|
|
public function withPort($port, int $flags = 0): UriInterface
|
|
{
|
|
$clone = clone $this;
|
|
$clone->setComponent('port', $port);
|
|
return $clone;
|
|
}
|
|
|
|
/**
|
|
* Return an instance with the specified path.
|
|
* @param string $path The path to use with the new instance
|
|
* @return static A new instance with the specified path
|
|
* @throws \InvalidArgumentException for invalid paths
|
|
*/
|
|
public function withPath($path, int $flags = 0): UriInterface
|
|
{
|
|
$clone = clone $this;
|
|
if ($flags & self::NORMALIZE_PATH) {
|
|
$path = $this->normalizePath($path);
|
|
}
|
|
if ($flags & self::ABSOLUTE_PATH && substr($path, 0, 1) !== '/') {
|
|
$path = "/{$path}";
|
|
}
|
|
$clone->setComponent('path', $path);
|
|
return $clone;
|
|
}
|
|
|
|
/**
|
|
* Return an instance with the specified query string.
|
|
* @param string $query The query string to use with the new instance
|
|
* @return static A new instance with the specified query string
|
|
* @throws \InvalidArgumentException for invalid query strings
|
|
*/
|
|
public function withQuery($query, int $flags = 0): UriInterface
|
|
{
|
|
$clone = clone $this;
|
|
$clone->setComponent('query', $query);
|
|
return $clone;
|
|
}
|
|
|
|
/**
|
|
* Return an instance with the specified URI fragment.
|
|
* @param string $fragment The fragment to use with the new instance
|
|
* @return static A new instance with the specified fragment
|
|
*/
|
|
public function withFragment($fragment, int $flags = 0): UriInterface
|
|
{
|
|
$clone = clone $this;
|
|
$clone->setComponent('fragment', $fragment);
|
|
return $clone;
|
|
}
|
|
|
|
|
|
// ---------- PSR-7 string ----------------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Return the string representation as a URI reference.
|
|
* @return string
|
|
*/
|
|
public function __toString(): string
|
|
{
|
|
return $this->toString();
|
|
}
|
|
|
|
|
|
// ---------- Extensions ------------------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Return the string representation as a URI reference.
|
|
* @return string
|
|
*/
|
|
public function toString(int $flags = 0): string
|
|
{
|
|
$scheme = $this->formatComponent($this->getComponent('scheme'), '', ':');
|
|
$authority = $this->authority ? "//{$this->formatComponent($this->getAuthority($flags))}" : '';
|
|
$path_flags = ($this->authority && $this->path ? self::ABSOLUTE_PATH : 0) | $flags;
|
|
$path = $this->formatComponent($this->getPath($path_flags));
|
|
$query = $this->formatComponent($this->getComponent('query'), '?');
|
|
$fragment = $this->formatComponent($this->getComponent('fragment'), '#');
|
|
return "{$scheme}{$authority}{$path}{$query}{$fragment}";
|
|
}
|
|
|
|
|
|
// ---------- Private helper methods ------------------------------------------------------------------------------
|
|
|
|
private function parse(string $uri_string = ''): void
|
|
{
|
|
if ($uri_string === '') {
|
|
return;
|
|
}
|
|
preg_match(self::RE_MAIN, $uri_string, $main);
|
|
$this->authority = !empty($main['authorityc']);
|
|
$this->setComponent('scheme', isset($main['schemec']) ? $main['scheme'] : '');
|
|
$this->setComponent('path', isset($main['path']) ? $main['path'] : '');
|
|
$this->setComponent('query', isset($main['queryc']) ? $main['query'] : '');
|
|
$this->setComponent('fragment', isset($main['fragmentc']) ? $main['fragment'] : '');
|
|
if ($this->authority) {
|
|
preg_match(self::RE_AUTH, $main['authority'], $auth);
|
|
if (empty($auth) && $main['authority'] !== '') {
|
|
throw new InvalidArgumentException("Invalid 'authority'.");
|
|
}
|
|
if ($this->isEmpty($auth['host']) && !$this->isEmpty($auth['user'])) {
|
|
throw new InvalidArgumentException("Invalid 'authority'.");
|
|
}
|
|
$this->setComponent('user', isset($auth['user']) ? $auth['user'] : '');
|
|
$this->setComponent('pass', isset($auth['passc']) ? $auth['pass'] : '');
|
|
$this->setComponent('host', isset($auth['host']) ? $auth['host'] : '');
|
|
$this->setComponent('port', isset($auth['portc']) ? $auth['port'] : '');
|
|
}
|
|
}
|
|
|
|
private function encode(string $source, string $keep = ''): string
|
|
{
|
|
$exclude = "[^%\/:=&!\$'()*+,;@{$keep}]+";
|
|
$exp = "/(%{$exclude})|({$exclude})/";
|
|
return preg_replace_callback($exp, function ($matches) {
|
|
if ($e = preg_match('/^(%[0-9a-fA-F]{2})/', $matches[0], $m)) {
|
|
return substr($matches[0], 0, 3) . rawurlencode(substr($matches[0], 3));
|
|
} else {
|
|
return rawurlencode($matches[0]);
|
|
}
|
|
}, $source);
|
|
}
|
|
|
|
private function setComponent(string $component, $value): void
|
|
{
|
|
$value = $this->parseCompontent($component, $value);
|
|
$this->$component = $value;
|
|
}
|
|
|
|
private function parseCompontent(string $component, $value)
|
|
{
|
|
if ($this->isEmpty($value)) {
|
|
return null;
|
|
}
|
|
switch ($component) {
|
|
case 'scheme':
|
|
$this->assertString($component, $value);
|
|
$this->assertpattern($component, $value, '/^[a-z][a-z0-9-+.]*$/i');
|
|
return mb_strtolower($value);
|
|
case 'host': // IP-literal / IPv4address / reg-name
|
|
$this->assertString($component, $value);
|
|
$this->authority = $this->authority || !$this->isEmpty($value);
|
|
return mb_strtolower($value);
|
|
case 'port':
|
|
$this->assertInteger($component, $value);
|
|
if ($value < 0 || $value > 65535) {
|
|
throw new InvalidArgumentException("Invalid port number");
|
|
}
|
|
return (int)$value;
|
|
case 'path':
|
|
$this->assertString($component, $value);
|
|
$value = $this->encode($value);
|
|
return $value;
|
|
case 'user':
|
|
case 'pass':
|
|
case 'query':
|
|
case 'fragment':
|
|
$this->assertString($component, $value);
|
|
$value = $this->encode($value, '?');
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
private function getComponent(string $component)
|
|
{
|
|
return isset($this->$component) ? $this->$component : null;
|
|
}
|
|
|
|
private function formatComponent($value, string $before = '', string $after = ''): string
|
|
{
|
|
return $this->isEmpty($value) ? '' : "{$before}{$value}{$after}";
|
|
}
|
|
|
|
private function isEmpty($value): bool
|
|
{
|
|
return is_null($value) || $value === '';
|
|
}
|
|
|
|
private function assertString(string $component, $value): void
|
|
{
|
|
if (!is_string($value)) {
|
|
throw new InvalidArgumentException("Invalid '{$component}': Should be a string");
|
|
}
|
|
}
|
|
|
|
private function assertInteger(string $component, $value): void
|
|
{
|
|
if (!is_numeric($value) || intval($value) != $value) {
|
|
throw new InvalidArgumentException("Invalid '{$component}': Should be an integer");
|
|
}
|
|
}
|
|
|
|
private function assertPattern(string $component, string $value, string $pattern): void
|
|
{
|
|
if (preg_match($pattern, $value) == 0) {
|
|
throw new InvalidArgumentException("Invalid '{$component}': Should match {$pattern}");
|
|
}
|
|
}
|
|
|
|
private function normalizePath(string $path): string
|
|
{
|
|
$result = [];
|
|
preg_match_all('!([^/]*/|[^/]*$)!', $path, $items);
|
|
foreach ($items[0] as $item) {
|
|
switch ($item) {
|
|
case '':
|
|
case './':
|
|
case '.':
|
|
break; // just skip
|
|
case '/':
|
|
if (empty($result)) {
|
|
array_push($result, $item); // add
|
|
}
|
|
break;
|
|
case '..':
|
|
case '../':
|
|
if (empty($result) || end($result) == '../') {
|
|
array_push($result, $item); // add
|
|
} else {
|
|
array_pop($result); // remove previous
|
|
}
|
|
break;
|
|
default:
|
|
array_push($result, $item); // add
|
|
}
|
|
}
|
|
return implode('', $result);
|
|
}
|
|
|
|
private function idna(string $value): string
|
|
{
|
|
if ($value === '' || !is_callable('idn_to_ascii')) {
|
|
return $value; // Can't convert, but don't cause exception
|
|
}
|
|
return idn_to_ascii($value, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
|
|
}
|
|
}
|