$input * @return array{rule: Rule, warnings: list}>}|WP_Error */ public function prepare( array $input, ?Rule $existing = null, bool $quiet = false ): array|WP_Error { $rule = $existing ? clone $existing : $this->fresh(); $warnings = []; $this->quiet = $quiet; foreach ( [ 'source', 'target', 'group_name', 'note', 'origin', 'match_type', 'target_type' ] as $key ) { if ( array_key_exists( $key, $input ) ) { $rule->{$key} = trim( (string) $input[ $key ] ); } } foreach ( [ 'target_id', 'status_code', 'priority', 'post_id', 'hits' ] as $key ) { if ( array_key_exists( $key, $input ) ) { $rule->{$key} = (int) $input[ $key ]; } } foreach ( [ 'case_sensitive', 'pass_query', 'enabled' ] as $key ) { if ( array_key_exists( $key, $input ) ) { $rule->{$key} = filter_var( $input[ $key ], FILTER_VALIDATE_BOOLEAN ); } } if ( array_key_exists( 'last_hit', $input ) ) { $rule->last_hit = self::utc( $input['last_hit'], true ); } /* ---- What happens ---------------------------------------------- */ if ( ! in_array( $rule->status_code, Rule::CODES, true ) ) { return new WP_Error( 'codebold_bad_code', __( 'Choose what kind of redirect this is: 301, 302, 307, 308, 410 or 451.', 'codebold-redirects' ) ); } if ( ! in_array( $rule->match_type, [ Rule::EXACT, Rule::WILDCARD, Rule::REGEX ], true ) ) { $rule->match_type = Rule::EXACT; } if ( ! in_array( $rule->origin, Rule::ORIGINS, true ) ) { $rule->origin = 'manual'; } $rule->priority = max( -1000, min( 1000, $rule->priority ) ); $rule->hits = max( 0, $rule->hits ); // Group and note are kept as the person typed them — "sale" stays exactly // that, shown as text — rather than having anything that looks like markup quietly // stripped. Only what cannot be text is removed, and if anything was, they are told. foreach ( [ 'group_name' => 100, 'note' => 255 ] as $field => $max ) { $typed = $rule->{$field}; $clean = Rule::plain_text( $typed, $max ); $rule->{$field} = $clean; if ( $clean !== trim( $typed ) && ! $this->quiet ) { $warnings[] = [ 'code' => 'text_changed', 'message' => 'note' === $field /* translators: %d: maximum length */ ? sprintf( __( 'The note was shortened to %d characters, and any line breaks or invisible characters were removed.', 'codebold-redirects' ), $max ) /* translators: %d: maximum length */ : sprintf( __( 'The group name was shortened to %d characters, and any line breaks or invisible characters were removed.', 'codebold-redirects' ), $max ), ]; } } /* ---- From ------------------------------------------------------ */ $source = $this->source( $rule, $warnings ); if ( is_wp_error( $source ) ) { return $source; } /* ---- To -------------------------------------------------------- */ $target = $this->target( $rule, $warnings ); if ( is_wp_error( $target ) ) { return $target; } /* ---- Only when… ------------------------------------------------ */ if ( array_key_exists( 'conditions', $input ) ) { $conditions = Conditions::sanitize( $input['conditions'] ); if ( is_wp_error( $conditions ) ) { return $conditions; } $rule->conditions = $conditions; } if ( array_key_exists( 'starts_at', $input ) ) { $rule->starts_at = self::utc( $input['starts_at'], ! empty( $input['schedule_utc'] ) ); } if ( array_key_exists( 'ends_at', $input ) ) { $rule->ends_at = self::utc( $input['ends_at'], ! empty( $input['schedule_utc'] ) ); } if ( null !== $rule->starts_at && null !== $rule->ends_at && strtotime( $rule->ends_at ) <= strtotime( $rule->starts_at ) ) { return new WP_Error( 'codebold_bad_schedule', __( 'The end of the schedule is before its start. Swap them round, or clear one.', 'codebold-redirects' ) ); } if ( null !== $rule->ends_at && strtotime( $rule->ends_at . ' UTC' ) <= time() ) { $warnings[] = [ 'code' => 'expired', 'message' => __( 'The schedule has already ended, so this redirect will not run. Change or clear the end date if that is not what you meant.', 'codebold-redirects' ), ]; } /* ---- Against the other rules ----------------------------------- */ $clash = $this->duplicate( $rule ); if ( null !== $clash ) { return new WP_Error( 'codebold_duplicate', sprintf( /* translators: 1: an address, 2: where it goes */ __( 'There is already a redirect from %1$s (to %2$s). Edit that one instead, or add a condition so the two apply to different visitors.', 'codebold-redirects' ), $clash->source, '' !== $clash->target ? $clash->target : (string) $clash->status_code ), [ 'id' => $clash->id, 'status' => 409 ] ); } $loop = $this->loop( $rule ); if ( null !== $loop ) { return $loop; } if ( ! $this->quiet ) { if ( $rule->moves() ) { $this->chain( $rule, $warnings ); } $this->incoming( $rule, $warnings ); } return [ 'rule' => $rule, 'warnings' => $warnings ]; } /** A new rule, with the site's chosen defaults. */ private function fresh(): Rule { return new Rule( case_sensitive: (bool) Settings::get( 'case_sensitive' ), pass_query: (bool) Settings::get( 'pass_query' ) ); } /** * @param list> $warnings */ private function source( Rule $rule, array &$warnings ): true|WP_Error { if ( '' === $rule->source ) { return new WP_Error( 'codebold_no_source', __( 'Enter the old address — the one visitors should be sent away from.', 'codebold-redirects' ) ); } if ( Rule::REGEX === $rule->match_type ) { if ( false === @preg_match( Conditions::delimit( $rule->source, $rule->case_sensitive ), '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors -- the failure is the answer. return new WP_Error( 'codebold_bad_regex', /* translators: %s: the PCRE error */ sprintf( __( 'That is not a valid regular expression (%s). If you only need "anything here", a wildcard with * is simpler.', 'codebold-redirects' ), preg_last_error_msg() ) ); } if ( preg_match( '#^https?://#i', $rule->source ) ) { $warnings[] = [ 'code' => 'regex_domain', 'message' => __( 'Patterns are matched against the part of the address after the domain, such as /blog/my-post. A pattern starting with http will never match.', 'codebold-redirects' ), ]; } $this->broad( $rule, $warnings ); return true; } $address = Address::parse( $rule->source ); if ( ! $address->is_local() ) { return new WP_Error( 'codebold_external_source', /* translators: %s: a domain name */ sprintf( __( 'The old address must be on this site. %s is a different website, so visitors to it never reach WordPress here.', 'codebold-redirects' ), $address->host ) ); } // Somebody typing "*" in an exact address almost certainly meant a wildcard. A real // address containing a literal asterisk is vanishingly rare; the mistake is not. if ( Rule::EXACT === $rule->match_type && str_contains( $address->path, '*' ) ) { $rule->match_type = Rule::WILDCARD; $warnings[] = [ 'code' => 'became_wildcard', 'message' => __( 'The address contains *, so this was saved as a wildcard: * stands for "anything".', 'codebold-redirects' ), ]; } if ( Rule::WILDCARD === $rule->match_type && ! str_contains( $address->path, '*' ) ) { $rule->match_type = Rule::EXACT; } $rule->source = $address->relative(); foreach ( self::PROTECTED as $protected ) { $key = mb_strtolower( $address->path ); if ( $key === $protected || str_starts_with( $key, $protected . '/' ) ) { return new WP_Error( 'codebold_protected', /* translators: %s: an address such as /wp-admin */ sprintf( __( '%s cannot be redirected. It is part of how you log in and manage the site, and redirecting it could lock you out.', 'codebold-redirects' ), $protected ) ); } } if ( '/' === $address->path && [] === $address->query && Rule::EXACT === $rule->match_type ) { $warnings[] = [ 'code' => 'homepage', 'message' => __( 'This redirects your homepage. Every visitor who arrives at your site\'s main address will be sent elsewhere.', 'codebold-redirects' ), ]; } if ( Rule::WILDCARD === $rule->match_type ) { $this->broad( $rule, $warnings ); } if ( ! $this->quiet && Rule::EXACT === $rule->match_type && $rule->moves() && [] === $address->query ) { $live = $this->live_page( $address, $rule->post_id ); if ( null !== $live ) { $warnings[] = [ 'code' => 'shadows', 'message' => sprintf( /* translators: %s: a page title */ __( '"%s" lives at this address. Visitors will be redirected instead of seeing it.', 'codebold-redirects' ), $live ), ]; } } return true; } /** * A pattern that matches everything is sometimes intended — moving a whole site — and * far more often a typo. Either way it deserves a sentence before it goes live. * * @param list> $warnings */ private function broad( Rule $rule, array &$warnings ): void { $probe = clone $rule; $probe->conditions = []; $samples = [ '/', '/a-page-that-surely-does-not-exist-' . wp_rand( 1000, 9999 ), '/2019/05/a-post' ]; $all = true; foreach ( $samples as $sample ) { if ( null === $this->engine->matches( $probe, Address::parse( $sample ) ) ) { $all = false; break; } } if ( $all ) { $warnings[] = [ 'code' => 'everything', 'message' => __( 'This matches every address on your site, including the homepage. That is right for moving a whole site to a new domain, and wrong almost every other time.', 'codebold-redirects' ), ]; } } /** * @param list> $warnings */ private function target( Rule $rule, array &$warnings ): true|WP_Error { if ( ! $rule->moves() ) { // 410 and 451 answer in place, so there is nowhere to go. Cleared rather than // kept, so that switching a rule to 410 and back does not resurrect a stale URL. $rule->target = ''; $rule->target_type = 'url'; $rule->target_id = 0; return true; } if ( 'post' === $rule->target_type ) { $post = $rule->target_id > 0 ? get_post( $rule->target_id ) : null; if ( ! $post instanceof \WP_Post ) { return new WP_Error( 'codebold_no_target', __( 'The page this was pointing at no longer exists. Choose another destination.', 'codebold-redirects' ) ); } $url = Engine::post_url( $post->ID ); // The snapshot is the fallback for when the page is later unpublished, so it is // only refreshed while there is a real address to take it from. if ( null !== $url ) { $rule->target = $url; } elseif ( 'publish' !== $post->post_status ) { $warnings[] = [ 'code' => 'unpublished_target', 'message' => __( 'That page is not published, so visitors would not be able to see it yet.', 'codebold-redirects' ), ]; } return true; } if ( 'term' === $rule->target_type ) { $link = $rule->target_id > 0 ? get_term_link( $rule->target_id ) : null; if ( ! is_string( $link ) ) { return new WP_Error( 'codebold_no_target', __( 'That category or tag no longer exists. Choose another destination.', 'codebold-redirects' ) ); } $rule->target = $link; return true; } $rule->target_type = 'url'; $rule->target_id = 0; $target = trim( $rule->target ); if ( '' === $target ) { return new WP_Error( 'codebold_no_target', __( 'Enter where visitors should go instead — a page on this site, or any web address.', 'codebold-redirects' ) ); } // "www.example.com/page" is a website, not a path on this one. if ( preg_match( '#^www\.#i', $target ) ) { $target = 'https://' . $target; } $has_scheme = (bool) preg_match( '#^[a-z][a-z0-9+.\-]*:#i', $target ); if ( $has_scheme && ! preg_match( '#^https?://#i', $target ) ) { return new WP_Error( 'codebold_bad_target', __( 'The destination must be a web address starting with http:// or https://, or a path on this site such as /new-page.', 'codebold-redirects' ) ); } if ( ! $has_scheme && ! str_starts_with( $target, '//' ) ) { $target = '/' . ltrim( $target, '/' ); } if ( preg_match( '/[\r\n]/', $target ) ) { return new WP_Error( 'codebold_bad_target', __( 'The destination contains a line break. Paste just the address.', 'codebold-redirects' ) ); } $address = Address::parse( $target ); if ( $address->has_host() && ! $address->is_local() ) { // The other half of the open-redirect guard in Engine::substitute(): the website // itself must be fixed in the rule. If a piece of the visitor's address could // choose the domain, anybody could use this site to bounce people anywhere. if ( preg_match( '/[\$\*]/', $address->host ) ) { return new WP_Error( 'codebold_capture_host', __( 'The website part of the destination cannot come from the visitor\'s address. Write the domain out in full; * and $1 can be used in the path after it.', 'codebold-redirects' ) ); } $rule->target = esc_url_raw( $target, [ 'http', 'https' ] ); if ( '' === $rule->target ) { return new WP_Error( 'codebold_bad_target', __( 'That destination is not a web address we can send visitors to.', 'codebold-redirects' ) ); } return true; } // A full address on this site is stored as a path, so the redirect keeps working // when the site moves between staging and live, or from http to https. $path = $address->has_host() ? self::local_path( $target ) : $target; $rule->target = $path; $has_captures = (bool) preg_match( '/\$\{?\d|\*/', $path ); // A typed path to a page is quietly linked to that page, so that renaming the page // later does not break this redirect. Only for plain addresses — a destination // built from pieces of the visitor's address is not one page. if ( ! $has_captures ) { $post_id = url_to_postid( Engine::absolute( Address::parse( $path )->path ) ); if ( $post_id > 0 && 'publish' === get_post_status( $post_id ) && ! str_contains( $path, '?' ) ) { $rule->target_type = 'post'; $rule->target_id = $post_id; $rule->target = (string) get_permalink( $post_id ); return true; } if ( ! $this->quiet ) { $this->missing( $path, $warnings ); } } return true; } /** The path and query of a full address on this site, minus the domain. */ private static function local_path( string $url ): string { $parts = wp_parse_url( $url ); $path = (string) ( $parts['path'] ?? '/' ); if ( isset( $parts['query'] ) ) { $path .= '?' . $parts['query']; } if ( isset( $parts['fragment'] ) ) { $path .= '#' . $parts['fragment']; } return '' === $path ? '/' : $path; } /** * A soft check that a destination on this site exists. Only a warning, because there * are plenty of real addresses WordPress cannot map back to a post — archives, shop * pages, anything a plugin routes — and refusing those would be worse than missing * the occasional typo. * * @param list> $warnings */ private function missing( string $path, array &$warnings ): void { $address = Address::parse( $path ); if ( '/' === $address->path ) { return; } $segments = explode( '/', trim( $address->path, '/' ) ); $slug = sanitize_title( (string) end( $segments ) ); // A category, tag, author or post type archive at this address is just as real as // a page, and none of them have a post ID. $known = term_exists( $slug ) || get_user_by( 'slug', $slug ) || in_array( $slug, self::archive_slugs(), true ); // A file that exists on disk is real too, whatever WordPress thinks. $known = $known || file_exists( ABSPATH . ltrim( $address->path, '/' ) ); // And so is anything another rule already redirects. $known = $known || null !== $this->engine->first( $address, self::neutral() ); if ( ! $known ) { $warnings[] = [ 'code' => 'missing_target', /* translators: %s: an address */ 'message' => sprintf( __( 'We could not find a page at %s on this site. Check the spelling — visitors sent there will see "page not found".', 'codebold-redirects' ), $address->path ), ]; } } /** @return list */ private static function archive_slugs(): array { $slugs = []; foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $type ) { if ( $type->has_archive ) { $slugs[] = is_string( $type->has_archive ) ? $type->has_archive : (string) ( $type->rewrite['slug'] ?? $type->name ); } } return $slugs; } /** * Another rule for the same address, applying to the same visitors. */ private function duplicate( Rule $rule ): ?Rule { if ( [] !== $rule->conditions ) { return null; } if ( Rule::EXACT === $rule->match_type ) { $mine = Address::parse( $rule->source ); foreach ( $this->repository->by_source( $rule->source ) as $other ) { if ( $other->id === $rule->id || [] !== $other->conditions ) { continue; } $theirs = Address::parse( $other->source ); if ( $theirs->key() === $mine->key() && $theirs->query == $mine->query ) { // phpcs:ignore Universal.Operators.StrictComparisons -- order of parameters does not matter. return $other; } } return null; } foreach ( $this->repository->patterns() as $other ) { if ( $other->id !== $rule->id && [] === $other->conditions && $other->match_type === $rule->match_type && $other->source === $rule->source ) { return $other; } } return null; } /** * A rule whose destination matches its own source sends visitors round for ever. * * For patterns the destination is built from a made-up address first — the mistake * this exists for is "^/(.*)$" to "/en/$1", which looks reasonable and produces * /en/en/en/en… until the browser gives up. */ private function loop( Rule $rule ): ?WP_Error { if ( ! $rule->moves() ) { return null; } $probe = clone $rule; $probe->conditions = []; $probe->pass_query = false; $sample = match ( $rule->match_type ) { Rule::EXACT => Address::parse( $rule->source ), Rule::WILDCARD => Address::parse( str_replace( '*', 'sample', $rule->source ) ), default => null, }; if ( Rule::REGEX === $rule->match_type ) { $captures = array_fill( 0, 9, 'sample' ); } else { $captures = null === $sample ? [] : ( $this->engine->matches( $probe, $sample ) ?? [] ); } $url = $this->engine->target( $probe, $captures, $sample ?? Address::parse( '/' ) ); if ( null === $url ) { return null; } $destination = Address::parse( $url ); if ( ! $destination->is_local() ) { return null; } if ( null === $this->engine->matches( $probe, $destination ) ) { return null; } return new WP_Error( 'codebold_loop', Rule::EXACT === $rule->match_type /* translators: 1: an address, 2: the same address, as the destination */ ? sprintf( __( 'This would send visitors from %1$s straight back to %2$s — a loop the browser stops with "too many redirects". Pick a different destination.', 'codebold-redirects' ), $rule->source, $destination->relative() ) /* translators: %s: an address */ : sprintf( __( 'The destination matches the pattern again (for example %s), so visitors would be redirected round and round. Make the pattern more specific, or point it somewhere it does not match.', 'codebold-redirects' ), $destination->relative() ) ); } /** * The destination is itself redirected. Not an error — visitors are sent straight to * the end — but worth knowing, and worth tidying. * * @param list> $warnings */ private function chain( Rule $rule, array &$warnings ): void { if ( Rule::EXACT !== $rule->match_type || ! in_array( $rule->target_type, [ 'url', 'post' ], true ) ) { return; } $probe = clone $rule; $probe->conditions = []; $probe->pass_query = false; $url = $this->engine->target( $probe, [], Address::parse( $rule->source ) ); if ( null === $url ) { return; } $destination = Address::parse( $url ); if ( ! $destination->is_local() ) { return; } $next = $this->engine->first( $destination, self::neutral() ); if ( null === $next || $next->rule->id === $rule->id ) { return; } // Follow the chain to its end, the way a visitor would be sent, so "point it there // directly" points at the last address rather than at the next link in the chain. $outcome = $this->engine->resolve( $destination, self::neutral() ); $final = null !== $outcome && ! $outcome->loop && null !== $outcome->url ? $outcome->url : $next->url; $shown = null !== $final ? self::shown( $final ) : (string) $next->rule->status_code; $warnings[] = [ 'code' => 'chain', 'message' => sprintf( /* translators: 1: an address, 2: the address the chain ends at */ __( '%1$s is itself redirected, and the chain ends at %2$s. Visitors are sent straight there, so this works — but you can point this redirect at %2$s directly.', 'codebold-redirects' ), $destination->relative(), $shown ), 'data' => [ 'id' => $next->rule->id, 'target' => $final ], ]; } /** An address as the screen shows it: a path when it is on this site. */ private static function shown( string $url ): string { $address = Address::parse( $url ); return $address->is_local() ? $address->relative() : $url; } /** * Something visitors can see at this address already, by name — so the warning can * say what is about to be hidden. * * url_to_postid() only knows single posts and pages. A shop's /shop is a post type * archive, a category is a term archive, and the blog is whichever page holds the * posts; redirecting any of them hides a real page just the same. */ private function live_page( Address $address, int $own_post ): ?string { $path = mb_strtolower( $address->path ); if ( '/' === $path ) { return null; // The homepage has its own, stronger warning. } $post_id = url_to_postid( Engine::absolute( $address->path ) ); if ( $post_id > 0 && $post_id !== $own_post && 'publish' === get_post_status( $post_id ) ) { return html_entity_decode( get_the_title( $post_id ), ENT_QUOTES, 'UTF-8' ); } $same = static fn( $url ): bool => is_string( $url ) && '' !== $url && mb_strtolower( Address::parse( $url )->path ) === $path; foreach ( [ (int) get_option( 'page_for_posts' ), function_exists( 'wc_get_page_id' ) ? (int) wc_get_page_id( 'shop' ) : 0 ] as $page ) { if ( $page > 0 && $page !== $own_post && 'publish' === get_post_status( $page ) && $same( get_permalink( $page ) ) ) { return html_entity_decode( get_the_title( $page ), ENT_QUOTES, 'UTF-8' ); } } foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $type ) { if ( $type->has_archive && $same( get_post_type_archive_link( $type->name ) ) ) { return (string) $type->labels->name; } } $segments = explode( '/', trim( $path, '/' ) ); $terms = get_terms( [ 'taxonomy' => array_values( get_taxonomies( [ 'public' => true ] ) ), 'slug' => sanitize_title( (string) end( $segments ) ), 'hide_empty' => false, 'number' => 5, ] ); foreach ( is_array( $terms ) ? $terms : [] as $term ) { if ( $term instanceof \WP_Term && $same( get_term_link( $term ) ) ) { return $term->name; } } return null; } /** * Other redirects already send people to this address, and will now carry on. * * @param list> $warnings */ private function incoming( Rule $rule, array &$warnings ): void { if ( Rule::EXACT !== $rule->match_type ) { return; } $incoming = array_filter( $this->repository->targeting_url( $rule->source ), static fn( Rule $other ): bool => $other->id !== $rule->id ); if ( [] === $incoming ) { return; } $warnings[] = [ 'code' => 'incoming', 'message' => sprintf( /* translators: %d: number of redirects */ _n( '%d other redirect sends visitors to this address. They will now carry on to the new destination.', '%d other redirects send visitors to this address. They will now carry on to the new destination.', count( $incoming ), 'codebold-redirects' ), count( $incoming ) ), 'data' => [ 'ids' => array_values( array_map( static fn( Rule $r ): int => $r->id, $incoming ) ) ], ]; } /** * A visitor with no particular traits, for the checks that ask "would anybody be * redirected here". Logged out, on a computer, no cookies — the most common visitor * and the one a search engine is. */ public static function neutral(): Visitor { return new Visitor( false, [], false, '', '', '', [], '', '' ); } /** * A date from the form, which is in the site's time zone, as UTC for storage. */ private static function utc( mixed $value, bool $already_utc ): ?string { $value = trim( (string) $value ); if ( '' === $value ) { return null; } $time = strtotime( str_replace( 'T', ' ', $value ) ); if ( false === $time ) { return null; } $local = gmdate( 'Y-m-d H:i:s', $time ); return $already_utc ? $local : get_gmt_from_date( $local ); } }