>|null $trace Filled with what was checked and why, for the tester. */ public function resolve( Address $request, Visitor $visitor, string $phase = 'early', ?array &$trace = null ): ?Outcome { $hits = []; $visited = []; $current = $request; $url = null; for ( $hop = 0; $hop < self::MAX_HOPS; $hop++ ) { $hop_trace = []; $hit = $this->first( $current, $visitor, 0 === $hop ? $phase : 'early', $hop_trace ); if ( null !== $trace ) { $trace[] = [ 'address' => $current->relative(), 'checked' => $hop_trace ]; } if ( null === $hit ) { break; } if ( in_array( $hit->rule->id, $visited, true ) ) { return new Outcome( $hits, null, 0, true ); } $visited[] = $hit->rule->id; // 410 and 451 answer where they are. As the first rule that is the answer; part // way down a chain, the visitor is sent to that address and gets it there. if ( ! $hit->rule->moves() ) { if ( 0 === $hop ) { return new Outcome( [ $hit ], null, $hit->rule->status_code, false ); } break; } $hits[] = $hit; $url = $hit->url; if ( null === $url ) { break; } $next = Address::parse( $url ); if ( ! $next->is_local() ) { break; } $current = $next; } if ( [] === $hits || null === $url ) { return null; } // Sending somebody to the page they are already on is a loop the browser will // report as "too many redirects" — whatever the rules say, never do it. $final = Address::parse( $url ); if ( $final->is_local() && $final->key() === $request->key() && $final->query === $request->query ) { return new Outcome( $hits, null, 0, true ); } return new Outcome( $hits, $url, $this->code( $hits ), false ); } /** * The status for a flattened chain. * * The first rule's, unless that would promise more than the chain can keep: a * permanent redirect is cached by browsers, so if any later step is temporary the * visitor's browser must not remember the end of the chain as for ever. In that case * the answer is the temporary code matching the first rule's method behaviour. * * @param list $hits */ private function code( array $hits ): int { $first = $hits[0]->rule->status_code; foreach ( $hits as $hit ) { if ( ! $hit->rule->is_permanent() ) { return match ( $first ) { 308 => 307, 301 => 302, default => $first, }; } } return $first; } /** * The first rule that applies to an address, with the target worked out. * * @param list> $trace */ public function first( Address $request, Visitor $visitor, string $phase = 'early', array &$trace = [] ): ?Hit { foreach ( $this->candidates( $request ) as $rule ) { $captures = $this->matches( $rule, $request ); if ( null === $captures ) { continue; } $step = [ 'id' => $rule->id, 'source' => $rule->source, 'match_type' => $rule->match_type, ]; $wants_404 = $rule->has_condition( 'not_found' ); if ( ( 'early' === $phase && $wants_404 ) || ( 'late' === $phase && ! $wants_404 ) ) { $trace[] = $step + [ 'result' => 'not_found_only' ]; continue; } if ( ! $rule->in_schedule() ) { $trace[] = $step + [ 'result' => 'schedule' ]; continue; } $check = $this->conditions->evaluate( $rule, $visitor ); if ( ! $check['pass'] ) { $trace[] = $step + [ 'result' => 'conditions', 'conditions' => $check['results'] ]; continue; } $url = $rule->moves() ? $this->target( $rule, $captures, $request ) : null; if ( $rule->moves() && null === $url ) { $trace[] = $step + [ 'result' => 'no_target' ]; continue; } $trace[] = $step + [ 'result' => 'matched', 'conditions' => $check['results'], 'url' => $url ]; return new Hit( $rule, $captures, $url ); } return null; } /** * @return list */ private function candidates( Address $request ): array { $exact = $this->repository->exact( $request ); usort( $exact, static function ( Rule $a, Rule $b ): int { $qa = str_contains( $a->source, '?' ) ? 0 : 1; $qb = str_contains( $b->source, '?' ) ? 0 : 1; return [ $qa, $a->priority, $a->id ] <=> [ $qb, $b->priority, $b->id ]; } ); return array_merge( $exact, $this->repository->patterns() ); } /** * Whether a rule's source matches an address. Returns the captured pieces — empty for * an exact rule — or null for no match. * * @return list|null */ public function matches( Rule $rule, Address $request ): ?array { switch ( $rule->match_type ) { case Rule::EXACT: $source = Address::parse( $rule->source ); if ( $source->key( $rule->case_sensitive ) !== $request->key( $rule->case_sensitive ) ) { return null; } return $source->query_within( $request->query ) ? [] : null; case Rule::WILDCARD: $source = Address::parse( $rule->source ); if ( 1 !== preg_match( self::wildcard_pattern( $source->path, $rule->case_sensitive ), $request->path, $m ) ) { return null; } return $source->query_within( $request->query ) ? self::captures( $m ) : null; case Rule::REGEX: $pattern = Conditions::delimit( $rule->source, $rule->case_sensitive ); // Silenced: the pattern was compiled on save, but a PCRE limit can still be // hit by an unlucky address, and that must cost a non-match, not a warning // on somebody's front page. if ( 1 === @preg_match( $pattern, $request->path, $m ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors return self::captures( $m ); } // Matched against the path first, so "^/old/(.*)$" behaves as people expect; // then with the query, for the patterns that are written to look at it. $query = $request->query_string(); if ( '' !== $query && 1 === @preg_match( $pattern, $request->path . '?' . $query, $m ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors return self::captures( $m ); } return null; } return null; } /** * A wildcard address, as a regular expression. * * "*" is "anything, including slashes". A trailing "/*" also matches the folder * itself, because "/blog/*" is how people write "the blog and everything in it", and a * rule that redirected every post but left /blog behind would look broken. */ public static function wildcard_pattern( string $path, bool $case_sensitive ): string { $tail = ''; if ( str_ends_with( $path, '/*' ) ) { $path = substr( $path, 0, -2 ); $tail = '(?:/(.*))?'; } $pieces = array_map( static fn( string $p ): string => preg_quote( $p, '@' ), explode( '*', $path ) ); return '@^' . implode( '(.*)', $pieces ) . $tail . '$@u' . ( $case_sensitive ? '' : 'i' ); } /** * @param array $m * @return list */ private static function captures( array $m ): array { $out = []; foreach ( $m as $key => $value ) { if ( is_int( $key ) && $key > 0 ) { $out[] = (string) $value; } } return $out; } /** * The address a rule sends this request to. * * @param list $captures */ public function target( Rule $rule, array $captures, Address $request ): ?string { if ( 'post' === $rule->target_type && $rule->target_id > 0 ) { $url = self::post_url( $rule->target_id ) ?? ( '' !== $rule->target ? $rule->target : null ); } elseif ( 'term' === $rule->target_type && $rule->target_id > 0 ) { $link = get_term_link( $rule->target_id ); $url = is_string( $link ) ? $link : ( '' !== $rule->target ? $rule->target : null ); } else { $url = '' !== $rule->target ? self::substitute( $rule, $captures ) : null; } if ( null === $url ) { return null; } $url = self::absolute( $url ); if ( $rule->pass_query && [] !== $request->query ) { $url = self::carry_query( $url, $request, $rule ); } return $url; } /** * A linked page's current address — which is the point of linking rather than * typing: rename the page again and the redirect follows it. * * Only while it is published. A draft or trashed page's permalink is an address * nobody can see, and sending visitors there trades one 404 for another; the * address the page last had is used instead. */ public static function post_url( int $post_id ): ?string { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return null; } $visible = 'publish' === $post->post_status || ( 'attachment' === $post->post_type && 'inherit' === $post->post_status ); if ( ! $visible ) { return null; } $url = get_permalink( $post ); return is_string( $url ) && '' !== $url ? $url : null; } /** * Put captured pieces into the destination. * * For a wildcard, each "*" in the destination takes the next "*" from the source, in * order. Both kinds also accept $1, $2… for anybody who wants to reorder them. * * @param list $captures */ private static function substitute( Rule $rule, array $captures ): string { $target = $rule->target; $relative = str_starts_with( $target, '/' ) && ! str_starts_with( $target, '//' ); // One pass for both forms, so a captured piece is inserted once and never itself // read as a placeholder — an address containing "$1" or "*" stays as it was typed. // // Run even when nothing was captured: "/blog/*" matching "/blog" itself captures // nothing, and its "/news/*" must become "/news/", not a literal asterisk. if ( Rule::EXACT !== $rule->match_type ) { $wildcard = Rule::WILDCARD === $rule->match_type; $next = 0; $target = (string) preg_replace_callback( $wildcard ? '/\$\{?(\d)\}?|\*/' : '/\$\{?(\d)\}?/', static function ( array $m ) use ( $captures, &$next ): string { if ( '*' === $m[0] ) { return $captures[ $next++ ] ?? ''; } return $captures[ (int) $m[1] - 1 ] ?? ''; }, $target ); } // The open-redirect guard. With a source of "/go/*" and a destination of "/*", a // request for "/go//evil.example" would otherwise produce "//evil.example" — which // every browser reads as a different website. A destination that began as a path // on this site stays a path on this site, whatever the visitor typed. if ( $relative ) { $target = '/' . ltrim( $target, '/\\' ); } return $target; } /** "/new-page" becomes a full address on this site; anything else is left alone. */ public static function absolute( string $url ): string { if ( str_starts_with( $url, '/' ) && ! str_starts_with( $url, '//' ) ) { $home = wp_parse_url( home_url() ); $origin = ( $home['scheme'] ?? 'https' ) . '://' . ( $home['host'] ?? '' ) . ( isset( $home['port'] ) ? ':' . $home['port'] : '' ); return $origin . $url; } return $url; } /** * Keep the visitor's query string on the way through — campaign tags above all, * which marketing will notice going missing long before anybody else does. * * Parameters the rule's own source named are not passed on: they were how the rule * was chosen, not something the visitor was carrying. And the destination's own * parameters win over the visitor's. */ private static function carry_query( string $url, Address $request, Rule $rule ): string { $named = Rule::REGEX === $rule->match_type ? [] : Address::parse( $rule->source )->query; $hash = ''; $fragment = strpos( $url, '#' ); if ( false !== $fragment ) { $hash = substr( $url, $fragment ); $url = substr( $url, 0, $fragment ); } $existing = Address::parse_query( (string) wp_parse_url( $url, PHP_URL_QUERY ) ); $add = []; foreach ( $request->query as $key => $value ) { if ( array_key_exists( $key, $named ) || array_key_exists( $key, $existing ) ) { continue; } $add[] = rawurlencode( (string) $key ) . ( '' === $value ? '' : '=' . rawurlencode( $value ) ); } if ( [] === $add ) { return $url . $hash; } return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . implode( '&', $add ) . $hash; } }