* @link https://getkirby.com * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ class Uri implements Stringable { /** * Cache for the current Uri object */ public static Uri|null $current = null; /** * The fragment after the hash */ protected string|false|null $fragment; /** * The host address */ protected string|null $host; /** * The optional password for basic authentication */ protected string|false|null $password; /** * The optional list of params */ protected Params $params; /** * The optional path */ protected Path $path; /** * The optional port number */ protected int|false|null $port; /** * All original properties */ protected array $props; /** * The optional query string without leading ? */ protected Query $query; /** * https or http */ protected string|null $scheme; /** * Supported schemes */ protected static array $schemes = ['http', 'https', 'ftp']; protected bool $slash; /** * The optional username for basic authentication */ protected string|false|null $username = null; /** * Creates a new URI object * * @param array $inject Additional props to inject if a URL string is passed */ public function __construct(array|string $props = [], array $inject = []) { if (is_string($props) === true) { // make sure the URL parser works properly when there's a // colon in the string but the string is a relative URL if (Url::isAbsolute($props) === false) { $props = 'https://getkirby.com/' . $props; $props = parse_url($props); unset($props['scheme'], $props['host']); } else { $props = parse_url($props); } $props['username'] = $props['user'] ?? null; $props['password'] = $props['pass'] ?? null; $props = [...$props, ...$inject]; } // parse the path and extract params if (empty($props['path']) === false) { $props = static::parsePath($props); } $this->props = $props; $this->setFragment($props['fragment'] ?? null); $this->setHost($props['host'] ?? null); $this->setParams($props['params'] ?? null); $this->setPassword($props['password'] ?? null); $this->setPath($props['path'] ?? null); $this->setPort($props['port'] ?? null); $this->setQuery($props['query'] ?? null); $this->setScheme($props['scheme'] ?? 'http'); $this->setSlash($props['slash'] ?? false); $this->setUsername($props['username'] ?? null); } /** * Magic caller to access all properties */ public function __call(string $property, array $arguments = []) { return $this->$property ?? null; } /** * Make sure that cloning also clones * the path and query objects */ public function __clone() { $this->path = clone $this->path; $this->query = clone $this->query; $this->params = clone $this->params; } /** * Magic getter */ public function __get(string $property) { return $this->$property ?? null; } /** * Magic setter */ public function __set(string $property, $value): void { if (method_exists($this, 'set' . $property) === true) { $this->{'set' . $property}($value); } } /** * Converts the URL object to string */ public function __toString(): string { try { return $this->toString(); } catch (Throwable) { return ''; } } /** * Returns the auth details (username:password) */ public function auth(): string|null { $auth = trim($this->username . ':' . $this->password); return $auth !== ':' ? $auth : null; } /** * Returns the base url (scheme + host) * without trailing slash */ public function base(): string|null { if ($domain = $this->domain()) { return $this->scheme ? $this->scheme . '://' . $domain : $domain; } return null; } /** * Clones the Uri object and applies optional * new props. */ public function clone(array $props = []): static { $clone = clone $this; foreach ($props as $key => $value) { $clone->__set($key, $value); } return $clone; } public static function current(array $props = []): static { if (static::$current !== null) { return static::$current; } if ($app = App::instance(null, true)) { $environment = $app->environment(); } $environment ??= new Environment(); return new static($environment->requestUrl(), $props); } /** * Returns the domain without scheme, path or query. * Includes auth part when not empty. * Includes port number when different from 80 or 443. */ public function domain(): string|null { if ($this->host === null || $this->host === '' || $this->host === '/') { return null; } $auth = $this->auth(); $domain = ''; if ($auth !== null) { $domain .= $auth . '@'; } $domain .= $this->host; if ( $this->port !== null && in_array($this->port, [80, 443], true) === false ) { $domain .= ':' . $this->port; } return $domain; } public function hasFragment(): bool { return $this->fragment !== null && $this->fragment !== ''; } public function hasPath(): bool { return $this->path()->isNotEmpty(); } public function hasQuery(): bool { return $this->query()->isNotEmpty(); } public function https(): bool { return $this->scheme() === 'https'; } /** * Tries to convert the internationalized host * name to the human-readable UTF8 representation * * @return $this */ public function idn(): static { if ($this->isAbsolute() === true) { $host = Idn::decode($this->host); $this->setHost($host); } return $this; } /** * Creates an Uri object for the URL to the index.php * or any other executed script. */ public static function index(array $props = []): static { if ($app = App::instance(null, true)) { $url = $app->url('index'); } $url ??= (new Environment())->baseUrl(); return new static($url, $props); } /** * Inherit query, params and fragment from a parent Uri * @since 5.2.0 * @return $this */ public function inherit(Uri|string $parent): static { if (is_string($parent) === true) { $parent = new static($parent); } $this->query->merge($parent->query()); $this->params->merge($parent->params()); if ($fragment = $parent->fragment()) { $this->setFragment($fragment); } return $this; } /** * Checks if the host exists */ public function isAbsolute(): bool { return $this->host !== null && $this->host !== ''; } /** * Returns the fragment after the hash * @since 5.1.0 */ public function fragment(): string|null { return $this->fragment; } /** * @return $this */ public function setFragment(string|null $fragment = null): static { $this->fragment = $fragment ? ltrim($fragment, '#') : null; return $this; } /** * @return $this */ public function setHost(string|null $host = null): static { $this->host = $host; return $this; } /** * @return $this */ public function setParams(Params|string|array|false|null $params = null): static { // ensure that the special constructor value of `false` // is never passed through as it's not supported by `Params` if ($params === false) { $params = []; } $this->params = $params instanceof Params ? $params : new Params($params); return $this; } /** * @return $this */ public function setPassword( #[SensitiveParameter] string|null $password = null ): static { $this->password = $password; return $this; } /** * @return $this */ public function setPath(Path|string|array|null $path = null): static { $this->path = $path instanceof Path ? $path : new Path($path); return $this; } /** * @return $this */ public function setPort(int|null $port = null): static { if ($port === 0) { $port = null; } if ($port !== null) { if ($port < 1 || $port > 65535) { throw new InvalidArgumentException( message: 'Invalid port format: ' . $port ); } } $this->port = $port; return $this; } /** * @return $this */ public function setQuery(Query|string|array|null $query = null): static { $this->query = $query instanceof Query ? $query : new Query($query); return $this; } /** * @return $this */ public function setScheme(string|null $scheme = null): static { if ( $scheme !== null && in_array($scheme, static::$schemes, true) === false ) { throw new InvalidArgumentException( message: 'Invalid URL scheme: ' . $scheme ); } $this->scheme = $scheme; return $this; } /** * Set if a trailing slash should be added to * the path when the URI is being built * * @return $this */ public function setSlash(bool $slash = false): static { $this->slash = $slash; return $this; } /** * @return $this */ public function setUsername(string|null $username = null): static { $this->username = $username; return $this; } /** * Converts the Url object to an array */ public function toArray(): array { $array = []; foreach ($this->props as $key => $value) { $value = $this->$key; if (is_object($value) === true) { $value = $value->toArray(); } $array[$key] = $value; } return $array; } public function toJson(...$arguments): string { return json_encode($this->toArray(), ...$arguments); } /** * Returns the full URL as string */ public function toString(): string { $url = $this->base(); $slash = true; if ($url === null || $url === '') { $url = '/'; $slash = false; } $path = $this->path->toString($slash) . $this->params->toString(true); if ($this->slash && ($path !== '' || $slash === true)) { $path .= '/'; } $url .= $path; $url .= $this->query->toString(true); if ($this->hasFragment() === true) { $url .= '#' . $this->fragment(); } return $url; } /** * Tries to convert a URL with an internationalized host * name to the machine-readable Punycode representation * * @return $this */ public function unIdn(): static { if ($this->isAbsolute() === true) { $host = Idn::encode($this->host); $this->setHost($host); } return $this; } /** * Parses the path inside the props and extracts * the params unless disabled * * @return array Modified props array */ protected static function parsePath(array $props): array { // extract params, the rest is the path; // only do this if not explicitly disabled (set to `false`) if (isset($props['params']) === false || $props['params'] !== false) { $extract = Params::extract($props['path']); $props['params'] ??= $extract['params']; $props['path'] = $extract['path']; $props['slash'] ??= $extract['slash']; return $props; } // use the full path; // automatically detect the trailing slash from it if possible if (is_string($props['path']) === true) { $props['slash'] = str_ends_with($props['path'], '/') === true; } return $props; } }