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