From 7e26130f125beafd1164f21e5faaf1ae529e9425 Mon Sep 17 00:00:00 2001 From: Andrew Ozz Date: Fri, 20 Apr 2018 12:18:35 +0000 Subject: [PATCH] Privacy: add functionality to anonymize commenters. Props xkon, fclaussen, allendav, birgire, azaozz. See #43442. git-svn-id: https://develop.svn.wordpress.org/trunk@42994 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/js/xfn.js | 6 +- src/wp-includes/comment.php | 113 ++++++++++++++ src/wp-includes/default-filters.php | 1 + tests/phpunit/tests/comment.php | 221 ++++++++++++++++++++++++++++ 4 files changed, 338 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/js/xfn.js b/src/wp-admin/js/xfn.js index bae1f70e93..7881c76159 100644 --- a/src/wp-admin/js/xfn.js +++ b/src/wp-admin/js/xfn.js @@ -72,15 +72,15 @@ jQuery( document ).ready( function( $ ) { set_action_state( $action, 'remove_personal_data_idle' ); var summaryMessage = strings.noDataFound; var classes = 'notice-success'; - if ( 0 == removedCount ) { - if ( 0 == retainedCount ) { + if ( 0 === removedCount ) { + if ( 0 === retainedCount ) { summaryMessage = strings.noDataFound; } else { summaryMessage = strings.noneRemoved; classes = 'notice-warning'; } } else { - if ( 0 == retainedCount ) { + if ( 0 === retainedCount ) { summaryMessage = strings.foundAndRemoved; } else { summaryMessage = strings.someNotRemoved; diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index d3f8cc6658..6b6e7b45af 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3378,3 +3378,116 @@ function wp_comments_personal_data_exporter( $email_address, $page = 1 ) { 'done' => $done, ); } + +/** + * Registers the personal data eraser for comments. + * + * @since 4.9.6 + * + * @param array $erasers An array of personal data erasers. + * @return array $erasers An array of personal data erasers. + */ +function wp_register_comment_personal_data_eraser( $erasers ) { + $erasers[] = array( + 'eraser_friendly_name' => __( 'WordPress Comments' ), + 'callback' => 'wp_comments_personal_data_eraser', + ); + + return $erasers; +} + +/** + * Erases personal data associated with an email address from the comments table. + * + * @since 4.9.6 + * + * @param string $email_address The comment author email address. + * @param int $page Comment page. + * @return array + */ +function wp_comments_personal_data_eraser( $email_address, $page = 1 ) { + global $wpdb; + + if ( empty( $email_address ) ) { + return array( + 'num_items_removed' => 0, + 'num_items_retained' => 0, + 'messages' => array(), + 'done' => true, + ); + } + + // Limit us to 500 comments at a time to avoid timing out. + $number = 500; + $page = (int) $page; + $num_items_removed = 0; + + $comments = get_comments( + array( + 'author_email' => $email_address, + 'number' => $number, + 'paged' => $page, + 'order_by' => 'comment_ID', + 'order' => 'ASC', + 'include_unapproved' => true, + ) + ); + + $anon_author = __( 'Anonymous' ); + $messages = array(); + + foreach ( (array) $comments as $comment ) { + $anonymized_comment = array(); + $anonymized_comment['comment_agent'] = ''; + $anonymized_comment['comment_author'] = $anon_author; + $anonymized_comment['comment_author_email'] = wp_privacy_anonymize_data( 'email', $comment->comment_author_email ); + $anonymized_comment['comment_author_IP'] = wp_privacy_anonymize_data( 'ip', $comment->comment_author_IP ); + $anonymized_comment['comment_author_url'] = wp_privacy_anonymize_data( 'url', $comment->comment_author_url ); + $anonymized_comment['user_id'] = 0; + + $comment_id = (int) $comment->comment_ID; + + /** + * Filters whether to anonymize the comment. + * + * @since 4.9.6 + * + * @param bool|string Whether to apply the comment anonymization (bool). + * Custom prevention message (string). Default true. + * @param WP_Comment $comment WP_Comment object. + * @param array $anonymized_comment Anonymized comment data. + */ + $anon_message = apply_filters( 'wp_anonymize_comment', true, $comment, $anonymized_comment ); + + if ( true !== $anon_message ) { + if ( $anon_message && is_string( $anon_message ) ) { + $messages[] = esc_html( $anon_message ); + } else { + /* translators: %d: Comment ID */ + $messages[] = sprintf( __( 'Comment %d contains personal data but could not be anonymized.' ), $comment_id ); + } + + continue; + } + + $args = array( + 'comment_ID' => $comment_id, + ); + + $updated = $wpdb->update( $wpdb->comments, $anonymized_comment, $args ); + + if ( $updated ) { + $num_items_removed++; + clean_comment_cache( $comment_id ); + } + } + + $done = count( $comments ) < $number; + + return array( + 'num_items_removed' => $num_items_removed, + 'num_items_retained' => count( $comments ) - $num_items_removed, + 'messages' => $messages, + 'done' => $done, + ); +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index bd8500e28d..510a045b89 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -329,6 +329,7 @@ add_action( 'do_pings', 'do_all_pings', 10, 1 ); add_action( 'do_robots', 'do_robots' ); add_action( 'set_comment_cookies', 'wp_set_comment_cookies', 10, 3 ); add_filter( 'wp_privacy_personal_data_exporters', 'wp_register_comment_personal_data_exporter', 10 ); +add_filter( 'wp_privacy_personal_data_erasers', 'wp_register_comment_personal_data_eraser', 10 ); add_action( 'sanitize_comment_cookies', 'sanitize_comment_cookies' ); add_action( 'admin_print_scripts', 'print_emoji_detection_script' ); add_action( 'admin_print_scripts', 'print_head_scripts', 20 ); diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index 4193473b16..29453179e4 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -812,6 +812,227 @@ class Tests_Comment extends WP_UnitTestCase { } } + /** + * The `wp_comments_personal_data_eraser()` function should erase user's comments. + * + * @ticket 43442 + */ + public function test_wp_comments_personal_data_eraser() { + + $post_id = self::factory()->post->create(); + $user_id = self::factory()->user->create(); + + $args = array( + 'user_id' => $user_id, + 'comment_post_ID' => $post_id, + 'comment_author' => 'Comment Author', + 'comment_author_email' => 'personal@local.host', + 'comment_author_url' => 'https://local.host/', + 'comment_author_IP' => '192.168.0.1', + 'comment_date' => '2018-04-14 17:20:00', + 'comment_agent' => 'COMMENT_AGENT', + 'comment_content' => 'Comment Content', + ); + $comment_id = self::factory()->comment->create( $args ); + + wp_comments_personal_data_eraser( $args['comment_author_email'] ); + + $comment = get_comment( $comment_id ); + + $actual = array( + 'comment_ID' => $comment->comment_ID, + 'user_id' => $comment->user_id, + 'comment_author' => $comment->comment_author, + 'comment_author_email' => $comment->comment_author_email, + 'comment_author_url' => $comment->comment_author_url, + 'comment_author_IP' => $comment->comment_author_IP, + 'comment_date' => $comment->comment_date, + 'comment_date_gmt' => $comment->comment_date_gmt, + 'comment_agent' => $comment->comment_agent, + 'comment_content' => $comment->comment_content, + ); + + $expected = array( + 'comment_ID' => (string) $comment_id, + 'user_id' => '0', // Anonymized. + 'comment_author' => 'Anonymous', // Anonymized. + 'comment_author_email' => 'deleted@site.invalid', // Anonymized. + 'comment_author_url' => 'https://site.invalid', // Anonymized. + 'comment_author_IP' => '192.168.0.0', // Anonymized. + 'comment_date' => '2018-04-14 17:20:00', + 'comment_date_gmt' => '2018-04-14 17:20:00', + 'comment_agent' => '', // Anonymized. + 'comment_content' => 'Comment Content', + ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Testing the `wp_comments_personal_data_eraser()` function's output on an empty first page. + * + * @ticket 43442 + */ + public function test_wp_comments_personal_data_eraser_empty_first_page_output() { + + $actual = wp_comments_personal_data_eraser( 'nocommentsfound@local.host' ); + $expected = array( + 'num_items_removed' => 0, + 'num_items_retained' => 0, + 'messages' => array(), + 'done' => true, + ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Testing the `wp_comments_personal_data_eraser()` function's output, for the non-empty first page. + * + * @ticket 43442 + */ + public function test_wp_comments_personal_data_eraser_non_empty_first_page_output() { + + $post_id = self::factory()->post->create(); + $args = array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'Comment Author', + 'comment_author_email' => 'personal@local.host', + 'comment_author_url' => 'https://local.host/', + 'comment_author_IP' => '192.168.0.1', + 'comment_date' => '2018-04-14 17:20:00', + 'comment_agent' => 'COMMENT_AGENT', + 'comment_content' => 'Comment Content', + ); + self::factory()->comment->create( $args ); + + $actual = wp_comments_personal_data_eraser( $args['comment_author_email'] ); + $expected = array( + 'num_items_removed' => 1, + 'num_items_retained' => 0, + 'messages' => array(), + 'done' => true, + ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Testing the `wp_comments_personal_data_eraser()` function's output, for an empty second page. + * + * @ticket 43442 + */ + public function test_wp_comments_personal_data_eraser_empty_second_page_output() { + + $post_id = self::factory()->post->create(); + $args = array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'Comment Author', + 'comment_author_email' => 'personal@local.host', + 'comment_author_url' => 'https://local.host/', + 'comment_author_IP' => '192.168.0.1', + 'comment_date' => '2018-04-14 17:20:00', + 'comment_agent' => 'COMMENT_AGENT', + 'comment_content' => 'Comment Content', + ); + self::factory()->comment->create( $args ); + + $actual = wp_comments_personal_data_eraser( $args['comment_author_email'], 2 ); + $expected = array( + 'num_items_removed' => 0, + 'num_items_retained' => 0, + 'messages' => array(), + 'done' => true, + ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Testing the `wp_anonymize_comment` filter, to prevent comment anonymization. + * + * @ticket 43442 + */ + public function test_wp_anonymize_comment_filter_to_prevent_comment_anonymization() { + + $post_id = self::factory()->post->create(); + $args = array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'Comment Author', + 'comment_author_email' => 'personal@local.host', + 'comment_author_url' => 'https://local.host/', + 'comment_author_IP' => '192.168.0.1', + 'comment_date' => '2018-04-14 17:20:00', + 'comment_agent' => 'COMMENT_AGENT', + 'comment_content' => 'Comment Content', + ); + $comment_id = self::factory()->comment->create( $args ); + + add_filter( 'wp_anonymize_comment', '__return_false' ); + $actual = wp_comments_personal_data_eraser( $args['comment_author_email'] ); + remove_filter( 'wp_anonymize_comment', '__return_false' ); + + $message = sprintf( 'Comment %d contains personal data but could not be anonymized.', $comment_id ); + + $expected = array( + 'num_items_removed' => 0, + 'num_items_retained' => 1, + 'messages' => array( $message ), + 'done' => true, + ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Testing the `wp_anonymize_comment` filter, to prevent comment anonymization, with a custom message. + * + * @ticket 43442 + */ + public function test_wp_anonymize_comment_filter_to_prevent_comment_anonymization_with_custom_message() { + + $post_id = self::factory()->post->create(); + $args = array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'Comment Author', + 'comment_author_email' => 'personal@local.host', + 'comment_author_url' => 'https://local.host/', + 'comment_author_IP' => '192.168.0.1', + 'comment_date' => '2018-04-14 17:20:00', + 'comment_agent' => 'COMMENT_AGENT', + 'comment_content' => 'Comment Content', + ); + $comment_id = self::factory()->comment->create( $args ); + + add_filter( 'wp_anonymize_comment', array( $this, 'wp_anonymize_comment_custom_message' ), 10, 3 ); + $actual = wp_comments_personal_data_eraser( $args['comment_author_email'] ); + remove_filter( 'wp_anonymize_comment', array( $this, 'wp_anonymize_comment_custom_message' ) ); + + $message = sprintf( 'Some custom message for comment %d.', $comment_id ); + + $expected = array( + 'num_items_removed' => 0, + 'num_items_retained' => 1, + 'messages' => array( $message ), + 'done' => true, + ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Callback for the `wp_anonymize_comment` filter. + * + * @param bool|string $anonymize Whether to apply the comment anonymization (bool). + * Custom prevention message (string). Default true. + * @param WP_Comment $comment WP_Comment object. + * @param array $anonymized_comment Anonymized comment data. + * @return string + */ + public function wp_anonymize_comment_custom_message( $anonymize, $comment, $anonymized_comment ) { + return sprintf( 'Some custom message for comment %d.', $comment->comment_ID ); + } + public function test_update_should_invalidate_comment_cache() { global $wpdb;