diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 4b6d9de25fa11..600605ae88394 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -472,6 +472,9 @@ add_action( 'post_updated', 'wp_check_for_changed_slugs', 12, 3 ); add_action( 'attachment_updated', 'wp_check_for_changed_slugs', 12, 3 ); +// Redirect old term slugs. +add_action( 'template_redirect', 'wp_old_slug_term_redirect' ); + // Redirect old dates. add_action( 'post_updated', 'wp_check_for_changed_dates', 12, 3 ); add_action( 'attachment_updated', 'wp_check_for_changed_dates', 12, 3 ); diff --git a/src/wp-includes/query.php b/src/wp-includes/query.php index 592e70e0290a3..eb643e0da51e0 100644 --- a/src/wp-includes/query.php +++ b/src/wp-includes/query.php @@ -1255,3 +1255,129 @@ function generate_postdata( $post ) { return false; } + +/** + * Redirect old term slugs to the correct term link. + * + * Attempts to find the current term slug from the past slugs. + * + * @since x.x.x + */ +function wp_old_slug_term_redirect() { + if ( ! is_404() ) { + return; + } + + $taxonomy = ''; + $slug = ''; + + if ( '' !== get_query_var( 'category_name' ) ) { + $taxonomy = 'category'; + $slug = get_query_var( 'category_name' ); + } elseif ( '' !== get_query_var( 'tag' ) ) { + $taxonomy = 'post_tag'; + $slug = get_query_var( 'tag' ); + } elseif ( '' !== get_query_var( 'taxonomy' ) && '' !== get_query_var( 'term' ) ) { + $taxonomy = get_query_var( 'taxonomy' ); + $slug = get_query_var( 'term' ); + } + + if ( str_contains( $slug, '/' ) ) { + $slug = basename( $slug ); + } + + if ( '' === $taxonomy || '' === $slug ) { + return; + } + + $term_id = _find_term_by_old_slug( $slug, $taxonomy ); + + if ( ! $term_id ) { + return; + } + + /** + * Filters the old slug redirect term ID. + * + * @since x.x.x + * + * @param int $term_id The redirect term ID. + */ + $term_id = apply_filters( 'old_slug_redirect_term_id', $term_id ); + + if ( ! $term_id ) { + return; + } + + $link = get_term_link( (int) $term_id, $taxonomy ); + + if ( is_wp_error( $link ) ) { + return; + } + + if ( get_query_var( 'paged' ) > 1 ) { + if ( get_option( 'permalink_structure' ) ) { + $link = trailingslashit( $link ) . 'page/' . get_query_var( 'paged' ); + } else { + $link = add_query_arg( 'paged', get_query_var( 'paged' ), $link ); + } + } elseif ( is_feed() ) { + if ( get_option( 'permalink_structure' ) ) { + $link = trailingslashit( $link ) . 'feed'; + } else { + $link = add_query_arg( 'feed', get_default_feed(), $link ); + } + } + + /** + * Filters the old slug redirect URL. + * + * @since 4.4.0 + * + * @param string $link The redirect URL. + */ + $link = apply_filters( 'old_slug_redirect_url', $link ); + + if ( ! $link ) { + return; + } + + wp_redirect( $link, 301 ); + exit; +} + +/** + * Find the term ID for redirecting an old slug. + * + * @since x.x.x + * @access private + * + * @see wp_old_slug_term_redirect() + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $slug The term slug to search for. + * @param string $taxonomy The taxonomy to search within. + * @return int The term ID. + */ +function _find_term_by_old_slug( $slug, $taxonomy ) { + global $wpdb; + + $query = $wpdb->prepare( + "SELECT tm.term_id FROM $wpdb->termmeta AS tm INNER JOIN $wpdb->term_taxonomy AS tt ON tm.term_id = tt.term_id WHERE tm.meta_key = '_wp_old_slug' AND tm.meta_value = %s AND tt.taxonomy = %s", + $slug, + $taxonomy + ); + + $last_changed = wp_cache_get_last_changed( 'terms' ); + $key = md5( $query ); + $cache_key = "find_term_by_old_slug:$key"; + $cache = wp_cache_get_salted( $cache_key, 'term-queries', $last_changed ); + if ( false !== $cache ) { + return (int) $cache; + } + + $term_id = (int) $wpdb->get_var( $query ); + wp_cache_set_salted( $cache_key, $term_id, 'term-queries', $last_changed ); + + return $term_id; +} diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 80f457de0e6f7..46a796f92fe94 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -3337,6 +3337,20 @@ function wp_update_term( $term_id, $taxonomy, $args = array() ) { $tt_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT tt.term_taxonomy_id FROM $wpdb->term_taxonomy AS tt INNER JOIN $wpdb->terms AS t ON tt.term_id = t.term_id WHERE tt.taxonomy = %s AND t.term_id = %d", $taxonomy, $term_id ) ); + // Check for changed slugs and save the old slug. + $old_slug = $term['slug']; + if ( $old_slug !== $slug && ! empty( $old_slug ) ) { + $old_slugs = (array) get_term_meta( $term_id, '_wp_old_slug' ); + + if ( ! in_array( $old_slug, $old_slugs, true ) ) { + add_term_meta( $term_id, '_wp_old_slug', $old_slug ); + } + + if ( in_array( $slug, $old_slugs, true ) ) { + delete_term_meta( $term_id, '_wp_old_slug', $slug ); + } + } + // Check whether this is a shared term that needs splitting. $_term_id = _split_shared_term( $term_id, $tt_id ); if ( ! is_wp_error( $_term_id ) ) { diff --git a/tests/phpunit/tests/rewrite/oldSlugTermRedirect.php b/tests/phpunit/tests/rewrite/oldSlugTermRedirect.php new file mode 100644 index 0000000000000..a1fd5e6a9ec6b --- /dev/null +++ b/tests/phpunit/tests/rewrite/oldSlugTermRedirect.php @@ -0,0 +1,315 @@ +set_permalink_structure( '/%postname%/' ); + + update_option( 'category_base', 'category' ); + update_option( 'tag_base', 'tag' ); + + global $wp_rewrite; + $category_base = get_option( 'category_base' ) ? get_option( 'category_base' ) : 'category'; + $tag_base = get_option( 'tag_base' ) ? get_option( 'tag_base' ) : 'tag'; + $wp_rewrite->add_permastruct( + 'category', + $wp_rewrite->front . $category_base . '/%' . 'category' . '%', + array( 'ep_mask' => EP_CATEGORIES ) + ); + $wp_rewrite->add_permastruct( + 'post_tag', + $wp_rewrite->front . $tag_base . '/%' . 'post_tag' . '%', + array( 'ep_mask' => EP_TAGS ) + ); + + flush_rewrite_rules( true ); + } + + public function tear_down() { + $this->old_slug_redirect_url = null; + + parent::tear_down(); + } + + public function filter_old_slug_redirect_url( $url ) { + $this->old_slug_redirect_url = $url; + return false; + } + + /** + * Tests that changing a category slug redirects the old URL to the new one. + */ + public function test_old_slug_term_redirect_category() { + $term_id = self::factory()->term->create( + array( + 'taxonomy' => 'category', + 'name' => 'Test Category', + 'slug' => 'old-cat', + ) + ); + + $old_link = get_term_link( $term_id, 'category' ); + + wp_update_term( + $term_id, + 'category', + array( + 'slug' => 'new-cat', + ) + ); + + $old_slugs = get_term_meta( $term_id, '_wp_old_slug', false ); + $this->assertContains( 'old-cat', $old_slugs ); + + $new_link = get_term_link( $term_id, 'category' ); + + $this->go_to( home_url( '/?category_name=old-cat' ) ); + + $this->assertTrue( is_404(), 'Should be a 404' ); + $cat_name = get_query_var( 'category_name' ); + $this->assertSame( 'old-cat', $cat_name ); + + $found = _find_term_by_old_slug( 'old-cat', 'category' ); + $this->assertSame( $term_id, $found, 'Should find term by old slug' ); + + wp_old_slug_term_redirect(); + $this->assertSame( $new_link, $this->old_slug_redirect_url ); + } + + /** + * Tests that changing a tag slug redirects the old URL to the new one. + */ + public function test_old_slug_term_redirect_tag() { + $term_id = self::factory()->term->create( + array( + 'taxonomy' => 'post_tag', + 'name' => 'Test Tag', + 'slug' => 'old-tag', + ) + ); + + $old_link = get_term_link( $term_id, 'post_tag' ); + + wp_update_term( + $term_id, + 'post_tag', + array( + 'slug' => 'new-tag', + ) + ); + + $old_slugs = get_term_meta( $term_id, '_wp_old_slug', false ); + $this->assertContains( 'old-tag', $old_slugs ); + + $new_link = get_term_link( $term_id, 'post_tag' ); + + $this->go_to( home_url( '/?tag=old-tag' ) ); + + $this->assertTrue( is_404(), 'Should be a 404' ); + $tag = get_query_var( 'tag' ); + $this->assertSame( 'old-tag', $tag ); + + $found = _find_term_by_old_slug( 'old-tag', 'post_tag' ); + $this->assertSame( $term_id, $found, 'Should find term by old slug' ); + + wp_old_slug_term_redirect(); + $this->assertSame( $new_link, $this->old_slug_redirect_url ); + } + + /** + * Tests that changing a custom taxonomy term slug redirects the old URL to the new one. + */ + public function test_old_slug_term_redirect_custom_taxonomy() { + register_taxonomy( + 'wptests_tax', + 'post', + array( + 'public' => true, + 'hierarchical' => false, + 'rewrite' => array( 'slug' => 'wptests-tax' ), + ) + ); + + flush_rewrite_rules( true ); + + $term_id = self::factory()->term->create( + array( + 'taxonomy' => 'wptests_tax', + 'name' => 'Old Term', + 'slug' => 'old-term', + ) + ); + + $old_link = get_term_link( $term_id, 'wptests_tax' ); + + wp_update_term( + $term_id, + 'wptests_tax', + array( + 'slug' => 'new-term', + ) + ); + + $new_link = get_term_link( $term_id, 'wptests_tax' ); + + $this->go_to( $old_link ); + wp_old_slug_term_redirect(); + $this->assertSame( $new_link, $this->old_slug_redirect_url ); + + _unregister_taxonomy( 'wptests_tax' ); + } + + /** + * Tests that changing a hierarchical taxonomy term slug redirects the old URL to the new one. + */ + public function test_old_slug_term_redirect_hierarchical_taxonomy() { + register_taxonomy( + 'wptests_hier_tax', + 'post', + array( + 'public' => true, + 'hierarchical' => true, + 'rewrite' => array( + 'slug' => 'wptests-hier-tax', + 'hierarchical' => true, + ), + ) + ); + + flush_rewrite_rules( true ); + + $parent_id = self::factory()->term->create( + array( + 'taxonomy' => 'wptests_hier_tax', + 'name' => 'Parent Term', + 'slug' => 'parent', + ) + ); + + $term_id = self::factory()->term->create( + array( + 'taxonomy' => 'wptests_hier_tax', + 'name' => 'Child Term', + 'slug' => 'child', + 'parent' => $parent_id, + ) + ); + + $old_link = get_term_link( $term_id, 'wptests_hier_tax' ); + + wp_update_term( + $term_id, + 'wptests_hier_tax', + array( + 'slug' => 'new-child', + ) + ); + + $new_link = get_term_link( $term_id, 'wptests_hier_tax' ); + + $this->go_to( $old_link ); + wp_old_slug_term_redirect(); + $this->assertSame( $new_link, $this->old_slug_redirect_url ); + + _unregister_taxonomy( 'wptests_hier_tax' ); + } + + /** + * Tests that no redirect occurs when the old slug is reused by another term. + */ + public function test_old_slug_doesnt_redirect_when_term_reused() { + $term_id = self::factory()->term->create( + array( + 'taxonomy' => 'category', + 'name' => 'First Category', + 'slug' => 'first-category', + ) + ); + + $old_link = get_term_link( $term_id, 'category' ); + + wp_update_term( + $term_id, + 'category', + array( + 'slug' => 'renamed-category', + ) + ); + + $new_term_id = self::factory()->term->create( + array( + 'taxonomy' => 'category', + 'name' => 'First Category', + 'slug' => 'first-category', + ) + ); + + $this->go_to( $old_link ); + wp_old_slug_term_redirect(); + $this->assertNull( $this->old_slug_redirect_url ); + } + + /** + * Tests that old slugs are stored in term meta and accumulate correctly. + */ + public function test_old_slug_stored_in_term_meta() { + $term_id = self::factory()->term->create( + array( + 'taxonomy' => 'category', + 'name' => 'Test Category', + 'slug' => 'slug-1', + ) + ); + + // Change slug: slug-1 -> slug-2. + wp_update_term( + $term_id, + 'category', + array( + 'slug' => 'slug-2', + ) + ); + + $old_slugs = get_term_meta( $term_id, '_wp_old_slug', false ); + $this->assertContains( 'slug-1', $old_slugs ); + + // Change slug: slug-2 -> slug-3. + wp_update_term( + $term_id, + 'category', + array( + 'slug' => 'slug-3', + ) + ); + + $old_slugs = get_term_meta( $term_id, '_wp_old_slug', false ); + $this->assertContains( 'slug-1', $old_slugs ); + $this->assertContains( 'slug-2', $old_slugs ); + + // Change slug: slug-3 -> slug-1 (reusing slug-1). + wp_update_term( + $term_id, + 'category', + array( + 'slug' => 'slug-1', + ) + ); + + // slug-1 is now current, so should be removed from old slugs. + $old_slugs = get_term_meta( $term_id, '_wp_old_slug', false ); + $this->assertNotContains( 'slug-1', $old_slugs ); + $this->assertContains( 'slug-2', $old_slugs ); + $this->assertContains( 'slug-3', $old_slugs ); + } +}