Make WordPress Core


Ignore:
Timestamp:
05/02/2018 02:15:05 AM (6 years ago)
Author:
SergeyBiryukov
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.
Merges [43012] and [43089] to the 4.9 branch.
See #43546.

Location:
branches/4.9
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • branches/4.9

  • branches/4.9/src/wp-admin/includes/file.php

    r42811 r43092  
    17981798    <?php
    17991799}
     1800
     1801/**
     1802 * Generate a single group for the personal data export report.
     1803 *
     1804 * @since 4.9.6
     1805 *
     1806 * @param array  $group_data {
     1807 *     The group data to render.
     1808 *
     1809 *     @type string $group_label  The user-facing heading for the group, e.g. 'Comments'.
     1810 *     @type array  $items        {
     1811 *         An array of group items.
     1812 *
     1813 *         @type array  $group_item_data  {
     1814 *             An array of name-value pairs for the item.
     1815 *
     1816 *             @type string $name   The user-facing name of an item name-value pair, e.g. 'IP Address'.
     1817 *             @type string $value  The user-facing value of an item data pair, e.g. '50.60.70.0'.
     1818 *         }
     1819 *     }
     1820 * }
     1821 * @return string The HTML for this group and its items.
     1822 */
     1823function wp_privacy_generate_personal_data_export_group_html( $group_data ) {
     1824    $allowed_tags      = array(
     1825        'a' => array(
     1826            'href'   => array(),
     1827            'target' => array()
     1828        ),
     1829        'br' => array()
     1830    );
     1831    $allowed_protocols = array( 'http', 'https' );
     1832    $group_html        = '';
     1833
     1834    $group_html .= '<h2>' . esc_html( $group_data['group_label'] ) . '</h2>';
     1835    $group_html .= '<div>';
     1836
     1837    foreach ( (array) $group_data['items'] as $group_item_id => $group_item_data ) {
     1838        $group_html .= '<table>';
     1839        $group_html .= '<tbody>';
     1840
     1841        foreach ( (array) $group_item_data as $group_item_datum ) {
     1842            $group_html .= '<tr>';
     1843            $group_html .= '<th>' . esc_html( $group_item_datum['name'] ) . '</th>';
     1844            $group_html .= '<td>' . wp_kses( $group_item_datum['value'], $allowed_tags, $allowed_protocols ) . '</td>';
     1845            $group_html .= '</tr>';
     1846        }
     1847
     1848        $group_html .= '</tbody>';
     1849        $group_html .= '</table>';
     1850    }
     1851
     1852    $group_html .= '</div>';
     1853
     1854    return $group_html;
     1855}
     1856
     1857/**
     1858 * Generate the personal data export file.
     1859 *
     1860 * @since 4.9.6
     1861 *
     1862 * @param int  $request_id  The export request ID.
     1863 */
     1864function wp_privacy_generate_personal_data_export_file( $request_id ) {
     1865    // Maybe make this a cron job instead.
     1866    wp_privacy_delete_old_export_files();
     1867
     1868    if ( ! class_exists( 'ZipArchive' ) ) {
     1869        wp_send_json_error( __( 'Unable to generate export file. ZipArchive not available.' ) );
     1870    }
     1871
     1872    // Get the request data.
     1873    $request = wp_get_user_request_data( $request_id );
     1874
     1875    if ( ! $request || 'export_personal_data' !== $request->action_name ) {
     1876        wp_send_json_error( __( 'Invalid request ID when generating export file' ) );
     1877    }
     1878
     1879    $email_address = $request->email;
     1880
     1881    if ( ! is_email( $email_address ) ) {
     1882        wp_send_json_error( __( 'Invalid email address when generating export file' ) );
     1883    }
     1884
     1885    // Create the exports folder if needed.
     1886    $upload_dir  = wp_upload_dir();
     1887    $exports_dir = trailingslashit( $upload_dir['basedir'] . '/exports' );
     1888    $exports_url = trailingslashit( $upload_dir['baseurl'] . '/exports' );
     1889
     1890    $result = wp_mkdir_p( $exports_dir );
     1891    if ( is_wp_error( $result ) ) {
     1892        wp_send_json_error( $result->get_error_message() );
     1893    }
     1894
     1895    // Protect export folder from browsing.
     1896    $index_pathname = $exports_dir . 'index.html';
     1897    if ( ! file_exists( $index_pathname ) ) {
     1898        $file = fopen( $index_pathname, 'w' );
     1899        if ( false === $file ) {
     1900            wp_send_json_error( __( 'Unable to protect export folder from browsing' ) );
     1901        }
     1902        fwrite( $file, 'Silence is golden.' );
     1903        fclose( $file );
     1904    }
     1905
     1906    $stripped_email       = str_replace( '@', '-at-', $email_address );
     1907    $stripped_email       = sanitize_title( $stripped_email ); // slugify the email address
     1908    $obscura              = md5( rand() );
     1909    $file_basename        = 'wp-personal-data-file-' . $stripped_email . '-' . $obscura;
     1910    $html_report_filename = $file_basename . '.html';
     1911    $html_report_pathname = $exports_dir . $html_report_filename;
     1912    $file = fopen( $html_report_pathname, 'w' );
     1913    if ( false === $file ) {
     1914        wp_send_json_error( __( 'Unable to open export file (HTML report) for writing' ) );
     1915    }
     1916
     1917    $title = sprintf(
     1918        /* translators: %s: user's e-mail address */
     1919        __( 'Personal Data Export for %s' ),
     1920        $email_address
     1921    );
     1922
     1923    // Open HTML.
     1924    fwrite( $file, "<!DOCTYPE html>\n" );
     1925    fwrite( $file, "<html>\n" );
     1926
     1927    // Head.
     1928    fwrite( $file, "<head>\n" );
     1929    fwrite( $file, "<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />\n" );
     1930    fwrite( $file, "<style type='text/css'>" );
     1931    fwrite( $file, "body { color: black; font-family: Arial, sans-serif; font-size: 11pt; margin: 15px auto; width: 860px; }" );
     1932    fwrite( $file, "table { background: #f0f0f0; border: 1px solid #ddd; margin-bottom: 20px; width: 100%; }" );
     1933    fwrite( $file, "th { padding: 5px; text-align: left; width: 20%; }" );
     1934    fwrite( $file, "td { padding: 5px; }" );
     1935    fwrite( $file, "tr:nth-child(odd) { background-color: #fafafa; }" );
     1936    fwrite( $file, "</style>" );
     1937    fwrite( $file, "<title>" );
     1938    fwrite( $file, esc_html( $title ) );
     1939    fwrite( $file, "</title>" );
     1940    fwrite( $file, "</head>\n" );
     1941
     1942    // Body.
     1943    fwrite( $file, "<body>\n" );
     1944
     1945    // Heading.
     1946    fwrite( $file, "<h1>" . esc_html__( 'Personal Data Export' ) . "</h1>" );
     1947
     1948    // And now, all the Groups.
     1949    $groups = get_post_meta( $request_id, '_export_data_grouped', true );
     1950
     1951    // First, build an "About" group on the fly for this report.
     1952    $about_group = array(
     1953        'group_label' => __( 'About' ),
     1954        'items'       => array(
     1955            'about-1' => array(
     1956                array(
     1957                    'name'  => __( 'Report generated for' ),
     1958                    'value' => $email_address,
     1959                ),
     1960                array(
     1961                    'name'  => __( 'For site' ),
     1962                    'value' => get_bloginfo( 'name' ),
     1963                ),
     1964                array(
     1965                    'name'  => __( 'At URL' ),
     1966                    'value' => get_bloginfo( 'url' ),
     1967                ),
     1968                array(
     1969                    'name'  => __( 'On' ),
     1970                    'value' => current_time( 'mysql' ),
     1971                ),
     1972            ),
     1973        ),
     1974    );
     1975
     1976    // Merge in the special about group.
     1977    $groups = array_merge( array( 'about' => $about_group ), $groups );
     1978
     1979    // Now, iterate over every group in $groups and have the formatter render it in HTML.
     1980    foreach ( (array) $groups as $group_id => $group_data ) {
     1981        fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group_data ) );
     1982    }
     1983
     1984    fwrite( $file, "</body>\n" );
     1985
     1986    // Close HTML.
     1987    fwrite( $file, "</html>\n" );
     1988    fclose( $file );
     1989
     1990    // Now, generate the ZIP.
     1991    $archive_filename = $file_basename . '.zip';
     1992    $archive_pathname = $exports_dir . $archive_filename;
     1993    $archive_url      = $exports_url . $archive_filename;
     1994
     1995    $zip = new ZipArchive;
     1996
     1997    if ( TRUE === $zip->open( $archive_pathname, ZipArchive::CREATE ) ) {
     1998        $zip->addFile( $html_report_pathname, 'index.html' );
     1999        $zip->close();
     2000    } else {
     2001        wp_send_json_error( __( 'Unable to open export file (archive) for writing' ) );
     2002    }
     2003
     2004    // And remove the HTML file.
     2005    unlink( $html_report_pathname );
     2006
     2007    // Save the export file in the request.
     2008    update_post_meta( $request_id, '_export_file_url', $archive_url );
     2009    update_post_meta( $request_id, '_export_file_path', $archive_pathname );
     2010}
     2011
     2012/**
     2013 * Send an email to the user with a link to the personal data export file
     2014 *
     2015 * @since 4.9.6
     2016 *
     2017 * @param int  $request_id  The request ID for this personal data export.
     2018 * @return true|WP_Error    True on success or `WP_Error` on failure.
     2019 */
     2020function wp_privacy_send_personal_data_export_email( $request_id ) {
     2021    // Get the request data.
     2022    $request = wp_get_user_request_data( $request_id );
     2023
     2024    if ( ! $request || 'export_personal_data' !== $request->action_name ) {
     2025        return new WP_Error( 'invalid', __( 'Invalid request ID when sending personal data export email.' ) );
     2026    }
     2027
     2028/* translators: Do not translate LINK, EMAIL, SITENAME, SITEURL: those are placeholders. */
     2029$email_text = __(
     2030'Howdy,
     2031
     2032Your request for an export of personal data has been completed. You may
     2033download your personal data by clicking on the link below. This link is
     2034good for the next 3 days.
     2035
     2036###LINK###
     2037
     2038This email has been sent to ###EMAIL###.
     2039
     2040Regards,
     2041All at ###SITENAME###
     2042###SITEURL###'
     2043);
     2044
     2045    /**
     2046     * Filters the text of the email sent with a personal data export file.
     2047     *
     2048     * The following strings have a special meaning and will get replaced dynamically:
     2049     * ###LINK###               URL of the personal data export file for the user.
     2050     * ###EMAIL###              The email we are sending to.
     2051     * ###SITENAME###           The name of the site.
     2052     * ###SITEURL###            The URL to the site.
     2053     *
     2054     * @since 4.9.6
     2055     *
     2056     * @param string $email_text     Text in the email.
     2057     * @param int    $request_id     The request ID for this personal data export.
     2058     */
     2059    $content = apply_filters( 'wp_privacy_personal_data_email_content', $email_text, $request_id );
     2060
     2061    $email_address = $request->email;
     2062    $export_file_url = get_post_meta( $request_id, '_export_file_url', true );
     2063    $site_name = is_multisite() ? get_site_option( 'site_name' ) : get_option( 'blogname' );
     2064    $site_url = network_home_url();
     2065
     2066    $content = str_replace( '###LINK###', esc_url_raw( $export_file_url ), $content );
     2067    $content = str_replace( '###EMAIL###', $email_address, $content );
     2068    $content = str_replace( '###SITENAME###', wp_specialchars_decode( $site_name, ENT_QUOTES ), $content );
     2069    $content = str_replace( '###SITEURL###', esc_url_raw( $site_url ), $content );
     2070
     2071    $mail_success = wp_mail(
     2072        $email_address,
     2073        sprintf(
     2074            __( '[%s] Personal Data Export' ),
     2075            wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES )
     2076        ),
     2077        $content
     2078    );
     2079
     2080    if ( ! $mail_success ) {
     2081        return new WP_Error( 'error', __( 'Unable to send personal data export email.' ) );
     2082    }
     2083
     2084    return true;
     2085}
     2086
     2087/**
     2088 * Intercept personal data exporter page ajax responses in order to assemble the personal data export file.
     2089 * @see wp_privacy_personal_data_export_page
     2090 * @since 4.9.6
     2091 *
     2092 * @param array  $response        The response from the personal data exporter for the given page.
     2093 * @param int    $exporter_index  The index of the personal data exporter. Begins at 1.
     2094 * @param string $email_address   The email address of the user whose personal data this is.
     2095 * @param int    $page            The page of personal data for this exporter. Begins at 1.
     2096 * @param int    $request_id      The request ID for this personal data export.
     2097 * @param bool   $send_as_email   Whether the final results of the export should be emailed to the user.
     2098 * @return array The filtered response.
     2099 */
     2100function wp_privacy_process_personal_data_export_page( $response, $exporter_index, $email_address, $page, $request_id, $send_as_email ) {
     2101    /* Do some simple checks on the shape of the response from the exporter.
     2102     * If the exporter response is malformed, don't attempt to consume it - let it
     2103     * pass through to generate a warning to the user by default ajax processing.
     2104     */
     2105    if ( ! is_array( $response ) ) {
     2106        return $response;
     2107    }
     2108
     2109    if ( ! array_key_exists( 'done', $response ) ) {
     2110        return $response;
     2111    }
     2112
     2113    if ( ! array_key_exists( 'data', $response ) ) {
     2114        return $response;
     2115    }
     2116
     2117    if ( ! is_array( $response['data'] ) ) {
     2118        return $response;
     2119    }
     2120
     2121    // Get the request data.
     2122    $request = wp_get_user_request_data( $request_id );
     2123
     2124    if ( ! $request || 'export_personal_data' !== $request->action_name ) {
     2125        wp_send_json_error( __( 'Invalid request ID when merging exporter data' ) );
     2126    }
     2127
     2128    $export_data = array();
     2129
     2130    // First exporter, first page? Reset the report data accumulation array.
     2131    if ( 1 === $exporter_index && 1 === $page ) {
     2132        update_post_meta( $request_id, '_export_data_raw', $export_data );
     2133    } else {
     2134        $export_data = get_post_meta( $request_id, '_export_data_raw', true );
     2135    }
     2136
     2137    // Now, merge the data from the exporter response into the data we have accumulated already.
     2138    $export_data = array_merge( $export_data, $response['data'] );
     2139    update_post_meta( $request_id, '_export_data_raw', $export_data );
     2140
     2141    // If we are not yet on the last page of the last exporter, return now.
     2142    $exporters = apply_filters( 'wp_privacy_personal_data_exporters', array() );
     2143    $is_last_exporter = $exporter_index === count( $exporters );
     2144    $exporter_done = $response['done'];
     2145    if ( ! $is_last_exporter || ! $exporter_done ) {
     2146        return $response;
     2147    }
     2148
     2149    // Last exporter, last page - let's prepare the export file.
     2150
     2151    // First we need to re-organize the raw data hierarchically in groups and items.
     2152    $groups = array();
     2153    foreach ( (array) $export_data as $export_datum ) {
     2154        $group_id    = $export_datum['group_id'];
     2155        $group_label = $export_datum['group_label'];
     2156        if ( ! array_key_exists( $group_id, $groups ) ) {
     2157            $groups[ $group_id ] = array(
     2158                'group_label' => $group_label,
     2159                'items'       => array(),
     2160            );
     2161        }
     2162
     2163        $item_id = $export_datum['item_id'];
     2164        if ( ! array_key_exists( $item_id, $groups[ $group_id ]['items'] ) ) {
     2165            $groups[ $group_id ]['items'][ $item_id ] = array();
     2166        }
     2167
     2168        $old_item_data = $groups[ $group_id ]['items'][ $item_id ];
     2169        $merged_item_data = array_merge( $export_datum['data'], $old_item_data );
     2170        $groups[ $group_id ]['items'][ $item_id ] = $merged_item_data;
     2171    }
     2172
     2173    // Then save the grouped data into the request.
     2174    delete_post_meta( $request_id, '_export_data_raw' );
     2175    update_post_meta( $request_id, '_export_data_grouped', $groups );
     2176
     2177    // And now, generate the export file, cleaning up any previous file
     2178    $export_path = get_post_meta( $request_id, '_export_file_path', true );
     2179    if ( ! empty( $export_path ) ) {
     2180        delete_post_meta( $request_id, '_export_file_path' );
     2181        @unlink( $export_path );
     2182    }
     2183    delete_post_meta( $request_id, '_export_file_url' );
     2184
     2185    // Generate the export file from the collected, grouped personal data.
     2186    do_action( 'wp_privacy_personal_data_export_file', $request_id );
     2187
     2188    // Clear the grouped data now that it is no longer needed.
     2189    delete_post_meta( $request_id, '_export_data_grouped' );
     2190
     2191    // If the destination is email, send it now.
     2192    if ( $send_as_email ) {
     2193        $mail_success = wp_privacy_send_personal_data_export_email( $request_id );
     2194        if ( is_wp_error( $mail_success ) ) {
     2195            wp_send_json_error( $mail_success->get_error_message() );
     2196        }
     2197    } else {
     2198        // Modify the response to include the URL of the export file so the browser can fetch it.
     2199        $export_file_url = get_post_meta( $request_id, '_export_file_url', true );
     2200        if ( ! empty( $export_file_url ) ) {
     2201            $response['url'] = $export_file_url;
     2202        }
     2203    }
     2204
     2205    // Update the request to completed state.
     2206    _wp_privacy_completed_request( $request_id );
     2207
     2208    return $response;
     2209}
     2210
     2211/**
     2212 * Cleans up export files older than three days old.
     2213 *
     2214 * @since 4.9.6
     2215 */
     2216function wp_privacy_delete_old_export_files() {
     2217    $upload_dir   = wp_upload_dir();
     2218    $exports_dir  = trailingslashit( $upload_dir['basedir'] . '/exports' );
     2219    $export_files = list_files( $exports_dir );
     2220
     2221    foreach( (array) $export_files as $export_file ) {
     2222        $file_age_in_seconds = time() - filemtime( $export_file );
     2223
     2224        if ( 3 * DAY_IN_SECONDS < $file_age_in_seconds ) {
     2225            @unlink( $export_file );
     2226        }
     2227    }
     2228}
Note: See TracChangeset for help on using the changeset viewer.