Make WordPress Core


Ignore:
Timestamp:
04/27/2018 07:53:37 PM (8 years ago)
Author:
azaozz
Message:

Privacy: add means to export personal data by username or email address. Generate a zipped export file containing all data. First run.

Props allendav.
See #43546.

File:
1 edited

Legend:

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

    r42983 r43012  
    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 {
     1944 *     The group data to render.
     1945 *
     1946 *     @type string $group_label  The user-facing heading for the group, e.g. 'Comments'.
     1947 *     @type array  $items        {
     1948 *         An array of group items.
     1949 *
     1950 *         @type array  $group_item_data  {
     1951 *             An array of name-value pairs for the item.
     1952 *
     1953 *             @type string $name   The user-facing name of an item name-value pair, e.g. 'IP Address'.
     1954 *             @type string $value  The user-facing value of an item data pair, e.g. '50.60.70.0'.
     1955 *         }
     1956 *     }
     1957 * }
     1958 * @return string The HTML for this group and its items.
     1959 */
     1960function wp_privacy_generate_personal_data_export_group_html( $group_data ) {
     1961        $allowed_tags      = array(
     1962                'a' => array(
     1963                        'href'   => array(),
     1964                        'target' => array()
     1965                ),
     1966                'br' => array()
     1967        );
     1968        $allowed_protocols = array( 'http', 'https' );
     1969        $group_html        = '';
     1970
     1971        $group_html .= '<h2>' . esc_html( $group_data['group_label'] ) . '</h2>';
     1972        $group_html .= '<div>';
     1973
     1974        foreach ( (array) $group_data['items'] as $group_item_id => $group_item_data ) {
     1975                $group_html .= '<table>';
     1976                $group_html .= '<tbody>';
     1977
     1978                foreach ( (array) $group_item_data as $group_item_datum ) {
     1979                        $group_html .= '<tr>';
     1980                        $group_html .= '<th>' . esc_html( $group_item_datum['name'] ) . '</th>';
     1981                        $group_html .= '<td>' . wp_kses( $group_item_datum['value'], $allowed_tags, $allowed_protocols ) . '</td>';
     1982                        $group_html .= '</tr>';
     1983                }
     1984
     1985                $group_html .= '</tbody>';
     1986                $group_html .= '</table>';
     1987        }
     1988
     1989        $group_html .= '</div>';
     1990
     1991        return $group_html;
     1992}
     1993
     1994/**
     1995 * Generate the personal data export file.
     1996 *
     1997 * @since 4.9.6
     1998 *
     1999 * @param int  $request_id  The export request ID.
     2000 */
     2001function wp_privacy_generate_personal_data_export_file( $request_id ) {
     2002        // Maybe make this a cron job instead.
     2003        wp_privacy_delete_old_export_files();
     2004
     2005        if ( ! class_exists( 'ZipArchive' ) ) {
     2006                wp_send_json_error( __( 'Unable to generate export file. ZipArchive not available.' ) );
     2007        }
     2008
     2009        // Get the request data.
     2010        $request = wp_get_user_request_data( $request_id );
     2011
     2012        if ( ! $request || 'export_personal_data' !== $request->action_name ) {
     2013                wp_send_json_error( __( 'Invalid request ID when generating export file' ) );
     2014        }
     2015
     2016        $email_address = $request->email;
     2017
     2018        if ( ! is_email( $email_address ) ) {
     2019                wp_send_json_error( __( 'Invalid email address when generating export file' ) );
     2020        }
     2021
     2022        // Create the exports folder if needed.
     2023        $upload_dir  = wp_upload_dir();
     2024        $exports_dir = trailingslashit( $upload_dir['basedir'] . '/exports' );
     2025        $exports_url = trailingslashit( $upload_dir['baseurl'] . '/exports' );
     2026
     2027        $result = wp_mkdir_p( $exports_dir );
     2028        if ( is_wp_error( $result ) ) {
     2029                wp_send_json_error( $result->get_error_message() );
     2030        }
     2031
     2032        // Protect export folder from browsing.
     2033        $index_pathname = $exports_dir . 'index.html';
     2034        if ( ! file_exists( $index_pathname ) ) {
     2035                $file = fopen( $index_pathname, 'w' );
     2036                if ( false === $file ) {
     2037                        wp_send_json_error( __( 'Unable to protect export folder from browsing' ) );
     2038                }
     2039                fwrite( $file, 'Silence is golden.' );
     2040                fclose( $file );
     2041        }
     2042
     2043        $stripped_email       = str_replace( '@', '-at-', $email_address );
     2044        $stripped_email       = sanitize_title( $stripped_email ); // slugify the email address
     2045        $obscura              = md5( rand() );
     2046        $file_basename        = 'wp-personal-data-file-' . $stripped_email . '-' . $obscura;
     2047        $html_report_filename = $file_basename . '.html';
     2048        $html_report_pathname = $exports_dir . $html_report_filename;
     2049        $file = fopen( $html_report_pathname, 'w' );
     2050        if ( false === $file ) {
     2051                wp_send_json_error( __( 'Unable to open export file (HTML report) for writing' ) );
     2052        }
     2053
     2054        $title = sprintf(
     2055                // translators: %s Users e-mail address.
     2056                __( 'Personal Data Export for %s' ),
     2057                $email_address
     2058        );
     2059
     2060        // Open HTML.
     2061        fwrite( $file, "<!DOCTYPE html>\n" );
     2062        fwrite( $file, "<html>\n" );
     2063
     2064        // Head.
     2065        fwrite( $file, "<head>\n" );
     2066        fwrite( $file, "<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />\n" );
     2067        fwrite( $file, "<style type='text/css'>" );
     2068        fwrite( $file, "body { color: black; font-family: Arial, sans-serif; font-size: 11pt; margin: 15px auto; width: 860px; }" );
     2069        fwrite( $file, "table { background: #f0f0f0; border: 1px solid #ddd; margin-bottom: 20px; width: 100%; }" );
     2070        fwrite( $file, "th { padding: 5px; text-align: left; width: 20%; }" );
     2071        fwrite( $file, "td { padding: 5px; }" );
     2072        fwrite( $file, "tr:nth-child(odd) { background-color: #fafafa; }" );
     2073        fwrite( $file, "</style>" );
     2074        fwrite( $file, "<title>" );
     2075        fwrite( $file, esc_html( $title ) );
     2076        fwrite( $file, "</title>" );
     2077        fwrite( $file, "</head>\n" );
     2078
     2079        // Body.
     2080        fwrite( $file, "<body>\n" );
     2081
     2082        // Heading.
     2083        fwrite( $file, "<h1>" . esc_html__( 'Personal Data Export' ) . "</h1>" );
     2084
     2085        // And now, all the Groups.
     2086        $groups = get_post_meta( $request_id, '_export_data_grouped', true );
     2087
     2088        // First, build an "About" group on the fly for this report.
     2089        $about_group = array(
     2090                'group_label' => __( 'About' ),
     2091                'items'       => array(
     2092                        'about-1' => array(
     2093                                array(
     2094                                        'name'  => __( 'Report generated for' ),
     2095                                        'value' => $email_address,
     2096                                ),
     2097                                array(
     2098                                        'name'  => __( 'For site' ),
     2099                                        'value' => get_bloginfo( 'name' ),
     2100                                ),
     2101                                array(
     2102                                        'name'  => __( 'At URL' ),
     2103                                        'value' => get_bloginfo( 'url' ),
     2104                                ),
     2105                                array(
     2106                                        'name'  => __( 'On' ),
     2107                                        'value' => current_time( 'mysql' ),
     2108                                ),
     2109                        ),
     2110                ),
     2111        );
     2112
     2113        // Merge in the special about group.
     2114        $groups = array_merge( array( 'about' => $about_group ), $groups );
     2115
     2116        // Now, iterate over every group in $groups and have the formatter render it in HTML.
     2117        foreach ( (array) $groups as $group_id => $group_data ) {
     2118                fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group_data ) );
     2119        }
     2120
     2121        fwrite( $file, "</body>\n" );
     2122
     2123        // Close HTML.
     2124        fwrite( $file, "</html>\n" );
     2125        fclose( $file );
     2126
     2127        // Now, generate the ZIP.
     2128        $archive_filename = $file_basename . '.zip';
     2129        $archive_pathname = $exports_dir . $archive_filename;
     2130        $archive_url      = $exports_url . $archive_filename;
     2131
     2132        $zip = new ZipArchive;
     2133
     2134        if ( TRUE === $zip->open( $archive_pathname, ZipArchive::CREATE ) ) {
     2135                $zip->addFile( $html_report_pathname, 'index.html' );
     2136                $zip->close();
     2137        } else {
     2138                wp_send_json_error( __( 'Unable to open export file (archive) for writing' ) );
     2139        }
     2140
     2141        // And remove the HTML file.
     2142        unlink( $html_report_pathname );
     2143
     2144        // Save the export file in the request.
     2145        update_post_meta( $request_id, '_export_file_url', $archive_url );
     2146        update_post_meta( $request_id, '_export_file_path', $archive_pathname );
     2147}
     2148
     2149/**
     2150 * Send an email to the user with a link to the personal data export file
     2151 *
     2152 * @since 4.9.6
     2153 *
     2154 * @param int  $request_id  The request ID for this personal data export.
     2155 * @return true|WP_Error    True on success or `WP_Error` on failure.
     2156 */
     2157function wp_privacy_send_personal_data_export_email( $request_id ) {
     2158        // Get the request data.
     2159        $request = wp_get_user_request_data( $request_id );
     2160
     2161        if ( ! $request || 'export_personal_data' !== $request->action_name ) {
     2162                return new WP_Error( 'invalid', __( 'Invalid request ID when sending personal data export email.' ) );
     2163        }
     2164
     2165/* translators: Do not translate LINK, EMAIL, SITENAME, SITEURL: those are placeholders. */
     2166$email_text = __(
     2167'Howdy,
     2168
     2169Your request for an export of personal data has been completed. You may
     2170download your personal data by clicking on the link below. This link is
     2171good for the next 3 days.
     2172
     2173###LINK###
     2174
     2175This email has been sent to ###EMAIL###.
     2176
     2177Regards,
     2178All at ###SITENAME###
     2179###SITEURL###'
     2180);
     2181
     2182        /**
     2183         * Filters the text of the email sent with a personal data export file.
     2184         *
     2185         * The following strings have a special meaning and will get replaced dynamically:
     2186         * ###LINK###               URL of the personal data export file for the user.
     2187         * ###EMAIL###              The email we are sending to.
     2188         * ###SITENAME###           The name of the site.
     2189         * ###SITEURL###            The URL to the site.
     2190         *
     2191         * @since 4.9.6
     2192         *
     2193         * @param string $email_text     Text in the email.
     2194         * @param int    $request_id     The request ID for this personal data export.
     2195         */
     2196        $content = apply_filters( 'wp_privacy_personal_data_email_content', $email_text, $request_id );
     2197
     2198        $email_address = $request->email;
     2199        $export_file_url = get_post_meta( $request_id, '_export_file_url', true );
     2200        $site_name = is_multisite() ? get_site_option( 'site_name' ) : get_option( 'blogname' );
     2201        $site_url = network_home_url();
     2202
     2203        $content = str_replace( '###LINK###', esc_url_raw( $export_file_url ), $content );
     2204        $content = str_replace( '###EMAIL###', $email_address, $content );
     2205        $content = str_replace( '###SITENAME###', wp_specialchars_decode( $site_name, ENT_QUOTES ), $content );
     2206        $content = str_replace( '###SITEURL###', esc_url_raw( $site_url ), $content );
     2207
     2208        $mail_success = wp_mail(
     2209                $email_address,
     2210                sprintf(
     2211                        __( '[%s] Personal Data Export' ),
     2212                        wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES )
     2213                ),
     2214                $content
     2215        );
     2216
     2217        if ( ! $mail_success ) {
     2218                return new WP_Error( 'error', __( 'Unable to send personal data export email.' ) );
     2219        }
     2220
     2221        return true;
     2222}
     2223
     2224/**
     2225 * Intercept personal data exporter page ajax responses in order to assemble the personal data export file.
     2226 * @see wp_privacy_personal_data_export_page
     2227 * @since 4.9.6
     2228 *
     2229 * @param array  $response        The response from the personal data exporter for the given page.
     2230 * @param int    $exporter_index  The index of the personal data exporter. Begins at 1.
     2231 * @param string $email_address   The email address of the user whose personal data this is.
     2232 * @param int    $page            The page of personal data for this exporter. Begins at 1.
     2233 * @param int    $request_id      The request ID for this personal data export.
     2234 * @param bool   $send_as_email   Whether the final results of the export should be emailed to the user.
     2235 * @return array The filtered response.
     2236 */
     2237function wp_privacy_process_personal_data_export_page( $response, $exporter_index, $email_address, $page, $request_id, $send_as_email ) {
     2238        /* Do some simple checks on the shape of the response from the exporter.
     2239         * If the exporter response is malformed, don't attempt to consume it - let it
     2240         * pass through to generate a warning to the user by default ajax processing.
     2241         */
     2242        if ( ! is_array( $response ) ) {
     2243                return $response;
     2244        }
     2245
     2246        if ( ! array_key_exists( 'done', $response ) ) {
     2247                return $response;
     2248        }
     2249
     2250        if ( ! array_key_exists( 'data', $response ) ) {
     2251                return $response;
     2252        }
     2253
     2254        if ( ! is_array( $response['data'] ) ) {
     2255                return $response;
     2256        }
     2257
     2258        // Get the request data.
     2259        $request = wp_get_user_request_data( $request_id );
     2260
     2261        if ( ! $request || 'export_personal_data' !== $request->action_name ) {
     2262                wp_send_json_error( __( 'Invalid request ID when merging exporter data' ) );
     2263        }
     2264
     2265        $export_data = array();
     2266
     2267        // First exporter, first page? Reset the report data accumulation array.
     2268        if ( 1 === $exporter_index && 1 === $page ) {
     2269                update_post_meta( $request_id, '_export_data_raw', $export_data );
     2270        } else {
     2271                $export_data = get_post_meta( $request_id, '_export_data_raw', true );
     2272        }
     2273
     2274        // Now, merge the data from the exporter response into the data we have accumulated already.
     2275        $export_data = array_merge( $export_data, $response['data'] );
     2276        update_post_meta( $request_id, '_export_data_raw', $export_data );
     2277
     2278        // If we are not yet on the last page of the last exporter, return now.
     2279        $exporters = apply_filters( 'wp_privacy_personal_data_exporters', array() );
     2280        $is_last_exporter = $exporter_index === count( $exporters );
     2281        $exporter_done = $response['done'];
     2282        if ( ! $is_last_exporter || ! $exporter_done ) {
     2283                return $response;
     2284        }
     2285
     2286        // Last exporter, last page - let's prepare the export file.
     2287
     2288        // First we need to re-organize the raw data hierarchically in groups and items.
     2289        $groups = array();
     2290        foreach ( (array) $export_data as $export_datum ) {
     2291                $group_id    = $export_datum['group_id'];
     2292                $group_label = $export_datum['group_label'];
     2293                if ( ! array_key_exists( $group_id, $groups ) ) {
     2294                        $groups[ $group_id ] = array(
     2295                                'group_label' => $group_label,
     2296                                'items'       => array(),
     2297                        );
     2298                }
     2299
     2300                $item_id = $export_datum['item_id'];
     2301                if ( ! array_key_exists( $item_id, $groups[ $group_id ]['items'] ) ) {
     2302                        $groups[ $group_id ]['items'][ $item_id ] = array();
     2303                }
     2304
     2305                $old_item_data = $groups[ $group_id ]['items'][ $item_id ];
     2306                $merged_item_data = array_merge( $export_datum['data'], $old_item_data );
     2307                $groups[ $group_id ]['items'][ $item_id ] = $merged_item_data;
     2308        }
     2309
     2310        // Then save the grouped data into the request.
     2311        delete_post_meta( $request_id, '_export_data_raw' );
     2312        update_post_meta( $request_id, '_export_data_grouped', $groups );
     2313
     2314        // And now, generate the export file, cleaning up any previous file
     2315        $export_path = get_post_meta( $request_id, '_export_file_path', true );
     2316        if ( ! empty( $export_path ) ) {
     2317                delete_post_meta( $request_id, '_export_file_path' );
     2318                @unlink( $export_path );
     2319        }
     2320        delete_post_meta( $request_id, '_export_file_url' );
     2321
     2322        // Generate the export file from the collected, grouped personal data.
     2323        do_action( 'wp_privacy_personal_data_export_file', $request_id );
     2324
     2325        // Clear the grouped data now that it is no longer needed.
     2326        delete_post_meta( $request_id, '_export_data_grouped' );
     2327
     2328        // If the destination is email, send it now.
     2329        if ( $send_as_email ) {
     2330                $mail_success = wp_privacy_send_personal_data_export_email( $request_id );
     2331                if ( is_wp_error( $mail_success ) ) {
     2332                        wp_send_json_error( $mail_success->get_error_message() );
     2333                }
     2334        } else {
     2335                // Modify the response to include the URL of the export file so the browser can fetch it.
     2336                $export_file_url = get_post_meta( $request_id, '_export_file_url', true );
     2337                if ( ! empty( $export_file_url ) ) {
     2338                        $response['url'] = $export_file_url;
     2339                }
     2340        }
     2341
     2342        // Update the request to completed state.
     2343        _wp_privacy_completed_request( $request_id );
     2344
     2345        return $response;
     2346}
     2347
     2348/**
     2349 * Cleans up export files older than three days old.
     2350 *
     2351 * @since 4.9.6
     2352 */
     2353function wp_privacy_delete_old_export_files() {
     2354        $upload_dir   = wp_upload_dir();
     2355        $exports_dir  = trailingslashit( $upload_dir['basedir'] . '/exports' );
     2356        $export_files = list_files( $exports_dir );
     2357
     2358        foreach( (array) $export_files as $export_file ) {
     2359                $file_age_in_seconds = time() - filemtime( $export_file );
     2360
     2361                if ( 3 * DAY_IN_SECONDS < $file_age_in_seconds ) {
     2362                        @unlink( $export_file );
     2363                }
     2364        }
     2365}
Note: See TracChangeset for help on using the changeset viewer.