WordPress.org

Make WordPress Core

Ticket #43546: 43546.2.diff

File 43546.2.diff, 15.7 KB (added by allendav, 2 years ago)

Work in progress - personal data export to ZIP file

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

     
    132132add_action( 'upgrader_process_complete', 'wp_version_check', 10, 0 );
    133133add_action( 'upgrader_process_complete', 'wp_update_plugins', 10, 0 );
    134134add_action( 'upgrader_process_complete', 'wp_update_themes', 10, 0 );
     135
     136// Privacy hooks
     137add_filter( 'wp_privacy_personal_data_export_page', 'wp_privacy_process_personal_data_export_page', 10, 4 );
  • 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        // check_ajax_referer( 'wp-privacy-export-personal-data', 'security' );
     4332        // TODO nonce with request ID
    43324333
    43334334        if ( ! current_user_can( 'manage_options' ) ) {
    43344335                wp_send_json_error( 'access denied' );
    43354336        }
    43364337
    4337         $email_address  = sanitize_text_field( $_POST['email'] );
     4338        $request_id  = sanitize_text_field( $_POST['id'] );
     4339
     4340        // Find the request CPT
     4341        $request = get_post( $request_id );
     4342        if ( 'user_export_request' !== $request->post_type ) {
     4343                wp_send_json_error( 'internal error: invalid request id' );
     4344        }
     4345
     4346        $email_address = get_post_meta( $request_id, '_user_email', true );
     4347
     4348        // Surprisingly, email addresses can contain mutli-byte characters now
     4349        $email_address = trim( mb_strtolower( $email_address ) );
     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// TODO phpDocs
     1939function wp_privacy_generate_personal_data_export_group_html( $group_data ) {
     1940        $group_html = '';
     1941
     1942        $group_html .= '<h2>' . esc_html( $group_data['group_label'] ) . '</h2>';
     1943        $group_html .= '<div>';
     1944
     1945        foreach ( (array) $group_data['items'] as $group_item_id => $group_item_data ) {
     1946                $group_html .= '<table>';
     1947                $group_html .= '<tbody>';
     1948
     1949                foreach ( (array) $group_item_data as $group_item_datum ) {
     1950                        $group_html .= '<tr>';
     1951                        $group_html .= '<th>' . esc_html( $group_item_datum['name'] ) . '</th>';
     1952                        // TODO entities!
     1953                        $group_html .= '<td>' . esc_html( $group_item_datum['value'] ) . '</td>';
     1954                        // TODO support attachment links
     1955                        $group_html .= '</tr>';
     1956                }
     1957
     1958                $group_html .= '</tbody>';
     1959                $group_html .= '</table>';
     1960        }
     1961
     1962        $group_html .= '</div>';
     1963
     1964        return $group_html;
     1965}
     1966
     1967// TODO phpDocs
     1968function wp_privacy_generate_personal_data_export( $exports_dir, $email_address, $personal_data ) {
     1969
     1970        // TODO Instead of HTML, return a ZIP containing the HTML and the attachments
     1971        $timestamp = current_time( 'timestamp' );
     1972        $index_filename = 'wp-personal-data-export-' . md5( $email_address ) . '-' . md5( $timestamp ) . '.html';
     1973
     1974        $index_path = trailingslashit( $exports_dir ) . $index_filename;
     1975        $file = fopen( $index_path, 'w' );
     1976        // TODO catch fopen error
     1977
     1978        $title = sprintf(
     1979                __( 'Personal Data Export for %s' ),
     1980                $email_address
     1981        );
     1982
     1983        // Open HTML
     1984        fwrite( $file, "<!DOCTYPE html>\n" );
     1985        fwrite( $file, "<html>\n" );
     1986
     1987        // Head
     1988        fwrite( $file, "<head>\n" );
     1989        fwrite( $file, "<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />\n" );
     1990        fwrite( $file, "<style type='text/css'>" );
     1991        fwrite( $file, "body { color: black; font-family: Arial, sans-serif; font-size: 11pt; margin: 15px auto; width: 860px; }" );
     1992        fwrite( $file, "table { background: #f0f0f0; border: 1px solid #ddd; margin-bottom: 20px; width: 100%; }" );
     1993        fwrite( $file, "th { padding: 5px; text-align: left; width: 20%; }" );
     1994        fwrite( $file, "td { padding: 5px; }" );
     1995        fwrite( $file, "tr:nth-child(odd) { background-color: #fafafa; }" );
     1996        fwrite( $file, "</style>" );
     1997        fwrite( $file, "<title>" );
     1998        fwrite( $file, esc_html( $title ) );
     1999        fwrite( $file, "</title>" );
     2000        fwrite( $file, "</head>\n" );
     2001
     2002        // Body
     2003        fwrite( $file, "<body>\n" );
     2004
     2005        // Heading
     2006        fwrite( $file, "<h1>" . esc_html__( 'Personal Data Export' ) . "</h1>" );
     2007
     2008        // And now, all the Groups
     2009        $groups = array();
     2010
     2011        // First, build a "About" group on the fly for this report
     2012        $groups[] = array(
     2013                'group_id'    => 'about',
     2014                'group_label' => __( 'About' ),
     2015                'items'       => array(
     2016                        'about-1' => array(
     2017                                array(
     2018                                        'name'  => __( 'Report generated for' ),
     2019                                        'value' => $email_address,
     2020                                ),
     2021                                array(
     2022                                        'name'  => __( 'For site' ),
     2023                                        'value' => get_bloginfo( 'name' ),
     2024                                ),
     2025                                array(
     2026                                        'name'  => __( 'At URL' ),
     2027                                        'value' => get_bloginfo( 'url' ),
     2028                                ),
     2029                                array(
     2030                                        'name'  => __( 'On' ),
     2031                                        'value' => current_time( 'mysql' ),
     2032                                ),
     2033                        ),
     2034                )
     2035        );
     2036
     2037        // Next, iterate over every item in $personal_data
     2038        // Extract all the unique group_ids, group_labels and item_ids
     2039        // Initialize/append the data for each item under its item_id
     2040        foreach ( (array) $personal_data as $personal_datum ) {
     2041                $group_id    = $personal_datum['group_id'];
     2042                $group_label = $personal_datum['group_label'];
     2043                if ( ! array_key_exists( $group_id, $groups ) ) {
     2044                        $groups[] = array(
     2045                                'group_id'    => $group_id,
     2046                                'group_label' => $group_label,
     2047                                'items'       => array(),
     2048                        );
     2049                }
     2050
     2051                $item_id = $personal_datum['item_id'];
     2052                if ( ! array_key_exists( $item_id, $groups[ $group_id ]['items'] ) ) {
     2053                        $groups[ $group_id ]['items'][ $item_id ] = array();
     2054                }
     2055
     2056                $old_item_data = $groups[ $group_id ]['items'][ $item_id ];
     2057                $merged_item_data = array_merge( $personal_datum['data'], $old_item_data );
     2058                $groups[ $group_id ]['items'][ $item_id ] = $merged_item_data;
     2059        }
     2060
     2061        // Now, iterate over every group in $groups and have the formatter render it in HTML
     2062        foreach ( (array) $groups as $group ) {
     2063                fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group ) );
     2064        }
     2065
     2066        fwrite( $file, "</body>\n" );
     2067
     2068        // Close HTML
     2069        fwrite( $file, "</html>\n" );
     2070        fclose( $file );
     2071
     2072        // Now, generate the ZIP
     2073        $archive_filename = 'wp-personal-data-export-' . md5( $email_address ) . '-' . md5( $timestamp ) . '.zip';
     2074        $archive_path = trailingslashit( $exports_dir ) . $archive_filename;
     2075
     2076        $zip = new ZipArchive;
     2077        // TODO test for no ZipArchive to work with
     2078
     2079        if ( TRUE === $zip->open( $archive_path, ZipArchive::CREATE ) ) {
     2080                $zip->addFile( $index_path, 'index.html' );
     2081                // TODO - add things referenced in wp-content/uploads
     2082                $zip->close();
     2083        } else {
     2084                error_log( "unable to open zip for creation" );
     2085                // TODO handle error here
     2086        }
     2087
     2088        // And remove the HTML file
     2089        unlink( $index_path );
     2090
     2091        return(
     2092                array(
     2093                        'timestamp' => $timestamp,
     2094                        'filename' => $archive_filename,
     2095                )
     2096        );
     2097}
     2098
     2099// TODO phpDocs
     2100function wp_privacy_process_personal_data_export_page( $response, $exporter_index, $email_address, $page ) {
     2101
     2102        // Housekeeping
     2103        $upload_dir  = wp_upload_dir();
     2104        $exports_dir = $upload_dir['basedir'] . '/exports';
     2105        $exports_url = $upload_dir['baseurl'] . '/exports';
     2106
     2107        // Create the exports folder if needed
     2108        $result = wp_mkdir_p( $exports_dir );
     2109        if ( is_wp_error( $result ) ) {
     2110                return $result;
     2111        }
     2112
     2113        // TODO Make sure the exports folder is protected (htaccess, index)
     2114
     2115        // Generate a export file option key from the email address
     2116        $export_file_option = '_wp_privacy_export_file_' . md5( $email_address );
     2117
     2118        // See if we have an export URL cached for this user already
     2119        $export_file_details = get_site_option( $export_file_option, '', false );
     2120
     2121        // And if we do, short circuit the export and send the path back instead
     2122        if ( ! empty( $export_file_details ) ) {
     2123                $pieces = explode( ':', $export_file_details ); // path:timestamp
     2124                $export_file_path = trailingslashit( $exports_dir ) . $pieces[0];
     2125
     2126                if ( file_exists( $export_file_path ) ) {
     2127                        return(
     2128                                array(
     2129                                        'data' => array(),
     2130                                        'done' => true,
     2131                                        'url'  => $pieces[0],
     2132                                )
     2133                        );
     2134                }
     2135
     2136                // No file? Delete the option and continue building the export
     2137                delete_site_option( $export_file_option );
     2138        }
     2139
     2140        // Generate a (temporary) data storage option key from the email address
     2141        $export_data_option = '_wp_privacy_export_data_' . md5( $email_address );
     2142
     2143        // First page of first exporter? Prepare/clear the option where we will assemble all the responses
     2144        if ( 1 === $exporter_index && 1 === $page ) {
     2145                update_site_option( $export_data_option, array() );
     2146        }
     2147
     2148        // Grab whatever data has already been collected on previous exporters and/or pages
     2149        $export_data = get_site_option( $export_data_option, array() );
     2150
     2151        // Does the response include data? If so, add this response's data to all the data we have received so far
     2152        // TODO - enforce the shape?
     2153        // TODO - enforce data as a numeric (not associative) array
     2154        $exporter_page_data = $response['data'];
     2155        if ( ! empty( $exporter_page_data ) && is_array( $exporter_page_data ) ) {
     2156                $export_data = array_merge( $export_data, $exporter_page_data );
     2157                update_site_option( $export_data_option, $export_data );
     2158        }
     2159
     2160        // Are we on the last exporter, and did that export say it was done? If not, return now
     2161        // so we can continue to collect data for export
     2162        $exporters = apply_filters( 'wp_privacy_personal_data_exporters', array() );
     2163        $is_last_exporter = $exporter_index === count( $exporters );
     2164        $exporter_done = $response['done'];
     2165        if ( ! $is_last_exporter || ! $exporter_done ) {
     2166                return $response;
     2167        }
     2168
     2169        // TODO de-repetitive-ize the data
     2170
     2171        // Generate the export
     2172        $result = wp_privacy_generate_personal_data_export( $exports_dir, $email_address, $export_data );
     2173        if ( is_wp_error( $result ) ) {
     2174                return $result;
     2175        }
     2176
     2177        // Get the timestamp and filename from the export
     2178        $timestamp = $result['timestamp'];
     2179        $filename = $result['filename'];
     2180
     2181        // Build the URL for the file
     2182        $export_url = trailingslashit( $exports_url ) . $filename;
     2183
     2184        // Modify the response to include the URL of the export file
     2185        $response['url'] = $export_url;
     2186
     2187        // TODO Save the export file in an option for safekeeping
     2188
     2189        return $response;
     2190}
     2191
     2192function wp_privacy_delete_old_export_files() {
     2193
     2194        // TODO delete old export files and their options
     2195
     2196}
  • 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',
     
    13421345         * @return string
    13431346         */
    13441347        public function column_email( $item ) {
     1348                $exporters       = apply_filters( 'wp_privacy_personal_data_exporters', array() );
     1349                $exporters_count = count( $exporters );
     1350                $request_id      = $item['request_id'];
     1351                $nonce           = wp_create_nonce( 'wp-privacy-export-personal-data-' . $request_id );
     1352
     1353                $download_data_markup = '<div class="download_personal_data" data-exporters-count="' . esc_attr( $exporters_count ) . '" data-request-id="' . esc_attr( $request_id ) . '" data-nonce="' . esc_attr( $nonce ) . '">' .
     1354                        '<span class="download_personal_data_idle"><a href="#" >' . __( 'Download Personal Data' ) . '</a></span>' .
     1355                        '<span style="display:none" class="download_personal_data_processing" >' . __( 'Downloading Data...' ) . '</span>' .
     1356                        '<span style="display:none" class="download_personal_data_failed">' . __( 'Download Failed!' ) . ' <a href="#" >' . __( 'Retry' ) . '</a></span>';
     1357
    13451358                $row_actions = array(
    1346                         'download_data' => __( 'Download Personal Data' ),
     1359                        'download_data' => $download_data_markup,
    13471360                );
    13481361
    13491362                return sprintf( '%1$s %2$s', $item['email'], $this->row_actions( $row_actions ) );
  • src/wp-admin/js/xfn.js

     
    2020                $( '#link_rel' ).val( ( isMe ) ? 'me' : inputs.substr( 0,inputs.length - 1 ) );
    2121        });
    2222});
     23
     24// Privacy request action handling
     25
     26jQuery( document ).ready( function( $ ) {
     27        function set_action_state( $action, state ) {
     28                $action.children().hide();
     29                $action.children( '.' + state ).show();
     30        }
     31
     32        $( '.download_personal_data a' ).click( function( event ) {
     33                event.stopPropagation();
     34
     35                var $this          = $( this );
     36                var $action        = $this.parents( '.download_personal_data' );
     37                var requestID      = $action.data( 'request-id' );
     38                var nonce          = $action.data( 'nonce' );
     39                var exportersCount = $action.data( 'exporters-count' );
     40
     41                $action.blur();
     42
     43                function on_export_done_success( url ) {
     44                        set_action_state( $action, 'download_personal_data_idle' );
     45                        // TODO - simplify once 43551 has landed - we won't need to test for a url
     46                        // nor show the successMessage then - we can just kick off the ZIP download
     47                        if ( url ) {
     48                                window.location = url; // kick off ZIP download
     49                        } else {
     50                                alert( 'Success!' ); // TODO DO NOT COMMIT
     51                        }
     52                }
     53
     54                function on_export_failure( textStatus, error ) {
     55                        set_action_state( $action, 'download_personal_data_failed' );
     56                        // TODO show a div with the error message
     57                        alert( 'Failure!' ); // TODO DO NOT COMMIT
     58                }
     59
     60                function do_next_export( exporterIndex, pageIndex ) {
     61                        $.ajax( {
     62                                url: ajaxurl,
     63                                data: {
     64                                        action: 'wp-privacy-export-personal-data',
     65                                        exporter: exporterIndex,
     66                                        id: requestID,
     67                                        page: pageIndex,
     68                                        security: nonce,
     69                                },
     70                                method: 'post'
     71                        } ).done( function( response ) {
     72                                if ( ! response.success ) {
     73                                        on_export_failure( 'error', responseData.data );
     74                                        return;
     75                                }
     76                                var responseData = response.data;
     77                                if ( ! responseData.done ) {
     78                                        setTimeout( do_next_export( exporterIndex, pageIndex + 1 ) );
     79                                } else {
     80                                        if ( exporterIndex < exportersCount ) {
     81                                                setTimeout( do_next_export( exporterIndex + 1, 1 ) );
     82                                        } else {
     83                                                console.log( responseData );
     84                                                on_export_done_success( responseData.url );
     85                                        }
     86                                }
     87                        } ).fail( function( jqxhr, textStatus, error ) {
     88                                on_export_failure( textStatus, error );
     89                        } );
     90                }
     91
     92                // And now, let's begin
     93                set_action_state( $action, 'download_personal_data_processing' );
     94
     95                do_next_export( 1, 1 );
     96        } )
     97} );