Make WordPress Core

Changeset 65250


Ignore:
Timestamp:
11/03/2026 08:34:25 PM (15 hours ago)
Author:
jorbin
Message:

Rewrites: Avoid fatal errors when flushing rules for random content.

wp_random_content_flush_rewrite_rules_for_post() runs on the wp_after_insert_post and after_delete_post actions, which third-party code may fire with a missing or incorrectly typed $post. Also make the $post parameter of _update_posts_count_on_delete() optional for the same single-argument after_delete_post calls.

Developed in: ​https://github.com/WordPress/wordpress-develop/pull/14356

Follow-up to r64402.

Props jorbin, bradshawtm, therssoftware.
Fixes #64498.

Location:
trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/ms-blogs.php

    r64304 r65250  
    890890 * @since 4.0.0
    891891 * @since 6.2.0 Added the `$post` parameter.
    892  *
    893  * @param int     $post_id Post ID.
    894  * @param WP_Post $post    Post object.
    895  */
    896 function _update_posts_count_on_delete( $post_id, $post ) {
    897         if ( ! $post || 'publish' !== $post->post_status || 'post' !== $post->post_type ) {
     892 * @since 7.2.0 The `$post` parameter isn't always guaranteed to be provided.
     893 *
     894 * @param int          $post_id Post ID.
     895 * @param WP_Post|null $post    Optional. Post object. Default null.
     896 */
     897function _update_posts_count_on_delete( $post_id, $post = null ) {
     898        if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status || 'post' !== $post->post_type ) {
    898899                return;
    899900        }
  • trunk/src/wp-includes/query.php

    r65042 r65250  
    15141514 *
    15151515 * Runs on the {@see 'wp_after_insert_post'} and {@see 'after_delete_post'} actions.
     1516 * Third-party code may fire these actions with unexpected values, so the parameters are
     1517 * not type declared. When `$post` is omitted it is looked up from `$post_id`. Passing
     1518 * anything other than a `WP_Post` as `$post`, or a post that cannot be looked up, is
     1519 * incorrect usage and nothing is flushed. `$post_before` is only considered on
     1520 * `wp_after_insert_post`, where passing anything other than a `WP_Post` or null is
     1521 * incorrect usage and the value is ignored.
    15161522 *
    15171523 * @since 7.2.0
    … …  
    15211527 *
    15221528 * @param int          $post_id     Post ID.
    1523  * @param WP_Post      $post        Post object.
     1529 * @param WP_Post|null $post        Optional. Post object. Looked up from `$post_id` when null. Default null.
    15241530 * @param bool         $update      Optional. Whether this is an existing post being updated. Default false.
    15251531 * @param WP_Post|null $post_before Optional. Post object before the update, null for new posts. Default null.
    15261532 */
    1527 function wp_random_content_flush_rewrite_rules_for_post( int $post_id, WP_Post $post, bool $update = false, ?WP_Post $post_before = null ): void {
     1533function wp_random_content_flush_rewrite_rules_for_post( $post_id, $post = null, $update = false, $post_before = null ): void {
    15281534        global $wp_rewrite;
     1535
     1536        // Only look up a real ID: get_post() falls back to the global post for an empty value.
     1537        if ( null === $post && (int) $post_id > 0 ) {
     1538                $post = get_post( (int) $post_id );
     1539        }
     1540
     1541        if ( ! $post instanceof WP_Post ) {
     1542                _doing_it_wrong(
     1543                        __FUNCTION__,
     1544                        sprintf(
     1545                                /* translators: 1: $post, 2: WP_Post */
     1546                                __( 'The %1$s argument must be a %2$s object.' ),
     1547                                '<code>$post</code>',
     1548                                '<code>WP_Post</code>'
     1549                        ),
     1550                        '7.2.0'
     1551                );
     1552                return;
     1553        }
     1554
     1555        $posts = array( $post );
     1556
     1557        // Only wp_after_insert_post passes the previous post object, which matters when it was renamed or moved.
     1558        if ( 'wp_after_insert_post' === current_action() ) {
     1559                if ( $post_before instanceof WP_Post ) {
     1560                        $posts[] = $post_before;
     1561                } elseif ( null !== $post_before ) {
     1562                        _doing_it_wrong(
     1563                                __FUNCTION__,
     1564                                sprintf(
     1565                                        /* translators: 1: $post_before, 2: WP_Post */
     1566                                        __( 'The %1$s argument must be a %2$s object or null.' ),
     1567                                        '<code>$post_before</code>',
     1568                                        '<code>WP_Post</code>'
     1569                                ),
     1570                                '7.2.0'
     1571                        );
     1572                }
     1573        }
    15291574
    15301575        if ( ! $wp_rewrite->using_permalinks() || ! wp_is_random_content_redirect_enabled() ) {
    … …  
    15321577        }
    15331578
    1534         foreach ( array( $post, $post_before ) as $page ) {
     1579        foreach ( $posts as $page ) {
    15351580                if (
    1536                         $page instanceof WP_Post
    1537                         && $wp_rewrite->get_random_base() === $page->post_name
     1581                        $wp_rewrite->get_random_base() === $page->post_name
    15381582                        && (
    15391583                                ( 'page' === $page->post_type && 0 === (int) $page->post_parent )
  • trunk/tests/phpunit/tests/query/randomContentRedirect.php

    r65042 r65250  
    250250                        )
    251251                );
     252        }
     253
     254        /**
     255         * Creates a published post and returns the post object.
     256         *
     257         * @param array<string, mixed> $args Optional. Post arguments.
     258         * @return WP_Post Post object.
     259         */
     260        protected function create_post_object( array $args = array() ): WP_Post {
     261                $post = get_post( $this->create_post( $args ) );
     262
     263                $this->assertInstanceOf( WP_Post::class, $post );
     264
     265                return $post;
    252266        }
    253267
    … …  
    14071421        }
    14081422
     1423        /**
     1424         * @dataProvider data_invalid_arguments
     1425         *
     1426         * @param array<int, mixed> $args The arguments to call the function with.
     1427         */
     1428        public function test_invalid_arguments_do_not_flush_rewrite_rules( array $args ): void {
     1429                // The data provider cannot query the database, so PHP_INT_MAX stands in for a nonexistent post ID.
     1430                $args = array_map(
     1431                        function ( $arg ) {
     1432                                return PHP_INT_MAX === $arg ? $this->get_nonexistent_post_id() : $arg;
     1433                        },
     1434                        $args
     1435                );
     1436
     1437                $this->setExpectedIncorrectUsage( 'wp_random_content_flush_rewrite_rules_for_post' );
     1438
     1439                $flushes = new MockAction();
     1440                add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) );
     1441
     1442                wp_random_content_flush_rewrite_rules_for_post( ...$args ); // @phpstan-ignore argument.type (Intentionally passing invalid values.)
     1443
     1444                $this->assertSame( 0, $flushes->get_call_count(), 'Invalid arguments should not flush the rewrite rules.' );
     1445        }
     1446
     1447        /**
     1448         * Data provider.
     1449         *
     1450         * Core never fires the `wp_after_insert_post` and `after_delete_post` actions with invalid
     1451         * arguments, but third-party code can. Doing so is incorrect usage and must not cause a
     1452         * fatal error.
     1453         *
     1454         * `PHP_INT_MAX` is replaced with a post ID that is verified not to exist when the test runs.
     1455         *
     1456         * @return array<string, array{array<int, mixed>}>
     1457         */
     1458        public function data_invalid_arguments(): array {
     1459                return array(
     1460                        'nulls'                              => array( array( null, null, false, null ) ),
     1461                        'strings'                            => array( array( 'random', 'random', 'yes', 'random' ) ),
     1462                        'arrays'                             => array( array( array(), array( 'post_name' => 'random' ), 1, array() ) ),
     1463                        'nulls without optional values'      => array( array( null, null ) ),
     1464                        'strings without optional values'    => array( array( 'random', 'random' ) ),
     1465                        'post ID of zero without a post'     => array( array( 0 ) ),
     1466                        'nonexistent post ID without a post' => array( array( PHP_INT_MAX ) ),
     1467                );
     1468        }
     1469
     1470        /**
     1471         * Returns a post ID that does not exist.
     1472         *
     1473         * Starts at `PHP_INT_MAX` and decrements until no post is found.
     1474         *
     1475         * @return int Post ID.
     1476         */
     1477        protected function get_nonexistent_post_id(): int {
     1478                $post_id = PHP_INT_MAX;
     1479
     1480                while ( get_post( $post_id ) instanceof WP_Post ) {
     1481                        --$post_id;
     1482                }
     1483
     1484                return $post_id;
     1485        }
     1486
     1487        /**
     1488         * Tests that an omitted post object does not fall back to the global post.
     1489         *
     1490         * `get_post()` returns the global post for an empty value, which would flush the
     1491         * rewrite rules for whatever post is being displayed rather than the one passed in.
     1492         */
     1493        public function test_omitted_post_does_not_fall_back_to_the_global_post(): void {
     1494                $this->setExpectedIncorrectUsage( 'wp_random_content_flush_rewrite_rules_for_post' );
     1495
     1496                $GLOBALS['post'] = $this->create_post_object(
     1497                        array(
     1498                                'post_type' => 'page',
     1499                                'post_name' => 'random',
     1500                        )
     1501                );
     1502
     1503                $flushes = new MockAction();
     1504                add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) );
     1505
     1506                wp_random_content_flush_rewrite_rules_for_post( null ); // @phpstan-ignore argument.type (Intentionally omitting the post ID.)
     1507
     1508                $this->assertSame( 0, $flushes->get_call_count(), 'An omitted post should not be looked up from the global post.' );
     1509        }
     1510
     1511        /**
     1512         * Tests that an omitted post object is looked up from the post ID.
     1513         *
     1514         * The `$post` parameter was added to `after_delete_post` in 5.5.0, but third-party
     1515         * code written against the original signature may still fire the action with only
     1516         * the post ID.
     1517         */
     1518        public function test_omitted_post_is_looked_up_from_the_post_id(): void {
     1519                $page_id = $this->create_post(
     1520                        array(
     1521                                'post_type' => 'page',
     1522                                'post_name' => 'random',
     1523                        )
     1524                );
     1525
     1526                $flushes = new MockAction();
     1527                add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) );
     1528
     1529                wp_random_content_flush_rewrite_rules_for_post( $page_id );
     1530
     1531                $this->assertSame( 1, $flushes->get_call_count(), 'A page at the random base should flush the rewrite rules when only its ID is passed.' );
     1532        }
     1533
     1534        /**
     1535         * Tests that firing `after_delete_post` with only a post ID does not cause a fatal error.
     1536         *
     1537         * As the post is already deleted, it cannot be looked up from the ID, so the call is
     1538         * incorrect usage and the rewrite rules are left alone.
     1539         *
     1540         * @covers ::_update_posts_count_on_delete
     1541         */
     1542        public function test_after_delete_post_fired_with_only_a_post_id_does_not_flush_rewrite_rules(): void {
     1543                $post_id = $this->create_post();
     1544                wp_delete_post( $post_id, true );
     1545
     1546                $this->setExpectedIncorrectUsage( 'wp_random_content_flush_rewrite_rules_for_post' );
     1547
     1548                $flushes = new MockAction();
     1549                add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) );
     1550
     1551                do_action( 'after_delete_post', $post_id ); // @phpstan-ignore WordPress.hookParamCountMismatch (Intentionally omitting the post object.)
     1552
     1553                $this->assertSame( 0, $flushes->get_call_count(), 'Firing after_delete_post for a deleted post without the post object should not flush the rewrite rules.' );
     1554        }
     1555
     1556        public function test_page_named_random_still_flushes_rewrite_rules_when_the_other_arguments_are_invalid(): void {
     1557                $page = $this->create_post_object(
     1558                        array(
     1559                                'post_type' => 'page',
     1560                                'post_name' => 'random',
     1561                        )
     1562                );
     1563
     1564                $this->setExpectedIncorrectUsage( 'wp_random_content_flush_rewrite_rules_for_post' );
     1565
     1566                $flushes = new MockAction();
     1567                add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) );
     1568
     1569                do_action( 'wp_after_insert_post', (string) $page->ID, $page, 'yes', false );
     1570
     1571                $this->assertSame( 1, $flushes->get_call_count(), 'A page at the random base should flush the rewrite rules even when the other arguments are invalid.' );
     1572        }
     1573
     1574        /**
     1575         * Tests that the previous post object is only considered on `wp_after_insert_post`.
     1576         *
     1577         * `after_delete_post` has no previous post, so a value passed in its place is ignored
     1578         * rather than reported as incorrect usage.
     1579         */
     1580        public function test_post_before_is_ignored_outside_wp_after_insert_post(): void {
     1581                $page_before = $this->create_post_object(
     1582                        array(
     1583                                'post_type' => 'page',
     1584                                'post_name' => 'random',
     1585                        )
     1586                );
     1587                $post        = $this->create_post_object();
     1588
     1589                $flushes = new MockAction();
     1590                add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) );
     1591
     1592                wp_random_content_flush_rewrite_rules_for_post( $post->ID, $post, false, 'not a post' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.)
     1593                wp_random_content_flush_rewrite_rules_for_post( $post->ID, $post, false, $page_before );
     1594
     1595                $this->assertSame( 0, $flushes->get_call_count(), 'The previous post should not be considered outside wp_after_insert_post.' );
     1596        }
     1597
     1598        public function test_renamed_page_at_random_base_flushes_rewrite_rules_via_post_before(): void {
     1599                $page_before = $this->create_post_object(
     1600                        array(
     1601                                'post_type' => 'page',
     1602                                'post_name' => 'random',
     1603                        )
     1604                );
     1605                $post        = $this->create_post_object();
     1606
     1607                $flushes = new MockAction();
     1608                add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) );
     1609
     1610                do_action( 'wp_after_insert_post', $post->ID, $post, true, $page_before );
     1611
     1612                $this->assertSame( 1, $flushes->get_call_count(), 'A page previously at the random base should flush the rewrite rules on wp_after_insert_post.' );
     1613        }
     1614
    14091615        /*
    14101616         * Opting out.
Note: See TracChangeset for help on using the changeset viewer.