> */ private array $resolved_rows = []; public function __construct( private readonly NotFoundLog $log = new NotFoundLog(), private readonly Engine $engine = new Engine() ) {} public function boot(): void { // Late, so that every other plugin has had its chance to handle the request — a // page a plugin answered is not a 404, whatever the main query said. add_action( 'template_redirect', [ $this, 'maybe_record' ], 99 ); add_action( 'codebold_redirect_created', [ $this, 'resolve' ] ); add_action( 'codebold_redirect_updated', [ $this, 'resolve' ] ); } public function maybe_record(): void { if ( ! is_404() ) { return; } $handler = Plugin::instance()->service( Handler::class ); if ( $handler->handled() ) { return; } $request = $handler->request(); if ( null !== $request ) { $this->record( $request ); } } public function record( Address $request ): void { if ( ! Settings::get( 'log_404' ) ) { return; } // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- trimmed and length-capped below; never output unescaped. $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; $referrer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; // phpcs:enable $is_bot = '' === $agent || (bool) preg_match( self::BOTS, $agent ); if ( $this->ignored( $request, $is_bot ) ) { return; } /** * Filter whether to record a 404. * * @param bool $record * @param Address $request */ if ( ! apply_filters( 'codebold_redirect_record_404', true, $request ) ) { return; } $this->log->hit( $request, self::strip_query( $referrer ), $agent, $is_bot ); } /** * The referrer's query string is dropped. It is where search terms, session tokens * and email addresses end up, and none of them are needed to find the broken link. */ private static function strip_query( string $url ): string { $question = strpos( $url, '?' ); return false === $question ? $url : substr( $url, 0, $question ); } public function ignored( Address $request, bool $is_bot ): bool { if ( $is_bot && Settings::get( 'log_ignore_bots' ) ) { return true; } if ( Settings::get( 'log_ignore_files' ) && preg_match( '/\.(' . self::FILE_TYPES . ')$/i', $request->path ) ) { return true; } foreach ( preg_split( '/\R/', (string) Settings::get( 'log_ignore' ) ) ?: [] as $line ) { $line = trim( $line ); if ( '' === $line ) { continue; } if ( ! str_starts_with( $line, '/' ) && ! str_starts_with( $line, '*' ) ) { $line = '/' . $line; } if ( 1 === preg_match( Engine::wildcard_pattern( $line, false ), $request->path ) ) { return true; } } return false; } /** * Remove the addresses a newly saved rule covers. * * Only for rules that would fire for everybody. A redirect for logged-in visitors on * phones fixes the address for some people, and the rest are still finding a 404. */ public function resolve( ?Rule $rule ): void { $this->resolved = 0; $this->resolved_rows = []; if ( null === $rule || ! $rule->enabled ) { return; } foreach ( $rule->conditions as $condition ) { if ( 'not_found' !== ( $condition['type'] ?? '' ) ) { return; } } if ( Rule::EXACT === $rule->match_type ) { $source = Address::parse( $rule->source ); // The list is kept by path. A rule for /shop?ref=spring fixes one way of // arriving at /shop, not /shop itself. if ( [] === $source->query ) { $this->resolved_rows = $this->log->by_hash( $source->hash() ); $this->resolved = $this->log->delete_by_hash( $source->hash() ); } return; } $covered = []; foreach ( $this->log->recent_urls() as $row ) { if ( null !== $this->engine->matches( $rule, Address::parse( $row['url'] ) ) ) { $covered[] = $row['id']; } } $this->resolved_rows = $this->log->many( $covered ); $this->resolved = $this->log->delete( $covered ); } public function last_resolved(): int { return $this->resolved; } /** * The rows the last save took out of the list, exactly as they were, so that undoing * the save can put them back — visits, referrer and all. Without this, "Undo" on a * one-click fix removed the redirect and quietly lost the history that prompted it. * * @return list> */ public function last_resolved_rows(): array { return $this->resolved_rows; } }