diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 8ea0b3680291c..29a93c0309fb8 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -431,6 +431,7 @@ function populate_options( array $options = array() ) { 'default_comment_status' => 'open', 'default_ping_status' => 'open', 'default_pingback_flag' => 1, + 'auto_approve_self_pingbacks' => 0, 'posts_per_page' => 10, /* translators: Default date format, see https://www.php.net/manual/datetime.format.php */ 'date_format' => __( 'F j, Y' ), diff --git a/src/wp-admin/options-discussion.php b/src/wp-admin/options-discussion.php index 0c350475fe176..40b47b98b8a89 100644 --- a/src/wp-admin/options-discussion.php +++ b/src/wp-admin/options-discussion.php @@ -176,6 +176,8 @@
+
+ diff --git a/src/wp-admin/options.php b/src/wp-admin/options.php index b45cbb00387ce..f423a0dfc77a1 100644 --- a/src/wp-admin/options.php +++ b/src/wp-admin/options.php @@ -109,6 +109,7 @@ 'comment_moderation', 'require_name_email', 'comment_previously_approved', + 'auto_approve_self_pingbacks', 'comment_max_links', 'moderation_keys', 'disallowed_keys', diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b93908adc0519..35a7701582a82 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -1349,6 +1349,53 @@ function wp_check_comment_data( $comment_data ) { return apply_filters( 'pre_comment_approved', $approved, $comment_data ); } +/** + * Automatically approves a pingback that originates from this site. + * + * A self-pingback is generated when one post on the site links to another post + * on the same site. When the `auto_approve_self_pingbacks` option is enabled, + * such pingbacks are approved instead of being held for moderation. + * + * Only pending pingbacks are affected; comments already flagged as approved, + * spam, or trash (or returning a WP_Error) are left untouched. + * + * The source is identified from `comment_author_url`. For a pingback this is the + * source URL that core has already fetched and verified actually links to the + * target post, so a URL resolving to a local post indicates genuine local content. + * + * @since 7.1.0 + * + * @param int|string|WP_Error $approved The approval status. Accepts 1, 0, 'spam', 'trash', + * or WP_Error. + * @param array $comment_data Comment data. + * @return int|string|WP_Error The (possibly updated) approval status. + */ +function wp_auto_approve_self_pingback( $approved, $comment_data ) { + // Only act on pending comments; leave approved, spam, trash, and errors alone. + if ( 0 !== $approved && '0' !== $approved ) { + return $approved; + } + + if ( ! get_option( 'auto_approve_self_pingbacks' ) ) { + return $approved; + } + + if ( empty( $comment_data['comment_type'] ) || 'pingback' !== $comment_data['comment_type'] ) { + return $approved; + } + + if ( empty( $comment_data['comment_author_url'] ) ) { + return $approved; + } + + // The pingback source URL resolves to a post on this site, so treat it as a self-pingback. + if ( url_to_postid( $comment_data['comment_author_url'] ) > 0 ) { + return 1; + } + + return $approved; +} + /** * Checks if a comment contains disallowed characters or words. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 5581828a10b61..83e4aeda6d78b 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -321,6 +321,7 @@ add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 ); add_filter( 'pre_comment_content', 'wp_rel_ugc', 15 ); +add_filter( 'pre_comment_approved', 'wp_auto_approve_self_pingback', 10, 2 ); add_filter( 'comment_email', 'antispambot' ); add_filter( 'option_tag_base', '_wp_filter_taxonomy_base' ); add_filter( 'option_category_base', '_wp_filter_taxonomy_base' ); diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 7979c119a986f..462d110210910 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2943,6 +2943,17 @@ function register_initial_settings() { ) ); + register_setting( + 'discussion', + 'auto_approve_self_pingbacks', + array( + 'show_in_rest' => true, + 'type' => 'boolean', + 'label' => __( 'Automatically approve pingbacks from this site' ), + 'description' => __( 'Automatically approve pingbacks generated by links between posts on this site.' ), + ) + ); + register_setting( 'discussion', 'default_comment_status', diff --git a/tests/phpunit/tests/comment/autoApproveSelfPingback.php b/tests/phpunit/tests/comment/autoApproveSelfPingback.php new file mode 100644 index 0000000000000..94f52c7e79a00 --- /dev/null +++ b/tests/phpunit/tests/comment/autoApproveSelfPingback.php @@ -0,0 +1,122 @@ +post->create(); + } + + /** + * Builds comment data for a pingback whose source URL points to a local post. + * + * @param string $comment_type Comment type. Default 'pingback'. + * @return array Comment data. + */ + private function get_self_pingback_data( $comment_type = 'pingback' ) { + return array( + 'comment_post_ID' => self::$post_id, + 'comment_author' => 'Self Site', + 'comment_author_url' => get_permalink( self::$post_id ), + 'comment_content' => 'A link from this site.', + 'comment_type' => $comment_type, + ); + } + + public function test_approves_pending_self_pingback_when_option_enabled() { + update_option( 'auto_approve_self_pingbacks', 1 ); + + $approved = wp_auto_approve_self_pingback( 0, $this->get_self_pingback_data() ); + + $this->assertSame( 1, $approved ); + } + + public function test_leaves_self_pingback_pending_when_option_disabled() { + update_option( 'auto_approve_self_pingbacks', 0 ); + + $approved = wp_auto_approve_self_pingback( 0, $this->get_self_pingback_data() ); + + $this->assertSame( 0, $approved ); + } + + public function test_ignores_non_pingback_comment_types() { + update_option( 'auto_approve_self_pingbacks', 1 ); + + $approved = wp_auto_approve_self_pingback( 0, $this->get_self_pingback_data( 'comment' ) ); + + $this->assertSame( 0, $approved ); + } + + public function test_does_not_approve_pingback_from_external_url() { + update_option( 'auto_approve_self_pingbacks', 1 ); + + $data = $this->get_self_pingback_data(); + $data['comment_author_url'] = 'https://example.org/some-other-post/'; + + $approved = wp_auto_approve_self_pingback( 0, $data ); + + $this->assertSame( 0, $approved ); + } + + /** + * Spam, trash, approved, and error verdicts must never be downgraded or overridden. + * + * @dataProvider data_non_pending_statuses + * + * @param mixed $status A non-pending approval status. + */ + public function test_does_not_override_non_pending_status( $status ) { + update_option( 'auto_approve_self_pingbacks', 1 ); + + $approved = wp_auto_approve_self_pingback( $status, $this->get_self_pingback_data() ); + + $this->assertSame( $status, $approved ); + } + + public function data_non_pending_statuses() { + return array( + 'already approved' => array( 1 ), + 'spam' => array( 'spam' ), + 'trash' => array( 'trash' ), + ); + } + + public function test_does_not_override_wp_error_status() { + update_option( 'auto_approve_self_pingbacks', 1 ); + + $error = new WP_Error( 'disallowed', 'Comment is not allowed.' ); + $approved = wp_auto_approve_self_pingback( $error, $this->get_self_pingback_data() ); + + $this->assertSame( $error, $approved ); + } + + /** + * The callback must be wired to the `pre_comment_approved` filter by default, + * so a pending self-pingback is approved through the real filter chain. + */ + public function test_filter_is_registered_and_approves_self_pingback() { + $this->assertSame( + 10, + has_filter( 'pre_comment_approved', 'wp_auto_approve_self_pingback' ), + 'wp_auto_approve_self_pingback should be hooked to pre_comment_approved at priority 10.' + ); + + update_option( 'auto_approve_self_pingbacks', 1 ); + + $approved = apply_filters( 'pre_comment_approved', 0, $this->get_self_pingback_data() ); + + $this->assertSame( 1, $approved ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index e8f90b53f20f1..c98d09a025a98 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -117,6 +117,7 @@ public function test_get_items() { 'page_on_front', 'page_for_posts', 'default_ping_status', + 'auto_approve_self_pingbacks', 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..52daf6ed41ea1 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11193,6 +11193,12 @@ mockedApiResponse.Schema = { ], "required": false }, + "auto_approve_self_pingbacks": { + "title": "Automatically approve pingbacks from this site", + "description": "Automatically approve pingbacks generated by links between posts on this site.", + "type": "boolean", + "required": false + }, "default_comment_status": { "title": "Allow comments on new posts", "description": "Allow people to submit comments on new posts.", @@ -14671,6 +14677,7 @@ mockedApiResponse.settings = { "page_on_front": 0, "page_for_posts": 0, "default_ping_status": "open", + "auto_approve_self_pingbacks": false, "default_comment_status": "open", "site_logo": null, "site_icon": 0