WordPress.org

Make WordPress Core

Ticket #43546: 43546.6.diff

File 43546.6.diff, 19.8 KB (added by allendav, 2 years ago)

Uses postmeta for temporary storage (instead of options), vastly improved error logging, and ZIP/HTML action is now hookable/replaceable

  • src/wp-admin/includes/admin-filters.php

     
    133133add_action( 'upgrader_process_complete', 'wp_update_plugins', 10, 0 );
    134134add_action( 'upgrader_process_complete', 'wp_update_themes', 10, 0 );
    135135
     136// Privacy hooks
     137add_filter( 'wp_privacy_personal_data_export_page', 'wp_privacy_process_personal_data_export_page', 10, 5 );
     138add_action( 'wp_privacy_personal_data_export_file', 'wp_privacy_generate_personal_data_export_file', 10 );
     139
    136140// Privacy policy text changes check.
    137141add_action( 'admin_init', array( 'WP_Privacy_Policy_Content', 'text_change_check' ), 20 );
    138142
     
    144148
    145149// Stop checking for text changes after the policy page is updated.
    146150add_action( 'post_updated', array( 'WP_Privacy_Policy_Content', '_policy_page_updated' ) );
    147 
  • src/wp-admin/includes/ajax-actions.php

     
    43284328}
    43294329
    43304330function wp_ajax_wp_privacy_export_personal_data() {
    4331         check_ajax_referer( 'wp-privacy-export-personal-data', 'security' );
     4331        $request_id  = (int) $_POST['id'];
    43324332
     4333        if ( empty( $request_id ) ) {
     4334                wp_send_json_error( __( 'Error: Invalid request ID.' ) );
     4335        }
     4336
    43334337        if ( ! current_user_can( 'manage_options' ) ) {
    43344338                wp_send_json_error( __( 'Error: Invalid request.' ) );
    43354339        }
    43364340
    4337         $email_address  = sanitize_text_field( $_POST['email'] );
     4341        check_ajax_referer( 'wp-privacy-export-personal-data-' . $request_id, 'security' );
     4342
     4343        // Find the request CPT
     4344        $request = get_post( $request_id );
     4345        if ( 'user_export_request' !== $request->post_type ) {
     4346                wp_send_json_error( 'internal error: invalid request id' );
     4347        }
     4348
     4349        $email_address = get_post_meta( $request_id, '_user_email', true );
     4350
     4351        if ( ! is_email( $email_address ) ) {
     4352                wp_send_json_error( 'A valid email address must be given.' );
     4353        }
     4354
    43384355        $exporter_index = (int) $_POST['exporter'];
    43394356        $page           = (int) $_POST['page'];
    43404357
     
    43754392                        wp_send_json_error( 'Page index cannot be less than one.' );
    43764393                }
    43774394
    4378                 // Surprisingly, email addresses can contain mutli-byte characters now
    4379                 $email_address = trim( mb_strtolower( $email_address ) );
    4380 
    4381                 if ( ! is_email( $email_address ) ) {
    4382                         wp_send_json_error( 'A valid email address must be given.' );
    4383                 }
    4384 
    43854395                $exporter = $exporters[ $index ];
    43864396                if ( ! is_array( $exporter ) ) {
    43874397                        wp_send_json_error( "Expected an array describing the exporter at index {$exporter_index}." );
     
    44354445         * @param int    $exporter_index  The index of the exporter that provided this data.
    44364446         * @param string $email_address   The email address associated with this personal data.
    44374447         * @param int    $page            The zero-based page for this response.
     4448         * @param int    $request_id      The privacy request post ID associated with this request.
    44384449         */
    4439         $response = apply_filters( 'wp_privacy_personal_data_export_page', $response, $exporter_index, $email_address, $page );
     4450        $response = apply_filters( 'wp_privacy_personal_data_export_page', $response, $exporter_index, $email_address, $page, $request_id );
    44404451        if ( is_wp_error( $response ) ) {
    44414452                wp_send_json_error( $response );
    44424453        }
  • src/wp-admin/includes/file.php

     
    19341934        </div>
    19351935        <?php
    19361936}
     1937
     1938/**
     1939 * Generate a single group for the personal data export report.
     1940 *
     1941 * @since 4.9.6
     1942 *
     1943 * @param array  $group_data The group data to render.
     1944 *     'group_label'          string             The user-facing heading for the group, e.g. 'Comments'.
     1945 *     'items'                associative-array  An array of group items.
     1946 *       $group_item_id       string             A group item ID (e.g. 'comment-123' ).
     1947 *       => $group_item_data  array              An array of name-value pairs for the item.
     1948 *         'name'             string             The user-facing name of an item name-value pair, e.g. 'IP Address'.
     1949 *         'value'            string             The user-facing value of an item data pair, e.g. '50.60.70.0'.
     1950 * @return string The HTML for this group and its items
     1951 */
     1952function wp_privacy_generate_personal_data_export_group_html( $group_data ) {
     1953        $allowed_tags      = array( 'a' => array( 'href', 'target'), 'br' => array() );
     1954        $allowed_protocols = array( 'http', 'https' );
     1955        $group_html        = '';
     1956
     1957        $group_html .= '<h2>' . esc_html( $group_data['group_label'] ) . '</h2>';
     1958        $group_html .= '<div>';
     1959
     1960        foreach ( (array) $group_data['items'] as $group_item_id => $group_item_data ) {
     1961                $group_html .= '<table>';
     1962                $group_html .= '<tbody>';
     1963
     1964                foreach ( (array) $group_item_data as $group_item_datum ) {
     1965                        $group_html .= '<tr>';
     1966                        $group_html .= '<th>' . esc_html( $group_item_datum['name'] ) . '</th>';
     1967                        $group_html .= '<td>' . wp_kses( $group_item_datum['value'], $allowed_tags, $allowed_protocols ) . '</td>';
     1968                        $group_html .= '</tr>';
     1969                }
     1970
     1971                $group_html .= '</tbody>';
     1972                $group_html .= '</table>';
     1973        }
     1974
     1975        $group_html .= '</div>';
     1976
     1977        return $group_html;
     1978}
     1979
     1980/**
     1981 * Generate the personal data export file.
     1982 *
     1983 * @since 4.9.6
     1984 *
     1985 * @param int  $request_id  The export request ID.
     1986 */
     1987function wp_privacy_generate_personal_data_export_file( $request_id ) {
     1988        // Maybe make this a cron job instead
     1989        wp_privacy_delete_old_export_files();
     1990
     1991        if ( ! class_exists( 'ZipArchive' ) ) {
     1992                wp_send_json_error( __( 'Unable to generate export file. ZipArchive not available.' ) );
     1993        }
     1994
     1995        // Get the request
     1996        $request = get_post( $request_id );
     1997        if ( 'user_export_request' !== $request->post_type ) {
     1998                wp_send_json_error( __( 'Invalid request ID when generating export file' ) );
     1999        }
     2000
     2001        // Create the exports folder if needed
     2002        $upload_dir  = wp_upload_dir();
     2003        $exports_dir = trailingslashit( $upload_dir['basedir'] . '/exports' );
     2004        $exports_url = trailingslashit( $upload_dir['baseurl'] . '/exports' );
     2005
     2006        $result = wp_mkdir_p( $exports_dir );
     2007        if ( is_wp_error( $result ) ) {
     2008                wp_send_json_error( $result->get_error_message() );
     2009        }
     2010
     2011        // Protect export folder from browsing
     2012        $index_pathname = $exports_dir . 'index.html';
     2013        if ( ! file_exists( $index_pathname ) ) {
     2014                $file = fopen( $index_pathname, 'w' );
     2015                if ( false === $file ) {
     2016                        wp_send_json_error( __( 'Unable to protect export folder from browsing' ) );
     2017                }
     2018                fwrite( $file, 'Silence is golden.' );
     2019                fclose( $file );
     2020        }
     2021
     2022        // Generate a difficult to guess filename
     2023        $email_address = get_post_meta( $request_id, '_user_email', true );
     2024        if ( ! is_email( $email_address ) ) {
     2025                wp_send_json_error( __( 'Invalid email address when generating export file' ) );
     2026        }
     2027
     2028        $stripped_email       = str_replace( '@', '-at-', $email_address );
     2029        $stripped_email       = sanitize_title( $stripped_email ); // slugify the email address
     2030        $obscura              = md5( rand() );
     2031        $file_basename        = 'wp-personal-data-file-' . $stripped_email . '-' . $obscura;
     2032        $html_report_filename = $file_basename . '.html';
     2033        $html_report_pathname = $exports_dir . $html_report_filename;
     2034        $file = fopen( $html_report_pathname, 'w' );
     2035        if ( false === $file ) {
     2036                wp_send_json_error( __( 'Unable to open export file (HTML report) for writing' ) );
     2037        }
     2038
     2039        $title = sprintf(
     2040                __( 'Personal Data Export for %s' ),
     2041                $email_address
     2042        );
     2043
     2044        // Open HTML
     2045        fwrite( $file, "<!DOCTYPE html>\n" );
     2046        fwrite( $file, "<html>\n" );
     2047
     2048        // Head
     2049        fwrite( $file, "<head>\n" );
     2050        fwrite( $file, "<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />\n" );
     2051        fwrite( $file, "<style type='text/css'>" );
     2052        fwrite( $file, "body { color: black; font-family: Arial, sans-serif; font-size: 11pt; margin: 15px auto; width: 860px; }" );
     2053        fwrite( $file, "table { background: #f0f0f0; border: 1px solid #ddd; margin-bottom: 20px; width: 100%; }" );
     2054        fwrite( $file, "th { padding: 5px; text-align: left; width: 20%; }" );
     2055        fwrite( $file, "td { padding: 5px; }" );
     2056        fwrite( $file, "tr:nth-child(odd) { background-color: #fafafa; }" );
     2057        fwrite( $file, "</style>" );
     2058        fwrite( $file, "<title>" );
     2059        fwrite( $file, esc_html( $title ) );
     2060        fwrite( $file, "</title>" );
     2061        fwrite( $file, "</head>\n" );
     2062
     2063        // Body
     2064        fwrite( $file, "<body>\n" );
     2065
     2066        // Heading
     2067        fwrite( $file, "<h1>" . esc_html__( 'Personal Data Export' ) . "</h1>" );
     2068
     2069        // And now, all the Groups
     2070        $groups = get_post_meta( $request_id, '_export_data_grouped', true );
     2071
     2072        // First, build a "About" group on the fly for this report
     2073        $about_group = array(
     2074                'group_label' => __( 'About' ),
     2075                'items'       => array(
     2076                        'about-1' => array(
     2077                                array(
     2078                                        'name'  => __( 'Report generated for' ),
     2079                                        'value' => $email_address,
     2080                                ),
     2081                                array(
     2082                                        'name'  => __( 'For site' ),
     2083                                        'value' => get_bloginfo( 'name' ),
     2084                                ),
     2085                                array(
     2086                                        'name'  => __( 'At URL' ),
     2087                                        'value' => get_bloginfo( 'url' ),
     2088                                ),
     2089                                array(
     2090                                        'name'  => __( 'On' ),
     2091                                        'value' => current_time( 'mysql' ),
     2092                                ),
     2093                        ),
     2094                )
     2095        );
     2096
     2097        // Merge in the special about group
     2098        $groups = array_merge( array( 'about' => $about_group ), $groups );
     2099
     2100        // Now, iterate over every group in $groups and have the formatter render it in HTML
     2101        foreach ( (array) $groups as $group_id => $group_data ) {
     2102                fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group_data ) );
     2103        }
     2104
     2105        fwrite( $file, "</body>\n" );
     2106
     2107        // Close HTML
     2108        fwrite( $file, "</html>\n" );
     2109        fclose( $file );
     2110
     2111        // Now, generate the ZIP
     2112        $archive_filename = $file_basename . '.zip';
     2113        $archive_pathname = $exports_dir . $archive_filename;
     2114        $archive_url      = $exports_url . $archive_filename;
     2115
     2116        $zip = new ZipArchive;
     2117
     2118        if ( TRUE === $zip->open( $archive_pathname, ZipArchive::CREATE ) ) {
     2119                $zip->addFile( $html_report_pathname, 'index.html' );
     2120                $zip->close();
     2121        } else {
     2122                wp_send_json_error( __( 'Unable to open export file (archive) for writing' ) );
     2123        }
     2124
     2125        // And remove the HTML file
     2126        unlink( $html_report_pathname );
     2127
     2128        // Save the export file in the request
     2129        update_post_meta( $request_id, '_export_file_url', $archive_url );
     2130        update_post_meta( $request_id, '_export_file_path', $archive_pathname );
     2131}
     2132
     2133/**
     2134 * Intercept personal data exporter page ajax responses in order to assemble the personal data export file.
     2135 * @see wp_privacy_personal_data_export_page
     2136 * @since 4.9.6
     2137 *
     2138 * @param array  $response        The response from the personal data exporter for the given page.
     2139 * @param int    $exporter_index  The index of the personal data exporter. Begins at 1.
     2140 * @param string $email_address   The email address of the user whose personal data this is.
     2141 * @param int    $page            The page of personal data for this exporter. Begins at 1.
     2142 * @param int    $request_id      The request ID for this personal data export.
     2143 * @return array The filtered response.
     2144 */
     2145function wp_privacy_process_personal_data_export_page( $response, $exporter_index, $email_address, $page, $request_id ) {
     2146        // Do some simple checks on the shape of the response from the exporter
     2147        // If the exporter response is malformed, don't attempt to consume it - let it
     2148        // pass through to generate a warning to the user by default ajax processing
     2149        if ( ! is_array( $response ) ) {
     2150                return $response;
     2151        }
     2152
     2153        if ( ! array_key_exists( 'done', $response ) ) {
     2154                return $response;
     2155        }
     2156
     2157        if ( ! array_key_exists( 'data', $response ) ) {
     2158                return $response;
     2159        }
     2160
     2161        if ( ! is_array( $response['data'] ) ) {
     2162                return $response;
     2163        }
     2164
     2165        // Get the request
     2166        $request = get_post( $request_id );
     2167        if ( 'user_export_request' !== $request->post_type ) {
     2168                wp_send_json_error( __( 'Invalid request ID when merging exporter data' ) );
     2169        }
     2170
     2171        $export_data = array();
     2172
     2173        // First exporter, first page? Reset the report data accumulation array
     2174        if ( 1 === $exporter_index && 1 === $page ) {
     2175                update_post_meta( $request_id, '_export_data_raw', $export_data );
     2176        } else {
     2177                $export_data = get_post_meta( $request_id, '_export_data_raw', true );
     2178        }
     2179
     2180        // Now, merge the data from the exporter response into the data we have accumulated already
     2181        $export_data = array_merge( $export_data, $response['data'] );
     2182        update_post_meta( $request_id, '_export_data_raw', $export_data );
     2183
     2184        // If we are not yet on the last page of the last exporter, return now
     2185        $exporters = apply_filters( 'wp_privacy_personal_data_exporters', array() );
     2186        $is_last_exporter = $exporter_index === count( $exporters );
     2187        $exporter_done = $response['done'];
     2188        if ( ! $is_last_exporter || ! $exporter_done ) {
     2189                return $response;
     2190        }
     2191
     2192        // Now we need to re-organize the raw data hierarchically in groups and items.
     2193        $groups = array();
     2194        foreach ( (array) $export_data as $export_datum ) {
     2195                $group_id    = $export_datum['group_id'];
     2196                $group_label = $export_datum['group_label'];
     2197                if ( ! array_key_exists( $group_id, $groups ) ) {
     2198                        $groups[ $group_id ] = array(
     2199                                'group_label' => $group_label,
     2200                                'items'       => array(),
     2201                        );
     2202                }
     2203
     2204                $item_id = $export_datum['item_id'];
     2205                if ( ! array_key_exists( $item_id, $groups[ $group_id ]['items'] ) ) {
     2206                        $groups[ $group_id ]['items'][ $item_id ] = array();
     2207                }
     2208
     2209                $old_item_data = $groups[ $group_id ]['items'][ $item_id ];
     2210                $merged_item_data = array_merge( $export_datum['data'], $old_item_data );
     2211                $groups[ $group_id ]['items'][ $item_id ] = $merged_item_data;
     2212        }
     2213
     2214        // Save the grouped data into the request
     2215        delete_post_meta( $request_id, '_export_data_raw' );
     2216        update_post_meta( $request_id, '_export_data_grouped', $groups );
     2217
     2218        // And now, generate the export file
     2219        $report_path = get_post_meta( $request_id, '_export_file_path', true );
     2220        if ( ! empty( $request_path ) ) {
     2221                delete_post_meta( $request_id, '_export_file_path' );
     2222                @unlink( $request_path );
     2223        }
     2224        delete_post_meta( $request_id, '_export_file_url' );
     2225
     2226        // Act on the collected, grouped personal data
     2227        do_action( 'wp_privacy_personal_data_export_file', $request_id );
     2228
     2229        // Clear the grouped data now that it is no longer needed
     2230        delete_post_meta( $request_id, '_export_data_grouped' );
     2231
     2232        // Modify the response to include the URL of the export file
     2233        $export_file_url = get_post_meta( $request_id, '_export_file_url', true );
     2234        if ( ! empty( $export_file_url ) ) {
     2235                $response['url'] = $export_file_url;
     2236        }
     2237
     2238        return $response;
     2239}
     2240
     2241/**
     2242 * Cleans up export files older than three days old
     2243 *
     2244 * @since 4.9.6
     2245 */
     2246function wp_privacy_delete_old_export_files() {
     2247        $upload_dir  = wp_upload_dir();
     2248        $exports_dir = trailingslashit( $upload_dir['basedir'] . '/exports' );
     2249        $export_files = list_files( $exports_dir );
     2250
     2251        foreach( (array) $export_files as $export_file ) {
     2252                $file_age_in_seconds = time() - filemtime( $export_file );
     2253
     2254                if ( 3 * DAY_IN_SECONDS < $file_age_in_seconds ) {
     2255                        @unlink( $export_file );
     2256                }
     2257        }
     2258}
  • src/wp-admin/includes/user.php

     
    847847
    848848        _wp_personal_data_handle_actions();
    849849
     850        // "Borrow" xfn.js for now so we don't have to create new files.
     851        wp_enqueue_script( 'xfn' );
     852
    850853        $requests_table = new WP_Privacy_Data_Export_Requests_Table( array(
    851854                'plural'   => 'privacy_requests',
    852855                'singular' => 'privacy_request',
  • src/wp-admin/js/xfn.js

     
    3939
    4040        function appendResultsAfterRow( $requestRow, classes, summaryMessage, additionalMessages ) {
    4141                clearResultsAfterRow( $requestRow );
     42
     43                var itemList = '';
    4244                if ( additionalMessages.length ) {
    43                         // TODO - render additionalMessages after the summaryMessage
     45                        $.each( additionalMessages, function( index, value ) {
     46                                itemList = itemList + '<li>' + value + '</li>';
     47                        } );
     48                        itemList = '<ul>' + itemList + '</ul>';
    4449                }
    4550
    4651                $requestRow.after( function() {
    47                         return '<tr class="request-results"><td colspan="5"><div class="notice inline notice-alt ' + classes + '"><p>' +
     52                        return '<tr class="request-results"><td colspan="5">' +
     53                                '<div class="notice inline notice-alt ' + classes + '">' +
     54                                '<p>' +
    4855                                summaryMessage +
    49                                 '</p></div></td></tr>';
     56                                '</p>' +
     57                                itemList +
     58                                '</div>' +
     59                                '</td>' +
     60                                '</tr>';
    5061                } );
    5162        }
    5263
     64        $( '.download_personal_data a' ).click( function( event ) {
     65                event.preventDefault();
     66                event.stopPropagation();
     67
     68                var $this          = $( this );
     69                var $action        = $this.parents( '.download_personal_data' );
     70                var $requestRow    = $this.parents( 'tr' );
     71                var requestID      = $action.data( 'request-id' );
     72                var nonce          = $action.data( 'nonce' );
     73                var exportersCount = $action.data( 'exporters-count' );
     74
     75                $action.blur();
     76                clearResultsAfterRow( $requestRow );
     77
     78                function on_export_done_success( zipUrl ) {
     79                        set_action_state( $action, 'download_personal_data_idle' );
     80                        if ( 'undefined' !== typeof zipUrl ) {
     81                                window.location = zipUrl;
     82                        } else {
     83                                on_export_failure( strings.noExportFile );
     84                        }
     85                }
     86
     87                function on_export_failure( errorMessage ) {
     88                        set_action_state( $action, 'download_personal_data_failed' );
     89                        if ( errorMessage ) {
     90                                appendResultsAfterRow( $requestRow, 'notice-error', strings.exportError, [ errorMessage ] );
     91                        }
     92                }
     93
     94                function do_next_export( exporterIndex, pageIndex ) {
     95                        $.ajax(
     96                                {
     97                                        url: ajaxurl,
     98                                        data: {
     99                                                action: 'wp-privacy-export-personal-data',
     100                                                exporter: exporterIndex,
     101                                                id: requestID,
     102                                                page: pageIndex,
     103                                                security: nonce,
     104                                        },
     105                                        method: 'post'
     106                                }
     107                        ).done( function( response ) {
     108                                if ( ! response.success ) {
     109                                        // e.g. invalid request ID
     110                                        on_export_failure( response.data );
     111                                        return;
     112                                }
     113                                var responseData = response.data;
     114                                if ( ! responseData.done ) {
     115                                        setTimeout( do_next_export( exporterIndex, pageIndex + 1 ) );
     116                                } else {
     117                                        if ( exporterIndex < exportersCount ) {
     118                                                setTimeout( do_next_export( exporterIndex + 1, 1 ) );
     119                                        } else {
     120                                                on_export_done_success( responseData.url );
     121                                        }
     122                                }
     123                        } ).fail( function( jqxhr, textStatus, error ) {
     124                                // e.g. Nonce failure
     125                                on_export_failure( error );
     126                        } );
     127                }
     128
     129                // And now, let's begin
     130                set_action_state( $action, 'download_personal_data_processing' );
     131                do_next_export( 1, 1 );
     132        } )
     133
    53134        $( '.remove_personal_data a' ).click( function( event ) {
    54135                event.preventDefault();
    55136                event.stopPropagation();
     
    92173
    93174                function on_erasure_failure() {
    94175                        set_action_state( $action, 'remove_personal_data_failed' );
    95                         appendResultsAfterRow( $requestRow, 'notice-error', strings.anErrorOccurred, [] );
     176                        appendResultsAfterRow( $requestRow, 'notice-error', strings.removalError, [] );
    96177                }
    97178
    98179                function do_next_erasure( eraserIndex, pageIndex ) {
  • src/wp-includes/script-loader.php

     
    715715                                'foundAndRemoved' => __( 'All of the personal data found for this user was removed.' ),
    716716                                'noneRemoved'     => __( 'Personal data was found for this user but was not removed.' ),
    717717                                'someNotRemoved'  => __( 'Personal data was found for this user but some of the personal data found was not removed.' ),
    718                                 'anErrorOccurred' => __( 'An error occurred while attempting to find and remove personal data.' ),
     718                                'removalError'    => __( 'An error occurred while attempting to find and remove personal data.' ),
     719                                'noExportFile'    => __( 'No personal data export file was generated.' ),
     720                                'exportError'     => __( 'An error occurred while attempting to export personal data.' ),
    719721                        )
    720722                );
    721723