Make WordPress Core


Ignore:
Timestamp:
06/10/2019 11:53:32 PM (7 years ago)
Author:
azaozz
Message:

Privacy tools:

  • Move the (remaining) privacy tools related functions from wp-admin/includes/file.php to wp-admin/includes/privacy-tools.php.
  • Move the WP_User_Request class to a separate file.

See #43895.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/includes/privacy-tools.php

    r45448 r45519  
    213213
    214214/**
     215 * Generate a single group for the personal data export report.
     216 *
     217 * @since 4.9.6
     218 *
     219 * @param array $group_data {
     220 *     The group data to render.
     221 *
     222 *     @type string $group_label  The user-facing heading for the group, e.g. 'Comments'.
     223 *     @type array  $items        {
     224 *         An array of group items.
     225 *
     226 *         @type array  $group_item_data  {
     227 *             An array of name-value pairs for the item.
     228 *
     229 *             @type string $name   The user-facing name of an item name-value pair, e.g. 'IP Address'.
     230 *             @type string $value  The user-facing value of an item data pair, e.g. '50.60.70.0'.
     231 *         }
     232 *     }
     233 * }
     234 * @return string The HTML for this group and its items.
     235 */
     236function wp_privacy_generate_personal_data_export_group_html( $group_data ) {
     237        $group_html  = '<h2>' . esc_html( $group_data['group_label'] ) . '</h2>';
     238        $group_html .= '<div>';
     239
     240        foreach ( (array) $group_data['items'] as $group_item_id => $group_item_data ) {
     241                $group_html .= '<table>';
     242                $group_html .= '<tbody>';
     243
     244                foreach ( (array) $group_item_data as $group_item_datum ) {
     245                        $value = $group_item_datum['value'];
     246                        // If it looks like a link, make it a link.
     247                        if ( false === strpos( $value, ' ' ) && ( 0 === strpos( $value, 'http://' ) || 0 === strpos( $value, 'https://' ) ) ) {
     248                                $value = '<a href="' . esc_url( $value ) . '">' . esc_html( $value ) . '</a>';
     249                        }
     250
     251                        $group_html .= '<tr>';
     252                        $group_html .= '<th>' . esc_html( $group_item_datum['name'] ) . '</th>';
     253                        $group_html .= '<td>' . wp_kses( $value, 'personal_data_export' ) . '</td>';
     254                        $group_html .= '</tr>';
     255                }
     256
     257                $group_html .= '</tbody>';
     258                $group_html .= '</table>';
     259        }
     260
     261        $group_html .= '</div>';
     262
     263        return $group_html;
     264}
     265
     266/**
     267 * Generate the personal data export file.
     268 *
     269 * @since 4.9.6
     270 *
     271 * @param int $request_id The export request ID.
     272 */
     273function wp_privacy_generate_personal_data_export_file( $request_id ) {
     274        if ( ! class_exists( 'ZipArchive' ) ) {
     275                wp_send_json_error( __( 'Unable to generate export file. ZipArchive not available.' ) );
     276        }
     277
     278        // Get the request data.
     279        $request = wp_get_user_request_data( $request_id );
     280
     281        if ( ! $request || 'export_personal_data' !== $request->action_name ) {
     282                wp_send_json_error( __( 'Invalid request ID when generating export file.' ) );
     283        }
     284
     285        $email_address = $request->email;
     286
     287        if ( ! is_email( $email_address ) ) {
     288                wp_send_json_error( __( 'Invalid email address when generating export file.' ) );
     289        }
     290
     291        // Create the exports folder if needed.
     292        $exports_dir = wp_privacy_exports_dir();
     293        $exports_url = wp_privacy_exports_url();
     294
     295        if ( ! wp_mkdir_p( $exports_dir ) ) {
     296                wp_send_json_error( __( 'Unable to create export folder.' ) );
     297        }
     298
     299        // Protect export folder from browsing.
     300        $index_pathname = $exports_dir . 'index.html';
     301        if ( ! file_exists( $index_pathname ) ) {
     302                $file = fopen( $index_pathname, 'w' );
     303                if ( false === $file ) {
     304                        wp_send_json_error( __( 'Unable to protect export folder from browsing.' ) );
     305                }
     306                fwrite( $file, '<!-- Silence is golden. -->' );
     307                fclose( $file );
     308        }
     309
     310        $stripped_email       = str_replace( '@', '-at-', $email_address );
     311        $stripped_email       = sanitize_title( $stripped_email ); // slugify the email address
     312        $obscura              = wp_generate_password( 32, false, false );
     313        $file_basename        = 'wp-personal-data-file-' . $stripped_email . '-' . $obscura;
     314        $html_report_filename = $file_basename . '.html';
     315        $html_report_pathname = wp_normalize_path( $exports_dir . $html_report_filename );
     316        $file                 = fopen( $html_report_pathname, 'w' );
     317        if ( false === $file ) {
     318                wp_send_json_error( __( 'Unable to open export file (HTML report) for writing.' ) );
     319        }
     320
     321        $title = sprintf(
     322                /* translators: %s: user's email address */
     323                __( 'Personal Data Export for %s' ),
     324                $email_address
     325        );
     326
     327        // Open HTML.
     328        fwrite( $file, "<!DOCTYPE html>\n" );
     329        fwrite( $file, "<html>\n" );
     330
     331        // Head.
     332        fwrite( $file, "<head>\n" );
     333        fwrite( $file, "<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />\n" );
     334        fwrite( $file, "<style type='text/css'>" );
     335        fwrite( $file, 'body { color: black; font-family: Arial, sans-serif; font-size: 11pt; margin: 15px auto; width: 860px; }' );
     336        fwrite( $file, 'table { background: #f0f0f0; border: 1px solid #ddd; margin-bottom: 20px; width: 100%; }' );
     337        fwrite( $file, 'th { padding: 5px; text-align: left; width: 20%; }' );
     338        fwrite( $file, 'td { padding: 5px; }' );
     339        fwrite( $file, 'tr:nth-child(odd) { background-color: #fafafa; }' );
     340        fwrite( $file, '</style>' );
     341        fwrite( $file, '<title>' );
     342        fwrite( $file, esc_html( $title ) );
     343        fwrite( $file, '</title>' );
     344        fwrite( $file, "</head>\n" );
     345
     346        // Body.
     347        fwrite( $file, "<body>\n" );
     348
     349        // Heading.
     350        fwrite( $file, '<h1>' . esc_html__( 'Personal Data Export' ) . '</h1>' );
     351
     352        // And now, all the Groups.
     353        $groups = get_post_meta( $request_id, '_export_data_grouped', true );
     354
     355        // First, build an "About" group on the fly for this report.
     356        $about_group = array(
     357                /* translators: Header for the About section in a personal data export. */
     358                'group_label' => _x( 'About', 'personal data group label' ),
     359                'items'       => array(
     360                        'about-1' => array(
     361                                array(
     362                                        'name'  => _x( 'Report generated for', 'email address' ),
     363                                        'value' => $email_address,
     364                                ),
     365                                array(
     366                                        'name'  => _x( 'For site', 'website name' ),
     367                                        'value' => get_bloginfo( 'name' ),
     368                                ),
     369                                array(
     370                                        'name'  => _x( 'At URL', 'website URL' ),
     371                                        'value' => get_bloginfo( 'url' ),
     372                                ),
     373                                array(
     374                                        'name'  => _x( 'On', 'date/time' ),
     375                                        'value' => current_time( 'mysql' ),
     376                                ),
     377                        ),
     378                ),
     379        );
     380
     381        // Merge in the special about group.
     382        $groups = array_merge( array( 'about' => $about_group ), $groups );
     383
     384        // Now, iterate over every group in $groups and have the formatter render it in HTML.
     385        foreach ( (array) $groups as $group_id => $group_data ) {
     386                fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group_data ) );
     387        }
     388
     389        fwrite( $file, "</body>\n" );
     390
     391        // Close HTML.
     392        fwrite( $file, "</html>\n" );
     393        fclose( $file );
     394
     395        /*
     396         * Now, generate the ZIP.
     397         *
     398         * If an archive has already been generated, then remove it and reuse the
     399         * filename, to avoid breaking any URLs that may have been previously sent
     400         * via email.
     401         */
     402        $error            = false;
     403        $archive_url      = get_post_meta( $request_id, '_export_file_url', true );
     404        $archive_pathname = get_post_meta( $request_id, '_export_file_path', true );
     405
     406        if ( empty( $archive_pathname ) || empty( $archive_url ) ) {
     407                $archive_filename = $file_basename . '.zip';
     408                $archive_pathname = $exports_dir . $archive_filename;
     409                $archive_url      = $exports_url . $archive_filename;
     410
     411                update_post_meta( $request_id, '_export_file_url', $archive_url );
     412                update_post_meta( $request_id, '_export_file_path', wp_normalize_path( $archive_pathname ) );
     413        }
     414
     415        if ( ! empty( $archive_pathname ) && file_exists( $archive_pathname ) ) {
     416                wp_delete_file( $archive_pathname );
     417        }
     418
     419        $zip = new ZipArchive;
     420        if ( true === $zip->open( $archive_pathname, ZipArchive::CREATE ) ) {
     421                if ( ! $zip->addFile( $html_report_pathname, 'index.html' ) ) {
     422                        $error = __( 'Unable to add data to export file.' );
     423                }
     424
     425                $zip->close();
     426
     427                if ( ! $error ) {
     428                        /**
     429                         * Fires right after all personal data has been written to the export file.
     430                         *
     431                         * @since 4.9.6
     432                         *
     433                         * @param string $archive_pathname     The full path to the export file on the filesystem.
     434                         * @param string $archive_url          The URL of the archive file.
     435                         * @param string $html_report_pathname The full path to the personal data report on the filesystem.
     436                         * @param int    $request_id           The export request ID.
     437                         */
     438                        do_action( 'wp_privacy_personal_data_export_file_created', $archive_pathname, $archive_url, $html_report_pathname, $request_id );
     439                }
     440        } else {
     441                $error = __( 'Unable to open export file (archive) for writing.' );
     442        }
     443
     444        // And remove the HTML file.
     445        unlink( $html_report_pathname );
     446
     447        if ( $error ) {
     448                wp_send_json_error( $error );
     449        }
     450}
     451
     452/**
     453 * Send an email to the user with a link to the personal data export file
     454 *
     455 * @since 4.9.6
     456 *
     457 * @param int $request_id The request ID for this personal data export.
     458 * @return true|WP_Error True on success or `WP_Error` on failure.
     459 */
     460function wp_privacy_send_personal_data_export_email( $request_id ) {
     461        // Get the request data.
     462        $request = wp_get_user_request_data( $request_id );
     463
     464        if ( ! $request || 'export_personal_data' !== $request->action_name ) {
     465                return new WP_Error( 'invalid_request', __( 'Invalid request ID when sending personal data export email.' ) );
     466        }
     467
     468        // Localize message content for user; fallback to site default for visitors.
     469        if ( ! empty( $request->user_id ) ) {
     470                $locale = get_user_locale( $request->user_id );
     471        } else {
     472                $locale = get_locale();
     473        }
     474
     475        $switched_locale = switch_to_locale( $locale );
     476
     477        /** This filter is documented in wp-includes/functions.php */
     478        $expiration      = apply_filters( 'wp_privacy_export_expiration', 3 * DAY_IN_SECONDS );
     479        $expiration_date = date_i18n( get_option( 'date_format' ), time() + $expiration );
     480
     481        /* translators: Do not translate EXPIRATION, LINK, SITENAME, SITEURL: those are placeholders. */
     482        $email_text = __(
     483                'Howdy,
     484
     485Your request for an export of personal data has been completed. You may
     486download your personal data by clicking on the link below. For privacy
     487and security, we will automatically delete the file on ###EXPIRATION###,
     488so please download it before then.
     489
     490###LINK###
     491
     492Regards,
     493All at ###SITENAME###
     494###SITEURL###'
     495        );
     496
     497        /**
     498         * Filters the text of the email sent with a personal data export file.
     499         *
     500         * The following strings have a special meaning and will get replaced dynamically:
     501         * ###EXPIRATION###         The date when the URL will be automatically deleted.
     502         * ###LINK###               URL of the personal data export file for the user.
     503         * ###SITENAME###           The name of the site.
     504         * ###SITEURL###            The URL to the site.
     505         *
     506         * @since 4.9.6
     507         *
     508         * @param string $email_text     Text in the email.
     509         * @param int    $request_id     The request ID for this personal data export.
     510         */
     511        $content = apply_filters( 'wp_privacy_personal_data_email_content', $email_text, $request_id );
     512
     513        $email_address   = $request->email;
     514        $export_file_url = get_post_meta( $request_id, '_export_file_url', true );
     515        $site_name       = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
     516        $site_url        = home_url();
     517
     518        $content = str_replace( '###EXPIRATION###', $expiration_date, $content );
     519        $content = str_replace( '###LINK###', esc_url_raw( $export_file_url ), $content );
     520        $content = str_replace( '###EMAIL###', $email_address, $content );
     521        $content = str_replace( '###SITENAME###', $site_name, $content );
     522        $content = str_replace( '###SITEURL###', esc_url_raw( $site_url ), $content );
     523
     524        $mail_success = wp_mail(
     525                $email_address,
     526                sprintf(
     527                        /* translators: Personal data export notification email subject. %s: Site title */
     528                        __( '[%s] Personal Data Export' ),
     529                        $site_name
     530                ),
     531                $content
     532        );
     533
     534        if ( $switched_locale ) {
     535                restore_previous_locale();
     536        }
     537
     538        if ( ! $mail_success ) {
     539                return new WP_Error( 'privacy_email_error', __( 'Unable to send personal data export email.' ) );
     540        }
     541
     542        return true;
     543}
     544
     545/**
     546 * Intercept personal data exporter page Ajax responses in order to assemble the personal data export file.
     547 * @see wp_privacy_personal_data_export_page
     548 * @since 4.9.6
     549 *
     550 * @param array  $response        The response from the personal data exporter for the given page.
     551 * @param int    $exporter_index  The index of the personal data exporter. Begins at 1.
     552 * @param string $email_address   The email address of the user whose personal data this is.
     553 * @param int    $page            The page of personal data for this exporter. Begins at 1.
     554 * @param int    $request_id      The request ID for this personal data export.
     555 * @param bool   $send_as_email   Whether the final results of the export should be emailed to the user.
     556 * @param string $exporter_key    The slug (key) of the exporter.
     557 * @return array The filtered response.
     558 */
     559function wp_privacy_process_personal_data_export_page( $response, $exporter_index, $email_address, $page, $request_id, $send_as_email, $exporter_key ) {
     560        /* Do some simple checks on the shape of the response from the exporter.
     561         * If the exporter response is malformed, don't attempt to consume it - let it
     562         * pass through to generate a warning to the user by default Ajax processing.
     563         */
     564        if ( ! is_array( $response ) ) {
     565                return $response;
     566        }
     567
     568        if ( ! array_key_exists( 'done', $response ) ) {
     569                return $response;
     570        }
     571
     572        if ( ! array_key_exists( 'data', $response ) ) {
     573                return $response;
     574        }
     575
     576        if ( ! is_array( $response['data'] ) ) {
     577                return $response;
     578        }
     579
     580        // Get the request data.
     581        $request = wp_get_user_request_data( $request_id );
     582
     583        if ( ! $request || 'export_personal_data' !== $request->action_name ) {
     584                wp_send_json_error( __( 'Invalid request ID when merging exporter data.' ) );
     585        }
     586
     587        $export_data = array();
     588
     589        // First exporter, first page? Reset the report data accumulation array.
     590        if ( 1 === $exporter_index && 1 === $page ) {
     591                update_post_meta( $request_id, '_export_data_raw', $export_data );
     592        } else {
     593                $export_data = get_post_meta( $request_id, '_export_data_raw', true );
     594        }
     595
     596        // Now, merge the data from the exporter response into the data we have accumulated already.
     597        $export_data = array_merge( $export_data, $response['data'] );
     598        update_post_meta( $request_id, '_export_data_raw', $export_data );
     599
     600        // If we are not yet on the last page of the last exporter, return now.
     601        /** This filter is documented in wp-admin/includes/ajax-actions.php */
     602        $exporters        = apply_filters( 'wp_privacy_personal_data_exporters', array() );
     603        $is_last_exporter = $exporter_index === count( $exporters );
     604        $exporter_done    = $response['done'];
     605        if ( ! $is_last_exporter || ! $exporter_done ) {
     606                return $response;
     607        }
     608
     609        // Last exporter, last page - let's prepare the export file.
     610
     611        // First we need to re-organize the raw data hierarchically in groups and items.
     612        $groups = array();
     613        foreach ( (array) $export_data as $export_datum ) {
     614                $group_id    = $export_datum['group_id'];
     615                $group_label = $export_datum['group_label'];
     616                if ( ! array_key_exists( $group_id, $groups ) ) {
     617                        $groups[ $group_id ] = array(
     618                                'group_label' => $group_label,
     619                                'items'       => array(),
     620                        );
     621                }
     622
     623                $item_id = $export_datum['item_id'];
     624                if ( ! array_key_exists( $item_id, $groups[ $group_id ]['items'] ) ) {
     625                        $groups[ $group_id ]['items'][ $item_id ] = array();
     626                }
     627
     628                $old_item_data                            = $groups[ $group_id ]['items'][ $item_id ];
     629                $merged_item_data                         = array_merge( $export_datum['data'], $old_item_data );
     630                $groups[ $group_id ]['items'][ $item_id ] = $merged_item_data;
     631        }
     632
     633        // Then save the grouped data into the request.
     634        delete_post_meta( $request_id, '_export_data_raw' );
     635        update_post_meta( $request_id, '_export_data_grouped', $groups );
     636
     637        /**
     638         * Generate the export file from the collected, grouped personal data.
     639         *
     640         * @since 4.9.6
     641         *
     642         * @param int $request_id The export request ID.
     643         */
     644        do_action( 'wp_privacy_personal_data_export_file', $request_id );
     645
     646        // Clear the grouped data now that it is no longer needed.
     647        delete_post_meta( $request_id, '_export_data_grouped' );
     648
     649        // If the destination is email, send it now.
     650        if ( $send_as_email ) {
     651                $mail_success = wp_privacy_send_personal_data_export_email( $request_id );
     652                if ( is_wp_error( $mail_success ) ) {
     653                        wp_send_json_error( $mail_success->get_error_message() );
     654                }
     655
     656                // Update the request to completed state when the export email is sent.
     657                _wp_privacy_completed_request( $request_id );
     658        } else {
     659                // Modify the response to include the URL of the export file so the browser can fetch it.
     660                $export_file_url = get_post_meta( $request_id, '_export_file_url', true );
     661                if ( ! empty( $export_file_url ) ) {
     662                        $response['url'] = $export_file_url;
     663                }
     664        }
     665
     666        return $response;
     667}
     668
     669/**
    215670 * Mark erasure requests as completed after processing is finished.
    216671 *
Note: See TracChangeset for help on using the changeset viewer.