$query */ private function __construct( public readonly string $path, public readonly array $query, public readonly string $host, public readonly string $scheme ) {} public static function parse( string $input ): self { return self::read( $input, false ); } /** * A visitor's request, which is always a path — even one that starts "//". * * Read as a typed address, "//old///page" would be a protocol-relative link to a * website called "old", and the request would be matched against "/page". A request * line has no host in it, so here the doubled slash is just a doubled slash. */ public static function from_request( string $uri ): self { return self::read( $uri, true ); } private static function read( string $input, bool $path_only ): self { $input = trim( $input ); $hash = strpos( $input, '#' ); if ( false !== $hash ) { $input = substr( $input, 0, $hash ); } $host = ''; $scheme = ''; if ( ! $path_only && preg_match( '#^([a-z][a-z0-9+.\-]*:)?//#i', $input ) ) { $parts = wp_parse_url( str_starts_with( $input, '//' ) ? 'http:' . $input : $input ); $host = strtolower( (string) ( $parts['host'] ?? '' ) ); $scheme = str_starts_with( $input, '//' ) ? '' : strtolower( (string) ( $parts['scheme'] ?? '' ) ); if ( isset( $parts['port'] ) ) { $host .= ':' . $parts['port']; } $path = (string) ( $parts['path'] ?? '/' ); $query = (string) ( $parts['query'] ?? '' ); } else { $question = strpos( $input, '?' ); $path = false === $question ? $input : substr( $input, 0, $question ); $query = false === $question ? '' : substr( $input, $question + 1 ); } return new self( self::clean_path( $path ), self::parse_query( $query ), $host, $scheme ); } public static function clean_path( string $path ): string { $path = rawurldecode( $path ); // Whitespace and control characters cannot be part of a real request path, and a // stray space pasted with an address would otherwise make it unmatchable. $path = (string) preg_replace( '/[\x00-\x1F\x7F]/u', '', trim( $path ) ); $path = '/' . ltrim( $path, '/' ); $path = (string) preg_replace( '#/{2,}#', '/', $path ); if ( '/' !== $path ) { $path = rtrim( $path, '/' ); } return '' === $path ? '/' : $path; } /** * Our own parser, because parse_str() rewrites keys — "utm.source" comes back as * "utm_source" — and a redirect for one would then match the other. * * @return array */ public static function parse_query( string $query ): array { $out = []; foreach ( explode( '&', $query ) as $pair ) { if ( '' === $pair ) { continue; } [ $key, $value ] = array_pad( explode( '=', $pair, 2 ), 2, '' ); $key = urldecode( $key ); if ( '' !== $key ) { $out[ $key ] = urldecode( $value ); } } return $out; } /** The folded copy every comparison uses. */ public function key( bool $case_sensitive = false ): string { return $case_sensitive ? $this->path : mb_strtolower( $this->path ); } /** What the exact-match lookup is indexed on: always case-folded, never the query. */ public function hash(): string { return md5( mb_strtolower( $this->path ) ); } public function query_string(): string { if ( [] === $this->query ) { return ''; } $pairs = []; foreach ( $this->query as $key => $value ) { $pairs[] = rawurlencode( (string) $key ) . ( '' === $value ? '' : '=' . rawurlencode( $value ) ); } return implode( '&', $pairs ); } /** The path and query as one string, the way it is stored and shown. */ public function relative(): string { $query = $this->query_string(); return $this->path . ( '' === $query ? '' : '?' . $query ); } public function has_host(): bool { return '' !== $this->host; } /** * Whether this address belongs to the site. * * www and bare domain are treated as one: a site reachable at both is one site, and * somebody pasting https://www.example.com/old into a site configured as * example.com means their own page. */ public function is_local(): bool { if ( ! $this->has_host() ) { return true; } return in_array( self::bare_host( $this->host ), self::site_hosts(), true ); } /** @return list */ public static function site_hosts(): array { $hosts = []; foreach ( [ home_url(), site_url() ] as $url ) { $parts = wp_parse_url( $url ); $host = strtolower( (string) ( $parts['host'] ?? '' ) ); if ( isset( $parts['port'] ) ) { $host .= ':' . $parts['port']; } $hosts[] = self::bare_host( $host ); } /** * Filter the host names treated as this site — for a site served on more than * one domain. * * @param list $hosts Without "www.". */ return array_values( array_unique( (array) apply_filters( 'codebold_redirect_site_hosts', $hosts ) ) ); } private static function bare_host( string $host ): string { return str_starts_with( $host, 'www.' ) ? substr( $host, 4 ) : $host; } /** * Whether every parameter this address names is present, with the same value, in * another. Extra parameters on the other side are fine: a redirect for * /shop?ref=spring should still fire for /shop?ref=spring&utm_source=mail. * * @param array $request */ public function query_within( array $request ): bool { foreach ( $this->query as $key => $value ) { if ( ! array_key_exists( $key, $request ) || (string) $request[ $key ] !== (string) $value ) { return false; } } return true; } }