handled; } public function early(): void { $request = $this->request(); if ( null === $request ) { return; } $this->act( $this->engine->resolve( $request, Visitor::current() ) ); } public function late(): void { if ( $this->answer > 0 ) { $this->answer_in_place(); return; } if ( ! is_404() ) { return; } $request = $this->request(); if ( null === $request ) { return; } $outcome = $this->engine->resolve( $request, Visitor::current( true ), 'late' ); if ( null !== $outcome ) { $this->act( $outcome ); if ( $this->answer > 0 ) { $this->answer_in_place(); } return; } $this->fallback( $request ); } /** * The request, if it is one this plugin should touch at all. */ public function request(): ?Address { if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) { return null; } // GET and HEAD only. A form or a webhook posting to an old address would be turned // into a GET by a 301, losing what it sent; failing visibly is better than that. $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; /** * Filter the request methods that may be redirected. * * @param list $methods */ $methods = (array) apply_filters( 'codebold_redirect_methods', [ 'GET', 'HEAD' ] ); if ( ! in_array( $method, $methods, true ) ) { return null; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- parsed, never output; Address strips control characters. $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; $request = Address::from_request( $uri ); // The admin, the login screen and the REST API are never redirected, whatever a // rule says. The Validator refuses rules for them, but a regex can match more than // its author intended, and this is the backstop. $key = mb_strtolower( $request->path ); foreach ( Validator::PROTECTED as $protected ) { if ( $key === $protected || str_starts_with( $key, $protected . '/' ) ) { return null; } } $rest = '/' . trim( rest_get_url_prefix(), '/' ); if ( $key === $rest || str_starts_with( $key, $rest . '/' ) || isset( $request->query['rest_route'] ) ) { return null; } /** * Filter whether this request should be checked for redirects at all. * * @param bool $check * @param Address $request */ return apply_filters( 'codebold_redirect_should_check', true, $request ) ? $request : null; } private function act( ?Outcome $outcome ): void { if ( null === $outcome ) { return; } if ( $outcome->loop ) { /** * Fires when the rules for an address lead in a circle and nothing was done. * * @param Outcome $outcome */ do_action( 'codebold_redirect_loop', $outcome ); return; } $this->handled = true; foreach ( $outcome->hits as $hit ) { $this->repository->record_hit( $hit->rule->id ); } if ( null === $outcome->url ) { // 410 or 451: let WordPress carry on and answer with its own 404 template, under // the right status, once the theme is ready to draw it. $this->answer = $outcome->code; return; } /** * Filter the final address before the visitor is sent there. * * @param string $url * @param Outcome $outcome */ $url = (string) apply_filters( 'codebold_redirect_url', $outcome->url, $outcome ); $this->cache_headers( $outcome ); /** * Fires just before a visitor is redirected. * * @param string $url * @param Outcome $outcome */ do_action( 'codebold_redirect_before', $url, $outcome ); // wp_redirect(), not wp_safe_redirect(): sending people to another website is // half of what redirects are for. The destination was checked when it was saved. // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect if ( wp_redirect( $url, $outcome->code, 'CodeBold Redirects' ) ) { exit; } } /** * Browsers remember a 301 for as long as they are told to — by default, for ever, * which is how a mistaken redirect keeps happening on somebody's laptop for months * after it was deleted. So a permanent redirect is cached for a set time (an hour, * unless changed), and anything conditional is not cached at all: the next visitor * might be somebody the rule does not apply to. */ private function cache_headers( Outcome $outcome ): void { $hours = (int) Settings::get( 'cache_hours' ); if ( headers_sent() ) { return; } if ( $outcome->conditional() || ! in_array( $outcome->code, [ 301, 308 ], true ) || $hours <= 0 ) { nocache_headers(); return; } $seconds = $hours * HOUR_IN_SECONDS; header( 'Cache-Control: max-age=' . $seconds ); header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + $seconds ) . ' GMT' ); } private function answer_in_place(): void { global $wp_query; $code = $this->answer; $this->answer = 0; if ( $wp_query instanceof \WP_Query ) { $wp_query->set_404(); } status_header( $code ); nocache_headers(); } /** * What to do with a 404 that no rule covers, if the site asked for anything. * * Off by default, and the settings screen says why: sending every missing page to the * homepage is what search engines call a "soft 404", and they treat it as a 404 that * also wastes the visitor's time. The closest-match option is safer — it only fires * when WordPress's own guess finds a single, published post with that name. */ private function fallback( Address $request ): void { $mode = (string) Settings::get( 'fallback_404' ); if ( 'none' === $mode ) { return; } $url = 'home' === $mode ? home_url( '/' ) : redirect_guess_404_permalink(); if ( ! is_string( $url ) || '' === $url ) { return; } $destination = Address::parse( $url ); if ( $destination->key() === $request->key() ) { return; } // Still recorded, so the list shows what was being caught and a real redirect can // replace the guess. Plugin::instance()->service( Recorder::class )->record( $request ); $this->handled = true; nocache_headers(); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- this site's own address. if ( wp_redirect( $url, 302, 'CodeBold Redirects' ) ) { exit; } } }