* WordPress Administration Privacy Tools API.
* @subpackage Administration
* Resend an existing request and return the result.
* @param int $request_id Request ID.
* @return true|WP_Error Returns true if sending the email was successful, or a WP_Error object.
function _wp_privacy_resend_request( $request_id ) {
$request_id = absint( $request_id );
$request = get_post( $request_id );
if ( ! $request || 'user_request' !== $request->post_type ) {
return new WP_Error( 'privacy_request_error', __( 'Invalid personal data request.' ) );
$result = wp_send_user_request( $request_id );
if ( is_wp_error( $result ) ) {
return new WP_Error( 'privacy_request_error', __( 'Unable to initiate confirmation for personal data request.' ) );
* Marks a request as completed by the admin and logs the current timestamp.
* @param int $request_id Request ID.
* @return int|WP_Error Request ID on success, or a WP_Error on failure.
function _wp_privacy_completed_request( $request_id ) {
$request_id = absint( $request_id );
$request = wp_get_user_request( $request_id );
return new WP_Error( 'privacy_request_error', __( 'Invalid personal data request.' ) );
update_post_meta( $request_id, '_wp_user_request_completed_timestamp', time() );
$result = wp_update_post(
'post_status' => 'request-completed',
* Handle list table actions.
function _wp_personal_data_handle_actions() {
if ( isset( $_POST['privacy_action_email_retry'] ) ) {
check_admin_referer( 'bulk-privacy_requests' );
$request_id = absint( current( array_keys( (array) wp_unslash( $_POST['privacy_action_email_retry'] ) ) ) );
$result = _wp_privacy_resend_request( $request_id );
if ( is_wp_error( $result ) ) {
'privacy_action_email_retry',
'privacy_action_email_retry',
$result->get_error_message(),
'privacy_action_email_retry',
'privacy_action_email_retry',
__( 'Confirmation request sent again successfully.' ),
} elseif ( isset( $_POST['action'] ) ) {
$action = ! empty( $_POST['action'] ) ? sanitize_key( wp_unslash( $_POST['action'] ) ) : '';
case 'add_export_personal_data_request':
case 'add_remove_personal_data_request':
check_admin_referer( 'personal-data-request' );
if ( ! isset( $_POST['type_of_action'], $_POST['username_or_email_for_privacy_request'] ) ) {
__( 'Invalid personal data action.' ),
$action_type = sanitize_text_field( wp_unslash( $_POST['type_of_action'] ) );
$username_or_email_address = sanitize_text_field( wp_unslash( $_POST['username_or_email_for_privacy_request'] ) );
if ( ! isset( $_POST['send_confirmation_email'] ) ) {
if ( ! in_array( $action_type, _wp_privacy_action_request_types(), true ) ) {
__( 'Invalid personal data action.' ),
if ( ! is_email( $username_or_email_address ) ) {
$user = get_user_by( 'login', $username_or_email_address );
if ( ! $user instanceof WP_User ) {
'username_or_email_for_privacy_request',
'username_or_email_for_privacy_request',
__( 'Unable to add this request. A valid email address or username must be supplied.' ),
$email_address = $user->user_email;
$email_address = $username_or_email_address;
if ( empty( $email_address ) ) {
$request_id = wp_create_user_request( $email_address, $action_type, array(), $status );
if ( is_wp_error( $request_id ) ) {
$message = $request_id->get_error_message();
} elseif ( ! $request_id ) {
$message = __( 'Unable to initiate confirmation request.' );
'username_or_email_for_privacy_request',
'username_or_email_for_privacy_request',
if ( 'pending' === $status ) {
wp_send_user_request( $request_id );
$message = __( 'Confirmation request initiated successfully.' );
} elseif ( 'confirmed' === $status ) {
$message = __( 'Request added successfully.' );
'username_or_email_for_privacy_request',
'username_or_email_for_privacy_request',
* Cleans up failed and expired requests before displaying the list table.
function _wp_personal_data_cleanup_requests() {
/** This filter is documented in wp-includes/user.php */
$expires = (int) apply_filters( 'user_request_key_expiration', DAY_IN_SECONDS );
$requests_query = new WP_Query(
'post_type' => 'user_request',
'post_status' => 'request-pending',
'column' => 'post_modified_gmt',
'before' => $expires . ' seconds ago',
$request_ids = $requests_query->posts;
foreach ( $request_ids as $request_id ) {
'post_status' => 'request-failed',
* Generate a single group for the personal data export report.
* @since 5.4.0 Added the `$group_id` and `$groups_count` parameters.
* @param array $group_data {
* The group data to render.
* @type string $group_label The user-facing heading for the group, e.g. 'Comments'.
* An array of group items.
* @type array $group_item_data {
* An array of name-value pairs for the item.
* @type string $name The user-facing name of an item name-value pair, e.g. 'IP Address'.
* @type string $value The user-facing value of an item data pair, e.g. '50.60.70.0'.
* @param string $group_id The group identifier.
* @param int $groups_count The number of all groups
* @return string The HTML for this group and its items.
function wp_privacy_generate_personal_data_export_group_html( $group_data, $group_id = '', $groups_count = 1 ) {
$group_id_attr = sanitize_title_with_dashes( $group_data['group_label'] . '-' . $group_id );
$group_html = '<h2 id="' . esc_attr( $group_id_attr ) . '">';
$group_html .= esc_html( $group_data['group_label'] );
$items_count = count( (array) $group_data['items'] );
if ( $items_count > 1 ) {
$group_html .= sprintf( ' <span class="count">(%d)</span>', $items_count );
if ( ! empty( $group_data['group_description'] ) ) {
$group_html .= '<p>' . esc_html( $group_data['group_description'] ) . '</p>';
foreach ( (array) $group_data['items'] as $group_item_id => $group_item_data ) {
$group_html .= '<table>';
$group_html .= '<tbody>';
foreach ( (array) $group_item_data as $group_item_datum ) {
$value = $group_item_datum['value'];
// If it looks like a link, make it a link.
if ( false === strpos( $value, ' ' ) && ( 0 === strpos( $value, 'http://' ) || 0 === strpos( $value, 'https://' ) ) ) {
$value = '<a href="' . esc_url( $value ) . '">' . esc_html( $value ) . '</a>';
$group_html .= '<th>' . esc_html( $group_item_datum['name'] ) . '</th>';
$group_html .= '<td>' . wp_kses( $value, 'personal_data_export' ) . '</td>';
$group_html .= '</tbody>';
$group_html .= '</table>';
if ( $groups_count > 1 ) {
$group_html .= '<div class="return-to-top">';
$group_html .= '<a href="#top"><span aria-hidden="true">↑ </span> ' . esc_html__( 'Go to top' ) . '</a>';
* Generate the personal data export file.
* @param int $request_id The export request ID.
function wp_privacy_generate_personal_data_export_file( $request_id ) {
if ( ! class_exists( 'ZipArchive' ) ) {
wp_send_json_error( __( 'Unable to generate personal data export file. ZipArchive not available.' ) );
$request = wp_get_user_request( $request_id );
if ( ! $request || 'export_personal_data' !== $request->action_name ) {
wp_send_json_error( __( 'Invalid request ID when generating personal data export file.' ) );
$email_address = $request->email;
if ( ! is_email( $email_address ) ) {
wp_send_json_error( __( 'Invalid email address when generating personal data export file.' ) );
// Create the exports folder if needed.
$exports_dir = wp_privacy_exports_dir();
$exports_url = wp_privacy_exports_url();
if ( ! wp_mkdir_p( $exports_dir ) ) {
wp_send_json_error( __( 'Unable to create personal data export folder.' ) );
// Protect export folder from browsing.
$index_pathname = $exports_dir . 'index.php';
if ( ! file_exists( $index_pathname ) ) {
$file = fopen( $index_pathname, 'w' );
wp_send_json_error( __( 'Unable to protect personal data export folder from browsing.' ) );
fwrite( $file, "<?php\n// Silence is golden.\n" );
$obscura = wp_generate_password( 32, false, false );
$file_basename = 'wp-personal-data-file-' . $obscura;
$html_report_filename = wp_unique_filename( $exports_dir, $file_basename . '.html' );
$html_report_pathname = wp_normalize_path( $exports_dir . $html_report_filename );
$json_report_filename = $file_basename . '.json';
$json_report_pathname = wp_normalize_path( $exports_dir . $json_report_filename );
* Gather general data needed.
/* translators: %s: User's email address. */
__( 'Personal Data Export for %s' ),
// And now, all the Groups.
$groups = get_post_meta( $request_id, '_export_data_grouped', true );
// First, build an "About" group on the fly for this report.
/* translators: Header for the About section in a personal data export. */
'group_label' => _x( 'About', 'personal data group label' ),
/* translators: Description for the About section in a personal data export. */
'group_description' => _x( 'Overview of export report.', 'personal data group description' ),
'name' => _x( 'Report generated for', 'email address' ),
'value' => $email_address,
'name' => _x( 'For site', 'website name' ),
'value' => get_bloginfo( 'name' ),
'name' => _x( 'At URL', 'website URL' ),
'value' => get_bloginfo( 'url' ),
'name' => _x( 'On', 'date/time' ),
'value' => current_time( 'mysql' ),
// Merge in the special about group.
$groups = array_merge( array( 'about' => $about_group ), $groups );
$groups_count = count( $groups );
// Convert the groups to JSON format.
$groups_json = wp_json_encode( $groups );
* Handle the JSON export.
$file = fopen( $json_report_pathname, 'w' );
wp_send_json_error( __( 'Unable to open personal data export file (JSON report) for writing.' ) );
fwrite( $file, '"' . $title . '":' );
fwrite( $file, $groups_json );
* Handle the HTML export.
$file = fopen( $html_report_pathname, 'w' );
wp_send_json_error( __( 'Unable to open personal data export (HTML report) for writing.' ) );
fwrite( $file, "<!DOCTYPE html>\n" );
fwrite( $file, "<html>\n" );
fwrite( $file, "<head>\n" );
fwrite( $file, "<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />\n" );
fwrite( $file, "<style type='text/css'>" );
fwrite( $file, 'body { color: black; font-family: Arial, sans-serif; font-size: 11pt; margin: 15px auto; width: 860px; }' );
fwrite( $file, 'table { background: #f0f0f0; border: 1px solid #ddd; margin-bottom: 20px; width: 100%; }' );
fwrite( $file, 'th { padding: 5px; text-align: left; width: 20%; }' );
fwrite( $file, 'td { padding: 5px; }' );
fwrite( $file, 'tr:nth-child(odd) { background-color: #fafafa; }' );
fwrite( $file, '.return-to-top { text-align: right; }' );
fwrite( $file, '</style>' );
fwrite( $file, '<title>' );
fwrite( $file, esc_html( $title ) );
fwrite( $file, '</title>' );
fwrite( $file, "</head>\n" );
fwrite( $file, "<body>\n" );
fwrite( $file, '<h1 id="top">' . esc_html__( 'Personal Data Export' ) . '</h1>' );
if ( $groups_count > 1 ) {
fwrite( $file, '<div id="table_of_contents">' );
fwrite( $file, '<h2>' . esc_html__( 'Table of Contents' ) . '</h2>' );
foreach ( (array) $groups as $group_id => $group_data ) {
$group_label = esc_html( $group_data['group_label'] );
$group_id_attr = sanitize_title_with_dashes( $group_data['group_label'] . '-' . $group_id );
$group_items_count = count( (array) $group_data['items'] );
if ( $group_items_count > 1 ) {
$group_label .= sprintf( ' <span class="count">(%d)</span>', $group_items_count );
fwrite( $file, '<a href="#' . esc_attr( $group_id_attr ) . '">' . $group_label . '</a>' );
fwrite( $file, '</li>' );
fwrite( $file, '</ul>' );
fwrite( $file, '</div>' );
// Now, iterate over every group in $groups and have the formatter render it in HTML.
foreach ( (array) $groups as $group_id => $group_data ) {
fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group_data, $group_id, $groups_count ) );
fwrite( $file, "</body>\n" );
fwrite( $file, "</html>\n" );
* If an archive has already been generated, then remove it and reuse the filename,
* to avoid breaking any URLs that may have been previously sent via email.
// This meta value is used from version 5.5.
$archive_filename = get_post_meta( $request_id, '_export_file_name', true );
// This one stored an absolute path and is used for backward compatibility.
$archive_pathname = get_post_meta( $request_id, '_export_file_path', true );
// If a filename meta exists, use it.
if ( ! empty( $archive_filename ) ) {
$archive_pathname = $exports_dir . $archive_filename;
} elseif ( ! empty( $archive_pathname ) ) {
// If a full path meta exists, use it and create the new meta value.
$archive_filename = basename( $archive_pathname );
update_post_meta( $request_id, '_export_file_name', $archive_filename );
// Remove the back-compat meta values.
delete_post_meta( $request_id, '_export_file_url' );