$source, 'source_hash' => $match === 'exact' ? Url::hash( $source ) : '', 'match_type' => $match, 'target' => $target, 'target_post_id' => $post, 'code' => $code, 'enabled' => isset( $in['enabled'] ) ? ( rest_sanitize_boolean( $in['enabled'] ) ? 1 : 0 ) : 1, 'origin' => isset( $in['origin'] ) ? sanitize_key( (string) $in['origin'] ) : 'manual', 'note' => isset( $in['note'] ) ? sanitize_text_field( (string) $in['note'] ) : '', ); } /** @return int|\WP_Error the new id */ public static function add( array $in ) { global $wpdb; $row = self::prepare( $in ); if ( is_wp_error( $row ) ) { return $row; } $existing = self::find_same( $row['source'], $row['match_type'] ); if ( $existing ) { return new \WP_Error( 'rb_redirection_exists', __( 'There is already a redirect for this address.', 'rb-links-redirection' ), array( 'id' => (int) $existing->id ) ); } $row['created'] = current_time( 'mysql', true ); $wpdb->insert( Install::redirects_table(), $row ); self::flush(); $id = (int) $wpdb->insert_id; /** a redirect was added */ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- rb_redirection_ is this plugin's prefix (constants, options, tables, functions and hooks); Plugin Check only guesses rbRedirection from the namespace do_action( 'rb_redirection_added', $id, $row ); return $id; } /** @return true|\WP_Error */ public static function update( int $id, array $in ) { global $wpdb; $current = self::get( $id ); if ( ! $current ) { return new \WP_Error( 'rb_redirection_missing', __( 'This redirect does not exist.', 'rb-links-redirection' ) ); } $row = self::prepare( array_merge( (array) $current, $in ) ); if ( is_wp_error( $row ) ) { return $row; } $existing = self::find_same( $row['source'], $row['match_type'] ); if ( $existing && (int) $existing->id !== $id ) { return new \WP_Error( 'rb_redirection_exists', __( 'There is already a redirect for this address.', 'rb-links-redirection' ), array( 'id' => (int) $existing->id ) ); } $wpdb->update( Install::redirects_table(), $row, array( 'id' => $id ) ); self::flush(); return true; } public static function delete( array $ids ): int { global $wpdb; $ids = array_filter( array_map( 'absint', $ids ) ); if ( ! $ids ) { return 0; } $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $in is only %d placeholders, one per id $n = (int) $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE id IN ({$in})", Install::redirects_table(), ...$ids ) ); self::flush(); return $n; } public static function set_enabled( array $ids, bool $on ): int { global $wpdb; $ids = array_filter( array_map( 'absint', $ids ) ); if ( ! $ids ) { return 0; } $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $in is only %d placeholders, one per id $n = (int) $wpdb->query( $wpdb->prepare( "UPDATE %i SET enabled = %d WHERE id IN ({$in})", Install::redirects_table(), $on ? 1 : 0, ...$ids ) ); self::flush(); return $n; } public static function get( int $id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', Install::redirects_table(), $id ) ); } /** * A page of the list. * * @return array{items: object[], total: int} */ public static function query( array $args ): array { global $wpdb; $t = Install::redirects_table(); // each filter is off when its value is empty ('' / -1), so the SQL itself never changes $like = empty( $args['search'] ) ? '' : '%' . $wpdb->esc_like( (string) $args['search'] ) . '%'; $origin = empty( $args['origin'] ) ? '' : (string) $args['origin']; $enabled = ( isset( $args['enabled'] ) && $args['enabled'] !== '' ) ? ( $args['enabled'] ? 1 : 0 ) : -1; $order = in_array( $args['orderby'] ?? '', array( 'hits', 'last_hit', 'source', 'id' ), true ) ? $args['orderby'] : 'id'; $dir = ( $args['order'] ?? 'desc' ) === 'asc' ? 'ASC' : 'DESC'; $per = max( 1, min( 5000, (int) ( $args['per_page'] ?? 20 ) ) ); $offset = max( 0, ( (int) ( $args['page'] ?? 1 ) - 1 ) * $per ); $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE (%s = '' OR source LIKE %s OR target LIKE %s OR note LIKE %s) AND (%s = '' OR origin = %s) AND (%d = -1 OR enabled = %d)", $t, $like, $like, $like, $like, $origin, $origin, $enabled, $enabled ) ); $items = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE (%s = '' OR source LIKE %s OR target LIKE %s OR note LIKE %s) AND (%s = '' OR origin = %s) AND (%d = -1 OR enabled = %d) ORDER BY CASE WHEN %s = 'ASC' THEN %i END ASC, CASE WHEN %s = 'DESC' THEN %i END DESC, id DESC LIMIT %d OFFSET %d", $t, $like, $like, $like, $like, $origin, $origin, $enabled, $enabled, $dir, $order, $dir, $order, $per, $offset ) ); return array( 'items' => $items, 'total' => $total ); } public static function find_exact( string $normalized, bool $enabled_only = true ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE source_hash = %s AND match_type = %s AND (%d = 0 OR enabled = 1) ORDER BY id ASC LIMIT 1', Install::redirects_table(), Url::hash( $normalized ), 'exact', $enabled_only ? 1 : 0 ) ); } /** a rule with the same source and kind, enabled or not */ public static function find_same( string $source, string $match_type ) { if ( $match_type === 'exact' ) { return self::find_exact( $source, false ); } global $wpdb; return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE match_type = %s AND source = %s LIMIT 1', Install::redirects_table(), $match_type, $source ) ); } /** the enabled pattern rules, cached until a redirect changes */ private static function patterns(): array { $cached = wp_cache_get( self::CACHE_KEY, 'rb_redirection' ); if ( is_array( $cached ) ) { return $cached; } $cached = get_transient( self::CACHE_KEY ); if ( ! is_array( $cached ) ) { global $wpdb; $cached = (array) $wpdb->get_results( $wpdb->prepare( "SELECT id, source, match_type, target, target_post_id, code FROM %i WHERE enabled = 1 AND match_type <> 'exact' ORDER BY id ASC", Install::redirects_table() ) ); set_transient( self::CACHE_KEY, $cached, DAY_IN_SECONDS ); } wp_cache_set( self::CACHE_KEY, $cached, 'rb_redirection' ); return $cached; } /** * Made for an address that went away: a post's old address, or a duplicate * Duplicate Post RB removed. It gives way when the address gets a page of its * own again; one made by hand, imported or added by another plugin does not. */ public static function is_automatic( $row ): bool { return in_array( (string) ( $row->origin ?? '' ), array( 'slug', 'duplicate-post-rb' ), true ); } public static function flush(): void { delete_transient( self::CACHE_KEY ); wp_cache_delete( self::CACHE_KEY, 'rb_redirection' ); } /** * The redirect for a request, and where it goes. * * @return array{row: object, to: string}|null */ public static function match( string $request_uri ): ?array { $normalized = Url::normalize( $request_uri ); $path = Url::path( $normalized ); // exact: with the query string first, then the path alone foreach ( array_unique( array( $normalized, $path ) ) as $candidate ) { $row = self::find_exact( $candidate ); if ( $row ) { return array( 'row' => $row, 'to' => self::target_of( $row, array() ) ); } } foreach ( self::patterns() as $row ) { $source = (string) $row->source; switch ( $row->match_type ) { case 'starts': if ( strpos( $path, $source ) === 0 ) { $row = self::stored( $row ); return array( 'row' => $row, 'to' => self::target_of( $row, array() ) ); } break; case 'contains': if ( $source !== '' && strpos( $path, $source ) !== false ) { $row = self::stored( $row ); return array( 'row' => $row, 'to' => self::target_of( $row, array() ) ); } break; case 'regex': // against the address as asked for, decoded, with its query $subject = rawurldecode( $request_uri ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a broken pattern must not break the site if ( @preg_match( self::regex( $source ), $subject, $m ) ) { $row = self::stored( $row ); $to = self::target_of( $row, $m ); if ( $to === null ) { return null; // the request tried to send the visitor off the rule's site } return array( 'row' => $row, 'to' => $to ); } break; } } return null; } /** * The whole row of a pattern rule that matched: the cached list keeps only * what matching needs, while the caller (the redirect, "Test an address", * rb_redirection_find) needs its origin, state and counters as they are now. * One query by primary key, and only for the request that matched. */ private static function stored( $row ) { $full = self::get( (int) $row->id ); return $full ? $full : $row; } /** a stored pattern as a PHP regex: "^/old/(.*)$" → "#^/old/(.*)$#i" */ public static function regex( string $source ): string { return '#' . str_replace( '#', '\#', $source ) . '#i'; } /** where a matched redirect sends the visitor; "$1" takes the regex's groups */ private static function target_of( $row, array $groups ): ?string { if ( (int) $row->code === 410 ) { return ''; } if ( (int) $row->target_post_id ) { $link = get_permalink( (int) $row->target_post_id ); if ( $link && get_post_status( (int) $row->target_post_id ) === 'publish' ) { return $link; } } $template = (string) $row->target; $target = $template; if ( $groups ) { $target = preg_replace_callback( '/\$(\d+)/', function ( $m ) use ( $groups ) { return isset( $groups[ (int) $m[1] ] ) ? $groups[ (int) $m[1] ] : ''; }, $target ); // the groups come from the visitor's address: they must not move the target to another site $target = self::contained( $template, $target ); if ( $target === null ) { return null; } } return Url::absolute( $target ); } /** * A regex target after "$1" was filled with the visitor's text, kept where the * rule's author pointed it: a site-relative target ("/news/$1") stays on this * site — "//evil.example" or "/\evil.example" from the request would make the * browser leave it — and a full URL keeps the host the author wrote. * * @return string|null null: the request tried to leave the target's host */ private static function contained( string $template, string $target ): ?string { $target = preg_replace( '/[\x00-\x1F\x7F]/', '', $target ); if ( preg_match( '#^/(?![/\\\\])#', $template ) ) { // one leading slash: browsers read "//" and "/\" as "another host" return '/' . ltrim( $target, "/\\ \t" ); } $want = wp_parse_url( preg_replace( '/\$\d+/', '', $template ), PHP_URL_HOST ); $got = wp_parse_url( str_replace( '\\', '/', $target ), PHP_URL_HOST ); if ( ! $want || ! $got || strcasecmp( (string) $want, (string) $got ) !== 0 ) { return null; } return $target; } /** a hit: count and time, cheap (one UPDATE) */ public static function hit( int $id ): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'UPDATE %i SET hits = hits + 1, last_hit = %s WHERE id = %d', Install::redirects_table(), current_time( 'mysql', true ), $id ) ); } private static function same_host( string $url ): bool { $host = wp_parse_url( $url, PHP_URL_HOST ); return ! $host || strcasecmp( (string) $host, (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) === 0; } }