*/ public function detect(): array { $found = []; foreach ( $this->labels() as $key => $label ) { $count = $this->count( $key ); if ( $count > 0 ) { $found[] = [ 'key' => $key, 'label' => $label, 'count' => $count ]; } } return $found; } /** @return array */ public function labels(): array { return [ 'redirection' => 'Redirection', 'rankmath' => 'Rank Math', 'yoast' => 'Yoast SEO Premium', 'srm' => 'Safe Redirect Manager', 'simple301' => 'Simple 301 Redirects', ]; } private function count( string $key ): int { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- other plugins' tables, read-only. switch ( $key ) { case 'redirection': $table = $wpdb->prefix . 'redirection_items'; return $this->has_table( $table ) ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ) : 0; case 'rankmath': $table = $wpdb->prefix . 'rank_math_redirections'; return $this->has_table( $table ) ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE status <> 'trashed'" ) : 0; case 'yoast': return count( $this->yoast_items() ); case 'srm': return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status IN ('publish','draft')", 'redirect_rule' ) ); case 'simple301': $option = get_option( '301_redirects' ); return is_array( $option ) ? count( $option ) : 0; } // phpcs:enable return 0; } private function has_table( string $table ): bool { global $wpdb; return $table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * @return array{rows: list}>, errors: list} */ public function read( string $key ): array { $rows = []; $errors = []; $items = match ( $key ) { 'redirection' => $this->redirection( $errors ), 'rankmath' => $this->rankmath(), 'yoast' => $this->yoast(), 'srm' => $this->srm(), 'simple301' => $this->simple301(), default => [], }; foreach ( $items as $index => $input ) { $rows[] = [ 'line' => $index + 1, 'input' => $input ]; } return [ 'rows' => $rows, 'errors' => $errors ]; } /** * Redirection (John Godley). Only its plain "redirect to URL" and "error" rules are * brought across; its other match types keep their logic in a serialised blob whose * format has changed several times, and a half-understood conditional rule is worse * than one that is reported as skipped. * * @param list $errors * @return list> */ private function redirection( array &$errors ): array { global $wpdb; $items = $wpdb->prefix . 'redirection_items'; $groups = $wpdb->prefix . 'redirection_groups'; if ( ! $this->has_table( $items ) ) { return []; } $names = []; if ( $this->has_table( $groups ) ) { foreach ( (array) $wpdb->get_results( "SELECT id, name FROM {$groups}" ) as $group ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $names[ (int) $group->id ] = (string) $group->name; } } $out = []; $n = 0; foreach ( (array) $wpdb->get_results( "SELECT * FROM {$items} ORDER BY position ASC, id ASC" ) as $row ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter ++$n; $row = (array) $row; if ( 'url' !== ( $row['match_type'] ?? 'url' ) ) { $errors[] = [ 'line' => $n, /* translators: %s: an address */ 'message' => sprintf( __( '%s uses one of Redirection\'s conditional match types, which has to be recreated by hand. Skipped.', 'codebold-redirects' ), (string) ( $row['url'] ?? '' ) ), ]; continue; } $action = (string) ( $row['action_type'] ?? 'url' ); $code = (int) ( $row['action_code'] ?? 301 ); if ( 'error' === $action && 404 === $code ) { $errors[] = [ 'line' => $n, /* translators: %s: an address */ 'message' => sprintf( __( '%s returns a plain 404, which needs no redirect. Skipped.', 'codebold-redirects' ), (string) ( $row['url'] ?? '' ) ), ]; continue; } if ( ! in_array( $action, [ 'url', 'error' ], true ) ) { continue; } $flags = json_decode( (string) ( $row['match_data'] ?? '' ), true ); $query = is_array( $flags ) ? (string) ( $flags['source']['flag_query'] ?? '' ) : ''; $input = [ 'source' => (string) ( $row['url'] ?? '' ), 'target' => 'url' === $action ? (string) ( $row['action_data'] ?? '' ) : '', 'status_code' => $code, 'match_type' => ! empty( $row['regex'] ) ? 'regex' : 'exact', 'enabled' => 'enabled' === ( $row['status'] ?? 'enabled' ), 'hits' => (int) ( $row['last_count'] ?? 0 ), 'note' => (string) ( $row['title'] ?? '' ), 'group_name' => $names[ (int) ( $row['group_id'] ?? 0 ) ] ?? '', ]; if ( 'pass' === $query ) { $input['pass_query'] = true; } $out[] = $input; } return $out; } /** * Rank Math. One rule can list several sources, each with its own comparison; each * becomes a rule of ours with the same destination. * * @return list> */ private function rankmath(): array { global $wpdb; $table = $wpdb->prefix . 'rank_math_redirections'; if ( ! $this->has_table( $table ) ) { return []; } $out = []; foreach ( (array) $wpdb->get_results( "SELECT * FROM {$table} WHERE status <> 'trashed' ORDER BY id ASC" ) as $row ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $row = (array) $row; $sources = maybe_unserialize( (string) ( $row['sources'] ?? '' ) ); foreach ( is_array( $sources ) ? $sources : [] as $source ) { if ( ! is_array( $source ) || '' === (string) ( $source['pattern'] ?? '' ) ) { continue; } $out[] = [ 'target' => (string) ( $row['url_to'] ?? '' ), 'status_code' => (int) ( $row['header_code'] ?? 301 ), 'enabled' => 'active' === ( $row['status'] ?? 'active' ), 'hits' => (int) ( $row['hits'] ?? 0 ), ] + self::comparison( (string) $source['pattern'], (string) ( $source['comparison'] ?? 'exact' ) ); } } return $out; } /** * Rank Math's comparisons, as our match types. Its patterns are written without the * leading slash, and so are its regular expressions. * * @return array{source: string, match_type: string} */ private static function comparison( string $pattern, string $comparison ): array { $plain = '/' . ltrim( $pattern, '/' ); return match ( $comparison ) { 'start' => [ 'source' => rtrim( $plain, '*' ) . '*', 'match_type' => 'wildcard' ], 'end' => [ 'source' => '*' . ltrim( $pattern, '/*' ), 'match_type' => 'wildcard' ], 'contains' => [ 'source' => '*' . trim( $pattern, '/*' ) . '*', 'match_type' => 'wildcard' ], 'regex' => [ 'source' => self::slash_regex( $pattern ), 'match_type' => 'regex' ], default => [ 'source' => $plain, 'match_type' => 'exact' ], }; } /** "^old/(.*)" was written against a path without its slash; ours have one. */ private static function slash_regex( string $pattern ): string { if ( str_starts_with( $pattern, '^' ) && ! str_starts_with( $pattern, '^/' ) ) { return '^/' . substr( $pattern, 1 ); } return $pattern; } /** @return list> */ private function yoast_items(): array { $items = get_option( 'wpseo-premium-redirects-base' ); return is_array( $items ) ? array_values( array_filter( $items, 'is_array' ) ) : []; } /** * Yoast SEO Premium. * * @return list> */ private function yoast(): array { $out = []; foreach ( $this->yoast_items() as $item ) { $origin = (string) ( $item['origin'] ?? '' ); if ( '' === $origin ) { continue; } $regex = 'regex' === ( $item['format'] ?? 'plain' ); $out[] = [ 'source' => $regex ? self::slash_regex( $origin ) : '/' . ltrim( $origin, '/' ), 'target' => (string) ( $item['url'] ?? '' ), 'status_code' => (int) ( $item['type'] ?? 301 ), 'match_type' => $regex ? 'regex' : 'exact', ]; } return $out; } /** * Safe Redirect Manager (10up). Rules are posts; the useful parts are meta. * * @return list> */ private function srm(): array { global $wpdb; // Read directly: when Safe Redirect Manager is inactive its post type is not // registered, and get_posts() refuses to return posts of an unknown type. $posts = (array) $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_status, post_excerpt FROM {$wpdb->posts} WHERE post_type = %s AND post_status IN ('publish','draft') ORDER BY menu_order ASC, ID ASC", 'redirect_rule' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $out = []; foreach ( $posts as $post ) { $from = (string) get_post_meta( (int) $post->ID, '_redirect_rule_from', true ); $regex = (bool) get_post_meta( (int) $post->ID, '_redirect_rule_from_regex', true ); if ( '' === $from ) { continue; } $out[] = [ 'source' => $from, 'target' => (string) get_post_meta( (int) $post->ID, '_redirect_rule_to', true ), 'status_code' => (int) ( get_post_meta( (int) $post->ID, '_redirect_rule_status_code', true ) ?: 302 ), 'match_type' => $regex ? 'regex' : ( str_ends_with( $from, '*' ) ? 'wildcard' : 'exact' ), 'enabled' => 'publish' === $post->post_status, 'note' => (string) ( get_post_meta( (int) $post->ID, '_redirect_rule_notes', true ) ?: $post->post_excerpt ), ]; } return $out; } /** * Simple 301 Redirects. An array of old => new, all 301s. * * @return list> */ private function simple301(): array { $items = get_option( '301_redirects' ); $wildcard = 'true' === get_option( '301_redirects_wildcard' ); $out = []; foreach ( is_array( $items ) ? $items : [] as $from => $to ) { $from = (string) $from; $out[] = [ 'source' => $from, 'target' => (string) $to, 'status_code' => 301, 'match_type' => $wildcard && str_contains( $from, '*' ) ? 'wildcard' : 'exact', ]; } return $out; } }