repo = $repo ?? new RedirectRepository(); } /** * Register hooks. */ public function register(): void { add_action( 'template_redirect', array( $this, 'maybe_redirect' ), 1 ); } /** * Invalidate cached maps. */ public static function flush_cache(): void { delete_transient( self::MAP_ALWAYS ); delete_transient( self::MAP_404 ); } /** * Redirect the current request if a rule matches. */ public function maybe_redirect(): void { $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Normalized by Path::normalize(). $match = $this->match( (string) $uri, is_404() ); if ( null === $match ) { return; } $this->repo->hit( (int) $match['id'] ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Targets are admin-defined and may be external. if ( wp_redirect( $match['target'], (int) $match['code'], 'Unbreak' ) ) { $this->stop(); } } /** * Exit after redirecting (separate so the behaviour can be overridden via the filter). */ private function stop(): void { /** * Whether to exit after sending a redirect. * * @param bool $exit Exit. */ if ( apply_filters( 'unbreak_redirect_exit', true ) ) { exit; } } /** * Find the rule for a request. * * @param string $request_uri Request URI. * @param bool $is_404 Whether WordPress resolved the request to a 404. * @return array{id:int,target:string,code:int}|null */ public function match( string $request_uri, bool $is_404 ): ?array { $always = $this->map( 'always' ); if ( ! $is_404 && empty( $always ) ) { return null; } $mode = (string) Settings::get( 'redirect_query' ); $full = Path::normalize( $request_uri, true ); $path = Path::normalize( $request_uri, false ); if ( null === $path ) { return null; } $keys = array(); if ( null !== $full && $full !== $path ) { $keys[] = $full; } if ( 'exact' !== $mode || $full === $path ) { $keys[] = $path; } $maps = $is_404 ? array( $always, $this->map( '404' ) ) : array( $always ); foreach ( $maps as $map ) { foreach ( $keys as $key ) { if ( isset( $map[ $key ] ) ) { $rule = $map[ $key ]; $target = (string) $rule[1]; if ( 'pass' === $mode && null !== $full && $full !== $path && false === strpos( $target, '?' ) ) { $target .= '?' . substr( $full, strlen( $path ) + 1 ); } return array( 'id' => (int) $rule[0], 'target' => $target, 'code' => (int) $rule[2], ); } } } return null; } /** * Cached map for one match mode: normalized source => [id, target, code]. * * @param string $mode "always" or "404". * @return array */ public function map( string $mode ): array { $name = 'always' === $mode ? self::MAP_ALWAYS : self::MAP_404; $cached = get_transient( $name ); if ( is_array( $cached ) ) { return $cached; } $always = array(); $on404 = array(); foreach ( $this->repo->enabled() as $rule ) { $entry = array( (int) $rule['id'], (string) $rule['target_url'], (int) $rule['code'] ); if ( 'always' === $rule['match_mode'] ) { $always[ (string) $rule['source_path'] ] = $entry; } else { $on404[ (string) $rule['source_path'] ] = $entry; } } // No expiration => autoloaded => zero queries on normal page views. set_transient( self::MAP_ALWAYS, $always ); set_transient( self::MAP_404, $on404, WEEK_IN_SECONDS ); return 'always' === $mode ? $always : $on404; } }