Edit: Updated on 5/11/2018 to include adding privacy policy content.
By now, I’m assuming most people are at least aware of what GDPR is. If not, go check out GDPR WP’s site, or do a quick Google search so you’re familiar with it. Basically, the upcoming enforcement of EU’s privacy laws will become enforceable on May 25, 2018 and it affects all businesses who collect data on European residents. Chances are if your plugin or theme collects personal data, your users will need to have GDPR compliance features incorporated.
Two of the main issues for WordPress site owners are (1) how to show personal data to your site’s users, and (2) how to erase their data from your site.
WordPress core will have a number of new features that are of particular interest to Plugin and Theme developers. New hooks will allow you to add data from your plugin or theme to the WordPress generated export file, as well as delete data from your plugin or theme upon request.
Why Should Developers Work With These Hooks?
WordPress site owners are not going to want to be burdened with dozens of different ways to export data from their site. Imagine if every theme or plugin developer decided to come up with their own export tools? It would be madness. There’s no way that your typical site owner will know where to look for all the personal data they have stored on their site.
By working with the hooks that will be provided in WordPress core (version 4.9.6 or later), you are making life 100 times easier for your plugin or theme’s users.
How to Include Data in the Export
First of all, we will need to register your exporter. WordPress has a new filter wp_privacy_personal_data_exporters that we will use to register the exporter.
Register Export Function
function register_my_plugin_exporter( $exporters ) { $exporters['my-plugin-exporter'] = array( 'exporter_friendly_name' => __( 'My Plugin Exporter Name' ), 'callback' => 'my_plugin_exporter', ); return $exporters; } add_filter( 'wp_privacy_personal_data_exporters', 'register_my_plugin_exporter', 10 ); |
This function adds to the array of $exporters that is run when processing your export request. The exporter_friendly_name is simply a friendly name that describes your export. This is used by error messages if there are unexpected responses from your exporter. It’s best to use the name of your plugin and some other descriptive information (i.e. My Plugin Order Exporter, My Plugin Contact Details Exporter, etc.) The callback is the function that will run to gather the data needed for this export.
We need to pass two variables to the function. First is the email address of the individual who is requesting their data, which is self-explanitory. The second is the page of personal data for this exporter, which begins at 1.
We pass a page number to the function so that it can iterate through the function several times. Inside the function itself, we’ll define a $number variable that will tell the function to only process $number records at a time because we don’t want it to time out.
Export Function
This is an example of the export function from wp-includes/comment.php, which shows how WordPress will export comments.
You can modify this function to round up all the data in your plugin or theme.
function my_plugin_exporter( $email_address, $page = 1 ) { // Limit us to 500 at a time to avoid timing out. $number = 500; $page = (int) $page; $data_to_export = array(); // In this example, we're getting comments, but you can use this function to get any data your plugin collects. $comments = get_comments( array( 'author_email' => $email_address, 'number' => $number, // The max number of comments to retrieve. 'paged' => $page, // The page number of this iteration of the function. $page == 1 returns comments 1-500, $page == 2 returns comments 501-1000, etc. 'order_by' => 'comment_ID', 'order' => 'ASC', 'update_comment_meta_cache' => false, ) ); $comment_prop_to_export = array( 'comment_author' => __( 'Comment Author' ), 'comment_author_email' => __( 'Comment Author Email' ), 'comment_author_url' => __( 'Comment Author URL' ), 'comment_author_IP' => __( 'Comment Author IP' ), 'comment_agent' => __( 'Comment Author User Agent' ), 'comment_date' => __( 'Comment Date' ), 'comment_content' => __( 'Comment Content' ), 'comment_link' => __( 'Comment URL' ), ); foreach ( (array) $comments as $comment ) { $comment_data_to_export = array(); foreach ( $comment_prop_to_export as $key => $name ) { $value = ''; switch ( $key ) { case 'comment_author': case 'comment_author_email': case 'comment_author_url': case 'comment_author_IP': case 'comment_agent': case 'comment_date': $value = $comment->{$key}; break; case 'comment_content': $value = get_comment_text( $comment->comment_ID ); break; case 'comment_link': $value = get_comment_link( $comment->comment_ID ); $value = '<a href="' . $value . '" target="_blank" rel="noreferrer noopener">' . $value . '</a>'; break; } if ( ! empty( $value ) ) { $comment_data_to_export[] = array( 'name' => $name, 'value' => $value, ); } } $data_to_export[] = array( 'group_id' => 'comments', // An ID to identify this particular group of information. 'group_label' => __( 'Comments' ), // A translatable string to label this group of information. 'item_id' => "comment-{$comment->comment_ID}", // The item ID of what we're exporting 'data' => $comment_data_to_export, // The personal data that should be exported ); } // $done identifies whether or not the number of $comments is less than $number. If it is, all comments have been processed and we're done. $done = count( $comments ) < $number; // The function should return an array with the (array)'data' to be exported and whether or not we're (bool)'done' return array( 'data' => $data_to_export, 'done' => $done, ); } |
I’ve added some inline comments to the function, which should explain the important parts of the function.
Including Data to Erase
We need to figure out what data we will erase. Fortunately, it’s probably going to be the same data from the export function, because we don’t want to erase data that the user hasn’t seen. So once you have the export function working the rest is pretty straightforward.
The biggest difference between the export and the eraser functions is what happens to the data. In the export function, we don’t modify, or remove data since we are just exporting it. In the eraser function, we’ll actually be deleting or anonymizing the data.
Like the export function, we need to register the eraser function. This time we will use the new wp_privacy_personal_data_erasers filter.
Register Eraser Function
function register_my_plugin_personal_data_eraser( $erasers ) { $erasers['my-plugin-eraser'] = array( 'eraser_friendly_name' => __( 'WordPress Comments' ), 'callback' => 'my_plugin_personal_data_eraser', ); return $erasers; } add_filter( 'wp_privacy_personal_data_erasers', 'register_my_plugin_personal_data_eraser', 10 ); |
Same idea as registering the export function so I won’t reiterate what each part is doing.
Eraser Function
Again, we need to pass the email address and the page to the function. I’ll continue using the comment eraser function found in wp-includes/comment.php.
function my_plugin_personal_data_eraser( $email_address, $page = 1 ) { global $wpdb; if ( empty( $email_address ) ) { return array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true, ); } // Limit us to 500 comments at a time to avoid timing out. $number = 500; // Get us on the right page of comments $page = (int) $page; $items_removed = false; $items_retained = false; $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(); // In this case WordPress isn't deleting comments, but instead is anonymizing it. // Your plugin could do something similar, or delete the data altogether. 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 ); // Default is true to anonymize comments, but filter could change this behavior to false 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 ); } $items_retained = true; continue; } $args = array( 'comment_ID' => $comment_id, ); $updated = $wpdb->update( $wpdb->comments, $anonymized_comment, $args ); if ( $updated ) { $items_removed = true; clean_comment_cache( $comment_id ); } else { $items_retained = true; } } // $done identifies whether or not the number of $comments is less than $number. If it is, all comments have been processed and we're done. $done = count( $comments ) < $number; // The function should return an array with the following: // (bool) 'items_removed' - were items removed // (bool) 'items_retained' - were items retained and anonymized // (array) 'messages' - error messages related to this erasure request // (bool) 'done' - are we done processing data or is there more to be processed return array( 'items_removed' => $items_removed, 'items_retained' => $items_retained, 'messages' => $messages, 'done' => $done, ); } |
Adding Privacy Policy Content
This feature is not very well documented, at least not as far as I’ve been able to find. Maybe someone else knows of a guide that I’ve missed – if so please add it to the comments.
I literally dug through WooCommerce’s beta version’s code to figure out how to add privacy policy content for your plugin or theme.
There is a function wp_add_privacy_policy_content() that you’ll use to add your content. I’m assuming that they are using a function like this instead of a filter so that other plugins don’t accidentally (or intentionally) remove another plugin’s privacy policy content.
The function takes two parameters: (1) your plugin’s name, and (2) the content of your privacy policy content.
See final thoughts section below for my opinion on this.
Here’s how the function works:
function my_plugin_add_privacy_policy_content(){ if( !function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $content = wp_kses_post( wpautop( __( 'Your privacy policy content here', 'my-plugin' ) ) ); wp_add_privacy_policy_content( 'My Plugin', $content ); } |
Final Thoughts
Adding a privacy policy statement is a huge responsibility to place on plugin and theme developers. I know I’m not a lawyer, so I don’t know all the intricacies of the law and what content does and does not need to be included. I also don’t have the budget to hire a lawyer to draft something that will cover every customer’s use case. With that said, there will still be people who blindly copy and paste the suggested text and use it as the basis of their own policy.
To address this, I would recommend that any plugin or theme developers not write content that could be put in the privacy policy as-is. Instead, provide guidelines as to what data your plugin collects, why it collects that data, where it is stored, and for how long your plugin stores it (i.e. conditions that would cause the data to be removed).
Something like “XYZ Form Plugin collects data that your users submit through our forms and stores it in the database. Our plugin collects this data to facilitate communication between the site visitors and site administrators. The data is stored in your website’s database and will remain there until it is deleted. Please update your privacy policy to reflect this data storage procedure.” Or something along those lines.
WordPress 4.9.6 is scheduled to be released on Tuesday, May 15th 17th. That’s just 10 8 days before the GDPR regulations become enforceable so there isn’t much time left.
If you are a plugin or theme developer and your product collects personal data, you can’t wait until May 15th 17th. Start working on how your plugin will handle data today!
You can download WordPress 4.9.6 beta 1 here or use the WordPress Beta Tester Plugin.
Michael Nelson says
Great summary. Although FYI just a few days ago there were some changes made on ticket https://core.trac.wordpress.org/ticket/43931 that make it so the array of exporters and erasers should now use slugs for their arrays keys, not INTs like in your code samples. (I literally JUST made the change to our code a half hour ago! Here’s the proof: https://github.com/eventespresso/event-espresso-core/commit/1721d6053e3411bcb589bdd9fa703555980be7ac)
Scott DeLuzio says
Thanks for that, I didn’t see that change come through! I’ve updated the code in the post to show the change.
Thanks again!