Make WordPress Core


Ignore:
Timestamp:
04/27/2018 07:53:37 PM (7 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.