diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 12ca0045b98b4..9347fd0de60c6 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -489,6 +489,9 @@ add_action( 'post_updated', 'wp_check_for_changed_dates', 12, 3 ); add_action( 'attachment_updated', 'wp_check_for_changed_dates', 12, 3 ); +// Redirect random content requests (disabled by default, see 'enable_random_content_redirect'). +add_action( 'template_redirect', 'wp_random_content_redirect' ); + // Nonce check for post previews. add_action( 'init', '_show_post_preview' ); diff --git a/src/wp-includes/query.php b/src/wp-includes/query.php index 60571c01cb880..cdd2ccef6e802 100644 --- a/src/wp-includes/query.php +++ b/src/wp-includes/query.php @@ -1235,6 +1235,122 @@ function _find_post_by_old_date( $post_type ) { return $id; } +/** + * Redirects requests for random content to a randomly selected published post. + * + * Handles requests using the `random` query parameter, e.g. `example.com/?random`. + * The `random_post_type` parameter restricts the pick to a post type (default `post`), + * and the `random_cat_id` parameter restricts it to a category (for post types using + * the `category` taxonomy). Only published, non-password-protected content from + * viewable post types is considered, and only GET/HEAD requests are handled. + * + * Disabled by default. To enable: + * + * add_filter( 'enable_random_content_redirect', '__return_true' ); + * + * @since 7.2.0 + */ +function wp_random_content_redirect() { + // The `random` parameter is a flag, present with or without a value. + if ( ! isset( $_GET['random'] ) ) { + return; + } + + /** + * Filters whether random content redirects are enabled. + * + * @since 7.2.0 + * + * @param bool $enabled Whether `?random` requests redirect to random content. Default false. + */ + if ( ! apply_filters( 'enable_random_content_redirect', false ) ) { + return; + } + + $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : ''; + + if ( ! in_array( $request_method, array( 'GET', 'HEAD' ), true ) ) { + return; + } + + $post_type = 'post'; + + if ( isset( $_GET['random_post_type'] ) ) { + $post_type = sanitize_key( wp_unslash( $_GET['random_post_type'] ) ); + } + + $post_type_object = get_post_type_object( $post_type ); + + if ( ! $post_type_object || ! is_post_type_viewable( $post_type_object ) ) { + return; + } + + $args = array( + 'post_type' => $post_type, + 'post_status' => 'publish', + 'has_password' => false, + 'posts_per_page' => 1, + 'orderby' => 'ID', + 'order' => 'ASC', + 'ignore_sticky_posts' => true, + 'update_post_term_cache' => false, + 'update_post_meta_cache' => false, + ); + + if ( isset( $_GET['random_cat_id'] ) ) { + $cat_id = (int) wp_unslash( $_GET['random_cat_id'] ); + + if ( $cat_id < 1 ) { + return; + } + + $args['cat'] = $cat_id; + } + + // Pick via COUNT plus a random OFFSET rather than `ORDER BY RAND()`, + // which randomizes and sorts every candidate row on each request. + $query = new WP_Query( $args ); + $post_count = (int) $query->found_posts; + + if ( $post_count > 1 ) { + $offset = wp_rand( 0, $post_count - 1 ); + + if ( $offset > 0 ) { + $args['offset'] = $offset; + $args['no_found_rows'] = true; + $args['cache_results'] = false; + + $query = new WP_Query( $args ); + } + } + + if ( empty( $query->posts ) ) { + return; + } + + $link = get_permalink( $query->posts[0] ); + + /** + * Filters the random content redirect URL. + * + * Returning a falsey value cancels the redirect. + * + * @since 7.2.0 + * + * @param string $link The redirect URL. + */ + $link = apply_filters( 'random_content_redirect_url', $link ); + + if ( ! $link ) { + return; + } + + // Temporary redirect, so clients and intermediary caches do not cache the randomly picked target. + nocache_headers(); + wp_safe_redirect( $link, 302 ); + exit; +} + /** * Set up global post data. * diff --git a/tests/phpunit/tests/rewrite/randomContentRedirect.php b/tests/phpunit/tests/rewrite/randomContentRedirect.php new file mode 100644 index 0000000000000..4653f3018304d --- /dev/null +++ b/tests/phpunit/tests/rewrite/randomContentRedirect.php @@ -0,0 +1,237 @@ +random_content_redirect_url = $url; + return false; + } + + public function test_hook_is_registered() { + $this->assertSame( 10, has_action( 'template_redirect', 'wp_random_content_redirect' ) ); + } + + public function test_disabled_by_default() { + self::factory()->post->create(); + + wp_random_content_redirect(); + $this->assertNull( $this->random_content_redirect_url ); + } + + public function test_no_random_parameter_does_not_redirect() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + unset( $_GET['random'] ); + + self::factory()->post->create(); + + wp_random_content_redirect(); + $this->assertNull( $this->random_content_redirect_url ); + } + + public function test_redirects_to_a_published_post() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + $post_ids = self::factory()->post->create_many( 3 ); + + wp_random_content_redirect(); + $this->assertContains( $this->random_content_redirect_url, array_map( 'get_permalink', $post_ids ) ); + } + + public function test_post_request_does_not_redirect() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + + self::factory()->post->create(); + + wp_random_content_redirect(); + $this->assertNull( $this->random_content_redirect_url ); + } + + public function test_unset_request_method_does_not_redirect() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + unset( $_SERVER['REQUEST_METHOD'] ); + + self::factory()->post->create(); + + wp_random_content_redirect(); + $this->assertNull( $this->random_content_redirect_url ); + } + + public function test_head_request_redirects() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + $_SERVER['REQUEST_METHOD'] = 'HEAD'; + + $post_id = self::factory()->post->create(); + + wp_random_content_redirect(); + $this->assertSame( get_permalink( $post_id ), $this->random_content_redirect_url ); + } + + public function test_excludes_password_protected_posts() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + $public_id = self::factory()->post->create(); + self::factory()->post->create_many( 2, array( 'post_password' => 'secret' ) ); + + wp_random_content_redirect(); + $this->assertSame( get_permalink( $public_id ), $this->random_content_redirect_url ); + } + + public function test_excludes_unpublished_posts() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + $public_id = self::factory()->post->create(); + self::factory()->post->create( array( 'post_status' => 'draft' ) ); + self::factory()->post->create( array( 'post_status' => 'private' ) ); + + wp_random_content_redirect(); + $this->assertSame( get_permalink( $public_id ), $this->random_content_redirect_url ); + } + + public function test_random_post_type_parameter() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + self::factory()->post->create(); + $page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + + $_GET['random_post_type'] = 'page'; + + wp_random_content_redirect(); + $this->assertSame( get_permalink( $page_id ), $this->random_content_redirect_url ); + } + + public function test_invalid_post_type_does_not_redirect() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + self::factory()->post->create(); + + $_GET['random_post_type'] = 'nonexistent_type'; + + wp_random_content_redirect(); + $this->assertNull( $this->random_content_redirect_url ); + } + + public function test_non_viewable_post_type_does_not_redirect() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + register_post_type( 'wptests_hidden', array( 'public' => false ) ); + + self::factory()->post->create(); + self::factory()->post->create( array( 'post_type' => 'wptests_hidden' ) ); + + $_GET['random_post_type'] = 'wptests_hidden'; + + wp_random_content_redirect(); + $this->assertNull( $this->random_content_redirect_url ); + } + + public function test_random_cat_id_parameter() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + $cat_id = self::factory()->category->create(); + $in_cat_id = self::factory()->post->create( array( 'post_category' => array( $cat_id ) ) ); + self::factory()->post->create_many( 3 ); + + $_GET['random_cat_id'] = (string) $cat_id; + + wp_random_content_redirect(); + $this->assertSame( get_permalink( $in_cat_id ), $this->random_content_redirect_url ); + } + + public function test_nonexistent_category_does_not_redirect() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + self::factory()->post->create(); + + $_GET['random_cat_id'] = '99999'; + + wp_random_content_redirect(); + $this->assertNull( $this->random_content_redirect_url ); + } + + /** + * @dataProvider data_invalid_category_ids + * + * @param string $cat_id Invalid category ID value. + */ + public function test_invalid_category_id_does_not_redirect( $cat_id ) { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + self::factory()->post->create(); + + $_GET['random_cat_id'] = $cat_id; + + wp_random_content_redirect(); + $this->assertNull( $this->random_content_redirect_url ); + } + + public function data_invalid_category_ids() { + return array( + 'zero' => array( '0' ), + 'non-numeric' => array( 'foo' ), + ); + } + + public function test_negated_category_id_of_existing_category_does_not_redirect() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + $cat_id = self::factory()->category->create(); + self::factory()->post->create( array( 'post_category' => array( $cat_id ) ) ); + + $_GET['random_cat_id'] = (string) ( -$cat_id ); + + wp_random_content_redirect(); + $this->assertNull( $this->random_content_redirect_url ); + } + + public function test_redirect_url_is_filterable() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + self::factory()->post->create(); + + add_filter( + 'random_content_redirect_url', + static function () { + return home_url( '/custom-target/' ); + }, + 9 + ); + + wp_random_content_redirect(); + $this->assertSame( home_url( '/custom-target/' ), $this->random_content_redirect_url ); + } + + public function test_no_posts_does_not_redirect() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + wp_random_content_redirect(); + $this->assertNull( $this->random_content_redirect_url ); + } + + public function test_single_post_redirects_to_it() { + add_filter( 'enable_random_content_redirect', '__return_true' ); + + $post_id = self::factory()->post->create(); + + wp_random_content_redirect(); + $this->assertSame( get_permalink( $post_id ), $this->random_content_redirect_url ); + } +}