diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index 512d8f2f1d..06b2ce2397 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -1926,18 +1926,23 @@ function post_reply_link( $args = array(), $post = null ) { * Retrieves HTML content for cancel comment reply link. * * @since 2.7.0 + * @since 6.2.0 Added the `$post` parameter. * - * @param string $text Optional. Text to display for cancel reply link. If empty, - * defaults to 'Click here to cancel reply'. Default empty. + * @param string $text Optional. Text to display for cancel reply link. If empty, + * defaults to 'Click here to cancel reply'. Default empty. + * @param int|WP_Post|null $post Optional. The post the comment thread is being + * displayed for. Defaults to the current global post. * @return string */ -function get_cancel_comment_reply_link( $text = '' ) { +function get_cancel_comment_reply_link( $text = '', $post = null ) { if ( empty( $text ) ) { $text = __( 'Click here to cancel reply.' ); } - $style = isset( $_GET['replytocom'] ) ? '' : ' style="display:none;"'; - $link = esc_html( remove_query_arg( array( 'replytocom', 'unapproved', 'moderation-hash' ) ) ) . '#respond'; + $post = get_post( $post ); + $reply_to_id = $post ? _get_comment_reply_id( $post->ID ) : 0; + $style = 0 !== $reply_to_id ? '' : ' style="display:none;"'; + $link = esc_html( remove_query_arg( array( 'replytocom', 'unapproved', 'moderation-hash' ) ) ) . '#respond'; $formatted_link = '' . $text . ''; @@ -1969,16 +1974,20 @@ function cancel_comment_reply_link( $text = '' ) { * Retrieves hidden input HTML for replying to comments. * * @since 3.0.0 + * @since 6.2.0 Renamed `$post_id` to `$post` and added WP_Post support. * - * @param int $post_id Optional. Post ID. Defaults to the current post ID. + * @param int|WP_Post|null $post Optional. The post the comment is being displayed for. + * Defaults to the current global post. * @return string Hidden input HTML for replying to comments. */ -function get_comment_id_fields( $post_id = 0 ) { - if ( empty( $post_id ) ) { - $post_id = get_the_ID(); +function get_comment_id_fields( $post = null ) { + $post = get_post( $post ); + if ( ! $post ) { + return ''; } - $reply_to_id = isset( $_GET['replytocom'] ) ? (int) $_GET['replytocom'] : 0; + $post_id = $post->ID; + $reply_to_id = _get_comment_reply_id( $post_id ); $result = "\n"; $result .= "\n"; @@ -2003,13 +2012,15 @@ function get_comment_id_fields( $post_id = 0 ) { * This tag must be within the `
` section of the `comments.php` template. * * @since 2.7.0 + * @since 6.2.0 Renamed `$post_id` to `$post` and added WP_Post support. * * @see get_comment_id_fields() * - * @param int $post_id Optional. Post ID. Defaults to the current post ID. + * @param int|WP_Post|null $post Optional. The post the comment is being displayed for. + * Defaults to the current global post. */ -function comment_id_fields( $post_id = 0 ) { - echo get_comment_id_fields( $post_id ); +function comment_id_fields( $post = null ) { + echo get_comment_id_fields( $post ); } /** @@ -2021,20 +2032,19 @@ function comment_id_fields( $post_id = 0 ) { * comment. See https://core.trac.wordpress.org/changeset/36512. * * @since 2.7.0 + * @since 6.2.0 Added the `$post` parameter. * - * @global WP_Comment $comment Global comment object. - * - * @param string|false $no_reply_text Optional. Text to display when not replying to a comment. - * Default false. - * @param string|false $reply_text Optional. Text to display when replying to a comment. - * Default false. Accepts "%s" for the author of the comment - * being replied to. - * @param bool $link_to_parent Optional. Boolean to control making the author's name a link - * to their comment. Default true. + * @param string|false $no_reply_text Optional. Text to display when not replying to a comment. + * Default false. + * @param string|false $reply_text Optional. Text to display when replying to a comment. + * Default false. Accepts "%s" for the author of the comment + * being replied to. + * @param bool $link_to_parent Optional. Boolean to control making the author's name a link + * to their comment. Default true. + * @param int|WP_Post|null $post Optional. The post that the comment form is being displayed for. + * Defaults to the current global post. */ -function comment_form_title( $no_reply_text = false, $reply_text = false, $link_to_parent = true ) { - global $comment; - +function comment_form_title( $no_reply_text = false, $reply_text = false, $link_to_parent = true, $post = null ) { if ( false === $no_reply_text ) { $no_reply_text = __( 'Leave a Reply' ); } @@ -2044,22 +2054,64 @@ function comment_form_title( $no_reply_text = false, $reply_text = false, $link_ $reply_text = __( 'Leave a Reply to %s' ); } - $reply_to_id = isset( $_GET['replytocom'] ) ? (int) $_GET['replytocom'] : 0; - - if ( 0 == $reply_to_id ) { + $post = get_post( $post ); + if ( ! $post ) { echo $no_reply_text; - } else { - // Sets the global so that template tags can be used in the comment form. - $comment = get_comment( $reply_to_id ); - - if ( $link_to_parent ) { - $author = '' . get_comment_author( $comment ) . ''; - } else { - $author = get_comment_author( $comment ); - } - - printf( $reply_text, $author ); + return; } + + $reply_to_id = _get_comment_reply_id( $post->ID ); + + if ( 0 === $reply_to_id ) { + echo $no_reply_text; + return; + } + + if ( $link_to_parent ) { + $author = '' . get_comment_author( $reply_to_id ) . ''; + } else { + $author = get_comment_author( $reply_to_id ); + } + + printf( $reply_text, $author ); +} + +/** + * Gets the comment's reply to ID from the $_GET['replytocom']. + * + * @since 6.2.0 + * + * @access private + * + * @param int|WP_Post $post The post the comment is being displayed for. + * Defaults to the current global post. + * @return int Comment's reply to ID. + */ +function _get_comment_reply_id( $post = null ) { + $post = get_post( $post ); + + if ( ! $post || ! isset( $_GET['replytocom'] ) || ! is_numeric( $_GET['replytocom'] ) ) { + return 0; + } + + $reply_to_id = (int) $_GET['replytocom']; + + /* + * Validate the comment. + * Bail out if it does not exist, is not approved, or its + * `comment_post_ID` does not match the given post ID. + */ + $comment = get_comment( $reply_to_id ); + + if ( + ! $comment instanceof WP_Comment || + 0 === (int) $comment->comment_approved || + $post->ID !== (int) $comment->comment_post_ID + ) { + return 0; + } + + return $reply_to_id; } /** @@ -2570,7 +2622,7 @@ function comment_form( $args = array(), $post = null ) { comment_approved + ) + ) { + /** + * Fires when a comment reply is attempted to an unapproved comment. + * + * @since 6.2.0 + * + * @param int $comment_post_id Post ID. + * @param int $comment_parent Parent comment ID. + */ + do_action( 'comment_reply_to_unapproved_comment', $comment_post_id, $comment_parent ); + + return new WP_Error( 'comment_reply_to_unapproved_comment', __( 'Sorry, replies to unapproved comments are not allowed.' ), 403 ); + } } $post = get_post( $comment_post_id ); @@ -3560,7 +3581,6 @@ function wp_handle_comment_submission( $comment_data ) { return new WP_Error( 'comment_on_password_protected' ); } else { - /** * Fires before a comment is posted. * @@ -3569,7 +3589,6 @@ function wp_handle_comment_submission( $comment_data ) { * @param int $comment_post_id Post ID. */ do_action( 'pre_comment_on_post', $comment_post_id ); - } // If the user is logged in. diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index 641b055ad4..a55ddd59e7 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -377,6 +377,430 @@ class Tests_Comment extends WP_UnitTestCase { $this->assertSame( array(), $found ); } + /** + * Tests that get_cancel_comment_reply_link() returns the expected value. + * + * @ticket 53962 + * + * @dataProvider data_get_cancel_comment_reply_link + * + * @covers ::get_cancel_comment_reply_link + * + * @param string $text Text to display for cancel reply link. + * If empty, defaults to 'Click here to cancel reply'. + * @param string|int $post The post the comment thread is being displayed for. + * Accepts 'POST_ID', 'POST', or an integer post ID. + * @param int|bool|null $replytocom A comment ID (int), whether to generate an approved (true) or unapproved (false) comment, + * or null not to create a comment. + * @param string $expected The expected reply link. + */ + public function test_get_cancel_comment_reply_link( $text, $post, $replytocom, $expected ) { + if ( 'POST_ID' === $post ) { + $post = self::$post_id; + } elseif ( 'POST' === $post ) { + $post = self::factory()->post->get_object_by_id( self::$post_id ); + } + + if ( null === $replytocom ) { + unset( $_GET['replytocom'] ); + } else { + $_GET['replytocom'] = $this->create_comment_with_approval_status( $replytocom ); + } + + $this->assertSame( $expected, get_cancel_comment_reply_link( $text, $post ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_get_cancel_comment_reply_link() { + return array( + 'text as empty string, a valid post ID and an approved comment' => array( + 'text' => '', + 'post' => 'POST_ID', + 'replytocom' => true, + 'expected' => 'Click here to cancel reply.', + ), + 'text as a custom string, a valid post ID and an approved comment' => array( + 'text' => 'Leave a reply!', + 'post' => 'POST_ID', + 'replytocom' => true, + 'expected' => 'Leave a reply!', + ), + 'text as empty string, a valid WP_Post object and an approved comment' => array( + 'text' => '', + 'post' => 'POST', + 'replytocom' => true, + 'expected' => 'Click here to cancel reply.', + ), + 'text as a custom string, a valid WP_Post object and an approved comment' => array( + 'text' => 'Leave a reply!', + 'post' => 'POST', + 'replytocom' => true, + 'expected' => 'Leave a reply!', + ), + 'text as empty string, an invalid post and an approved comment' => array( + 'text' => '', + 'post' => -99999, + 'replytocom' => true, + 'expected' => '', + ), + 'text as a custom string, a valid post, but no replytocom' => array( + 'text' => 'Leave a reply!', + 'post' => 'POST', + 'replytocom' => null, + 'expected' => '', + ), + ); + } + + /** + * Tests that comment_form_title() outputs the author of an approved comment. + * + * @ticket 53962 + * + * @covers ::comment_form_title + */ + public function test_should_output_the_author_of_an_approved_comment() { + // Must be set for `comment_form_title()`. + $_GET['replytocom'] = $this->create_comment_with_approval_status( true ); + + $comment = get_comment( $_GET['replytocom'] ); + comment_form_title( false, false, false, self::$post_id ); + + $this->assertInstanceOf( + 'WP_Comment', + $comment, + 'The comment is not an instance of WP_Comment.' + ); + + $this->assertObjectHasAttribute( + 'comment_author', + $comment, + 'The comment object does not have a "comment_author" property.' + ); + + $this->assertIsString( + $comment->comment_author, + 'The "comment_author" is not a string.' + ); + + $this->expectOutputString( + 'Leave a Reply to ' . $comment->comment_author, + 'The expected string was not output.' + ); + } + + /** + * Tests that get_comment_id_fields() allows replying to an approved comment. + * + * @ticket 53962 + * + * @dataProvider data_should_allow_reply_to_an_approved_comment + * + * @covers ::get_comment_id_fields + * + * @param string $comment_post The post of the comment. + * Accepts 'POST', 'NEW_POST', 'POST_ID' and 'NEW_POST_ID'. + */ + public function test_should_allow_reply_to_an_approved_comment( $comment_post ) { + // Must be set for `get_comment_id_fields()`. + $_GET['replytocom'] = $this->create_comment_with_approval_status( true ); + + if ( 'POST_ID' === $comment_post ) { + $comment_post = self::$post_id; + } elseif ( 'POST' === $comment_post ) { + $comment_post = self::factory()->post->get_object_by_id( self::$post_id ); + } + + $expected = "\n"; + $expected .= "\n"; + $actual = get_comment_id_fields( $comment_post ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_allow_reply_to_an_approved_comment() { + return array( + 'a post ID' => array( 'comment_post' => 'POST_ID' ), + 'a WP_Post object' => array( 'comment_post' => 'POST' ), + ); + } + + /** + * Tests that get_comment_id_fields() returns an empty string + * when the post cannot be retrieved. + * + * @ticket 53962 + * + * @dataProvider data_non_existent_posts + * + * @covers ::get_comment_id_fields + * + * @param bool $replytocom Whether to create an approved (true) or unapproved (false) comment. + * @param int $comment_post The post of the comment. + * + */ + public function test_should_return_empty_string( $replytocom, $comment_post ) { + if ( is_bool( $replytocom ) ) { + $replytocom = $this->create_comment_with_approval_status( $replytocom ); + } + + // Must be set for `get_comment_id_fields()`. + $_GET['replytocom'] = $replytocom; + + $actual = get_comment_id_fields( $comment_post ); + + $this->assertSame( '', $actual ); + } + + /** + * Tests that comment_form_title() does not output the author. + * + * @ticket 53962 + * + * @covers ::comment_form_title + * + * @dataProvider data_parent_comments + * @dataProvider data_non_existent_posts + * + * @param bool $replytocom Whether to create an approved (true) or unapproved (false) comment. + * @param string $comment_post The post of the comment. + * Accepts 'POST', 'NEW_POST', 'POST_ID' and 'NEW_POST_ID'. + */ + public function test_should_not_output_the_author( $replytocom, $comment_post ) { + if ( is_bool( $replytocom ) ) { + $replytocom = $this->create_comment_with_approval_status( $replytocom ); + } + + // Must be set for `comment_form_title()`. + $_GET['replytocom'] = $replytocom; + + if ( 'NEW_POST_ID' === $comment_post ) { + $comment_post = self::factory()->post->create(); + } elseif ( 'NEW_POST' === $comment_post ) { + $comment_post = self::factory()->post->create_and_get(); + } elseif ( 'POST_ID' === $comment_post ) { + $comment_post = self::$post_id; + } elseif ( 'POST' === $comment_post ) { + $comment_post = self::factory()->post->get_object_by_id( self::$post_id ); + } + + $comment_post_id = $comment_post instanceof WP_Post ? $comment_post->ID : $comment_post; + + get_comment( $_GET['replytocom'] ); + + comment_form_title( false, false, false, $comment_post_id ); + + $this->expectOutputString( 'Leave a Reply' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_existent_posts() { + return array( + 'an unapproved comment and a non-existent post ID' => array( + 'replytocom' => false, + 'comment_post' => -99999, + ), + 'an approved comment and a non-existent post ID' => array( + 'replytocom' => true, + 'comment_post' => -99999, + ), + ); + } + + /** + * Tests that get_comment_id_fields() does not allow replies when + * the comment does not have a parent post. + * + * @ticket 53962 + * + * @covers ::get_comment_id_fields + * + * @dataProvider data_parent_comments + * + * @param mixed $replytocom Whether to create an approved (true) or unapproved (false) comment, + * or an invalid comment ID. + * @param string $comment_post The post of the comment. + * Accepts 'POST', 'NEW_POST', 'POST_ID' and 'NEW_POST_ID'. + */ + public function test_should_not_allow_reply( $replytocom, $comment_post ) { + if ( is_bool( $replytocom ) ) { + $replytocom = $this->create_comment_with_approval_status( $replytocom ); + } + + // Must be set for `get_comment_id_fields()`. + $_GET['replytocom'] = $replytocom; + + if ( 'NEW_POST_ID' === $comment_post ) { + $comment_post = self::factory()->post->create(); + } elseif ( 'NEW_POST' === $comment_post ) { + $comment_post = self::factory()->post->create_and_get(); + } elseif ( 'POST_ID' === $comment_post ) { + $comment_post = self::$post_id; + } elseif ( 'POST' === $comment_post ) { + $comment_post = self::factory()->post->get_object_by_id( self::$post_id ); + } + + $comment_post_id = $comment_post instanceof WP_Post ? $comment_post->ID : $comment_post; + + $expected = "\n"; + $expected .= "\n"; + $actual = get_comment_id_fields( $comment_post ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_parent_comments() { + return array( + 'an unapproved parent comment (ID)' => array( + 'replytocom' => false, + 'comment_post' => 'POST_ID', + ), + 'an approved parent comment on another post (ID)' => array( + 'replytocom' => true, + 'comment_post' => 'NEW_POST_ID', + ), + 'an unapproved parent comment on another post (ID)' => array( + 'replytocom' => false, + 'comment_post' => 'NEW_POST_ID', + ), + 'a parent comment ID that cannot be cast to an integer' => array( + 'replytocom' => array( 'I cannot be cast to an integer.' ), + 'comment_post' => 'POST_ID', + ), + 'an unapproved parent comment (WP_Post)' => array( + 'replytocom' => false, + 'comment_post' => 'POST', + ), + 'an approved parent comment on another post (WP_Post)' => array( + 'replytocom' => true, + 'comment_post' => 'NEW_POST', + ), + 'an unapproved parent comment on another post (WP_Post)' => array( + 'replytocom' => false, + 'comment_post' => 'NEW_POST', + ), + 'a parent comment WP_Post that cannot be cast to an integer' => array( + 'replytocom' => array( 'I cannot be cast to an integer.' ), + 'comment_post' => 'POST', + ), + ); + } + + /** + * Helper function to create a comment with an approval status. + * + * @since 6.2.0 + * + * @param bool $approved Whether or not the comment is approved. + * @return int The comment ID. + */ + public function create_comment_with_approval_status( $approved ) { + return self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => ( $approved ) ? '1' : '0', + ) + ); + } + + /** + * Tests that _get_comment_reply_id() returns the expected value. + * + * @ticket 53962 + * + * @dataProvider data_get_comment_reply_id + * + * @covers ::_get_comment_reply_id + * + * @param int|bool|null $replytocom A comment ID (int), whether to generate an approved (true) or unapproved (false) comment, + * or null not to create a comment. + * @param string|int $post The post the comment thread is being displayed for. + * Accepts 'POST_ID', 'POST', or an integer post ID. + * @param int $expected The expected result. + */ + public function test_get_comment_reply_id( $replytocom, $post, $expected ) { + if ( false === $replytocom ) { + unset( $_GET['replytocom'] ); + } else { + $_GET['replytocom'] = $this->create_comment_with_approval_status( (bool) $replytocom ); + } + + if ( 'POST_ID' === $post ) { + $post = self::$post_id; + } elseif ( 'POST' === $post ) { + $post = self::factory()->post->get_object_by_id( self::$post_id ); + } + + if ( 'replytocom' === $expected ) { + $expected = $_GET['replytocom']; + } + + $this->assertSame( $expected, _get_comment_reply_id( $post ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_get_comment_reply_id() { + return array( + 'no comment ID set ($_GET["replytocom"])' => array( + 'replytocom' => false, + 'post' => 0, + 'expected' => 0, + ), + 'a non-numeric comment ID' => array( + 'replytocom' => 'three', + 'post' => 0, + 'expected' => 0, + ), + 'a non-existent comment ID' => array( + 'replytocom' => -999999, + 'post' => 0, + 'expected' => 0, + ), + 'an unapproved comment' => array( + 'replytocom' => false, + 'post' => 0, + 'expected' => 0, + ), + 'a post that does not match the parent' => array( + 'replytocom' => false, + 'post' => -999999, + 'expected' => 0, + ), + 'an approved comment and the correct post ID' => array( + 'replytocom' => true, + 'post' => 'POST_ID', + 'expected' => 'replytocom', + ), + 'an approved comment and the correct WP_Post object' => array( + 'replytocom' => true, + 'post' => 'POST', + 'expected' => 'replytocom', + ), + ); + } + /** * @ticket 14279 * diff --git a/tests/phpunit/tests/comment/wpHandleCommentSubmission.php b/tests/phpunit/tests/comment/wpHandleCommentSubmission.php index e4ec77af7a..ff2f3c8b7a 100644 --- a/tests/phpunit/tests/comment/wpHandleCommentSubmission.php +++ b/tests/phpunit/tests/comment/wpHandleCommentSubmission.php @@ -882,4 +882,124 @@ class Tests_Comment_wpHandleCommentSubmission extends WP_UnitTestCase { $this->assertNotWPError( $second_comment ); $this->assertEquals( self::$post->ID, $second_comment->comment_post_ID ); } + + /** + * Tests that wp_handle_comment_submission() only allows replying to + * an approved parent comment. + * + * @ticket 53962 + * + * @dataProvider data_should_only_allow_replying_to_an_approved_parent_comment + * + * @param int $approved Whether the parent comment is approved. + */ + public function test_should_only_allow_replying_to_an_approved_parent_comment( $approved ) { + wp_set_current_user( self::$editor_id ); + + $comment_parent = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post->ID, + 'comment_approved' => $approved, + ) + ); + + $comment = wp_handle_comment_submission( + array( + 'comment_post_ID' => self::$post->ID, + 'comment_author' => 'A comment author', + 'comment_author_email' => 'comment_author@example.org', + 'comment' => 'Howdy, comment!', + 'comment_parent' => $comment_parent, + ) + ); + + if ( $approved ) { + $this->assertInstanceOf( + 'WP_Comment', + $comment, + 'The comment was not submitted.' + ); + } else { + $this->assertWPError( $comment, 'The comment was submitted.' ); + $this->assertSame( + 'comment_reply_to_unapproved_comment', + $comment->get_error_code(), + 'The wrong error code was returned.' + ); + } + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_only_allow_replying_to_an_approved_parent_comment() { + return array( + 'an approved parent comment' => array( 'approved' => 1 ), + 'an unapproved parent comment' => array( 'approved' => 0 ), + ); + } + + /** + * Tests that wp_handle_comment_submission() only allows replying to + * an existing parent comment. + * + * @ticket 53962 + * + * @dataProvider data_should_only_allow_replying_to_an_existing_parent_comment + * + * @param bool $exists Whether the parent comment exists. + */ + public function test_should_only_allow_replying_to_an_existing_parent_comment( $exists ) { + wp_set_current_user( self::$editor_id ); + + $parent_comment = -99999; + + if ( $exists ) { + $parent_comment = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post->ID, + 'comment_approved' => 1, + ) + ); + } + + $comment = wp_handle_comment_submission( + array( + 'comment_post_ID' => self::$post->ID, + 'comment_author' => 'A comment author', + 'comment_author_email' => 'comment_author@example.org', + 'comment' => 'Howdy, comment!', + 'comment_parent' => $parent_comment, + ) + ); + + if ( $exists ) { + $this->assertInstanceOf( + 'WP_Comment', + $comment, + 'The comment was not submitted.' + ); + } else { + $this->assertWPError( $comment, 'The comment was submitted.' ); + $this->assertSame( + 'comment_reply_to_unapproved_comment', + $comment->get_error_code(), + 'The wrong error code was returned.' + ); + } + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_only_allow_replying_to_an_existing_parent_comment() { + return array( + 'an existing parent comment' => array( 'exists' => true ), + 'a non-existent parent comment' => array( 'exists' => false ), + ); + } }