| 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 The group data to render. |
| 1944 | * 'group_label' string The user-facing heading for the group, e.g. 'Comments'. |
| 1945 | * 'items' associative-array An array of group items. |
| 1946 | * $group_item_id string A group item ID (e.g. 'comment-123' ). |
| 1947 | * => $group_item_data array An array of name-value pairs for the item. |
| 1948 | * 'name' string The user-facing name of an item name-value pair, e.g. 'IP Address'. |
| 1949 | * 'value' string The user-facing value of an item data pair, e.g. '50.60.70.0'. |
| 1950 | * @return string The HTML for this group and its items |
| 1951 | */ |
| 1952 | function wp_privacy_generate_personal_data_export_group_html( $group_data ) { |
| 1953 | $allowed_tags = array( 'a' => array( 'href', 'target'), 'br' => array() ); |
| 1954 | $allowed_protocols = array( 'http', 'https' ); |
| 1955 | $group_html = ''; |
| 1956 | |
| 1957 | $group_html .= '<h2>' . esc_html( $group_data['group_label'] ) . '</h2>'; |
| 1958 | $group_html .= '<div>'; |
| 1959 | |
| 1960 | foreach ( (array) $group_data['items'] as $group_item_id => $group_item_data ) { |
| 1961 | $group_html .= '<table>'; |
| 1962 | $group_html .= '<tbody>'; |
| 1963 | |
| 1964 | foreach ( (array) $group_item_data as $group_item_datum ) { |
| 1965 | $group_html .= '<tr>'; |
| 1966 | $group_html .= '<th>' . esc_html( $group_item_datum['name'] ) . '</th>'; |
| 1967 | $group_html .= '<td>' . wp_kses( $group_item_datum['value'], $allowed_tags, $allowed_protocols ) . '</td>'; |
| 1968 | $group_html .= '</tr>'; |
| 1969 | } |
| 1970 | |
| 1971 | $group_html .= '</tbody>'; |
| 1972 | $group_html .= '</table>'; |
| 1973 | } |
| 1974 | |
| 1975 | $group_html .= '</div>'; |
| 1976 | |
| 1977 | return $group_html; |
| 1978 | } |
| 1979 | |
| 1980 | /** |
| 1981 | * Generate the personal data export file. |
| 1982 | * |
| 1983 | * @since 4.9.6 |
| 1984 | * |
| 1985 | * @param int $request_id The export request ID. |
| 1986 | */ |
| 1987 | function wp_privacy_generate_personal_data_export_file( $request_id ) { |
| 1988 | // Maybe make this a cron job instead |
| 1989 | wp_privacy_delete_old_export_files(); |
| 1990 | |
| 1991 | if ( ! class_exists( 'ZipArchive' ) ) { |
| 1992 | wp_send_json_error( __( 'Unable to generate export file. ZipArchive not available.' ) ); |
| 1993 | } |
| 1994 | |
| 1995 | // Get the request |
| 1996 | $request = get_post( $request_id ); |
| 1997 | if ( 'user_export_request' !== $request->post_type ) { |
| 1998 | wp_send_json_error( __( 'Invalid request ID when generating export file' ) ); |
| 1999 | } |
| 2000 | |
| 2001 | // Create the exports folder if needed |
| 2002 | $upload_dir = wp_upload_dir(); |
| 2003 | $exports_dir = trailingslashit( $upload_dir['basedir'] . '/exports' ); |
| 2004 | $exports_url = trailingslashit( $upload_dir['baseurl'] . '/exports' ); |
| 2005 | |
| 2006 | $result = wp_mkdir_p( $exports_dir ); |
| 2007 | if ( is_wp_error( $result ) ) { |
| 2008 | wp_send_json_error( $result->get_error_message() ); |
| 2009 | } |
| 2010 | |
| 2011 | // Protect export folder from browsing |
| 2012 | $index_pathname = $exports_dir . 'index.html'; |
| 2013 | if ( ! file_exists( $index_pathname ) ) { |
| 2014 | $file = fopen( $index_pathname, 'w' ); |
| 2015 | if ( false === $file ) { |
| 2016 | wp_send_json_error( __( 'Unable to protect export folder from browsing' ) ); |
| 2017 | } |
| 2018 | fwrite( $file, 'Silence is golden.' ); |
| 2019 | fclose( $file ); |
| 2020 | } |
| 2021 | |
| 2022 | // Generate a difficult to guess filename |
| 2023 | $email_address = get_post_meta( $request_id, '_user_email', true ); |
| 2024 | if ( ! is_email( $email_address ) ) { |
| 2025 | wp_send_json_error( __( 'Invalid email address when generating export file' ) ); |
| 2026 | } |
| 2027 | |
| 2028 | $stripped_email = str_replace( '@', '-at-', $email_address ); |
| 2029 | $stripped_email = sanitize_title( $stripped_email ); // slugify the email address |
| 2030 | $obscura = md5( rand() ); |
| 2031 | $file_basename = 'wp-personal-data-file-' . $stripped_email . '-' . $obscura; |
| 2032 | $html_report_filename = $file_basename . '.html'; |
| 2033 | $html_report_pathname = $exports_dir . $html_report_filename; |
| 2034 | $file = fopen( $html_report_pathname, 'w' ); |
| 2035 | if ( false === $file ) { |
| 2036 | wp_send_json_error( __( 'Unable to open export file (HTML report) for writing' ) ); |
| 2037 | } |
| 2038 | |
| 2039 | $title = sprintf( |
| 2040 | __( 'Personal Data Export for %s' ), |
| 2041 | $email_address |
| 2042 | ); |
| 2043 | |
| 2044 | // Open HTML |
| 2045 | fwrite( $file, "<!DOCTYPE html>\n" ); |
| 2046 | fwrite( $file, "<html>\n" ); |
| 2047 | |
| 2048 | // Head |
| 2049 | fwrite( $file, "<head>\n" ); |
| 2050 | fwrite( $file, "<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />\n" ); |
| 2051 | fwrite( $file, "<style type='text/css'>" ); |
| 2052 | fwrite( $file, "body { color: black; font-family: Arial, sans-serif; font-size: 11pt; margin: 15px auto; width: 860px; }" ); |
| 2053 | fwrite( $file, "table { background: #f0f0f0; border: 1px solid #ddd; margin-bottom: 20px; width: 100%; }" ); |
| 2054 | fwrite( $file, "th { padding: 5px; text-align: left; width: 20%; }" ); |
| 2055 | fwrite( $file, "td { padding: 5px; }" ); |
| 2056 | fwrite( $file, "tr:nth-child(odd) { background-color: #fafafa; }" ); |
| 2057 | fwrite( $file, "</style>" ); |
| 2058 | fwrite( $file, "<title>" ); |
| 2059 | fwrite( $file, esc_html( $title ) ); |
| 2060 | fwrite( $file, "</title>" ); |
| 2061 | fwrite( $file, "</head>\n" ); |
| 2062 | |
| 2063 | // Body |
| 2064 | fwrite( $file, "<body>\n" ); |
| 2065 | |
| 2066 | // Heading |
| 2067 | fwrite( $file, "<h1>" . esc_html__( 'Personal Data Export' ) . "</h1>" ); |
| 2068 | |
| 2069 | // And now, all the Groups |
| 2070 | $groups = get_post_meta( $request_id, '_export_data_grouped', true ); |
| 2071 | |
| 2072 | // First, build a "About" group on the fly for this report |
| 2073 | $about_group = array( |
| 2074 | 'group_label' => __( 'About' ), |
| 2075 | 'items' => array( |
| 2076 | 'about-1' => array( |
| 2077 | array( |
| 2078 | 'name' => __( 'Report generated for' ), |
| 2079 | 'value' => $email_address, |
| 2080 | ), |
| 2081 | array( |
| 2082 | 'name' => __( 'For site' ), |
| 2083 | 'value' => get_bloginfo( 'name' ), |
| 2084 | ), |
| 2085 | array( |
| 2086 | 'name' => __( 'At URL' ), |
| 2087 | 'value' => get_bloginfo( 'url' ), |
| 2088 | ), |
| 2089 | array( |
| 2090 | 'name' => __( 'On' ), |
| 2091 | 'value' => current_time( 'mysql' ), |
| 2092 | ), |
| 2093 | ), |
| 2094 | ) |
| 2095 | ); |
| 2096 | |
| 2097 | // Merge in the special about group |
| 2098 | $groups = array_merge( array( 'about' => $about_group ), $groups ); |
| 2099 | |
| 2100 | // Now, iterate over every group in $groups and have the formatter render it in HTML |
| 2101 | foreach ( (array) $groups as $group_id => $group_data ) { |
| 2102 | fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group_data ) ); |
| 2103 | } |
| 2104 | |
| 2105 | fwrite( $file, "</body>\n" ); |
| 2106 | |
| 2107 | // Close HTML |
| 2108 | fwrite( $file, "</html>\n" ); |
| 2109 | fclose( $file ); |
| 2110 | |
| 2111 | // Now, generate the ZIP |
| 2112 | $archive_filename = $file_basename . '.zip'; |
| 2113 | $archive_pathname = $exports_dir . $archive_filename; |
| 2114 | $archive_url = $exports_url . $archive_filename; |
| 2115 | |
| 2116 | $zip = new ZipArchive; |
| 2117 | |
| 2118 | if ( TRUE === $zip->open( $archive_pathname, ZipArchive::CREATE ) ) { |
| 2119 | $zip->addFile( $html_report_pathname, 'index.html' ); |
| 2120 | $zip->close(); |
| 2121 | } else { |
| 2122 | wp_send_json_error( __( 'Unable to open export file (archive) for writing' ) ); |
| 2123 | } |
| 2124 | |
| 2125 | // And remove the HTML file |
| 2126 | unlink( $html_report_pathname ); |
| 2127 | |
| 2128 | // Save the export file in the request |
| 2129 | update_post_meta( $request_id, '_export_file_url', $archive_url ); |
| 2130 | update_post_meta( $request_id, '_export_file_path', $archive_pathname ); |
| 2131 | } |
| 2132 | |
| 2133 | /** |
| 2134 | * Intercept personal data exporter page ajax responses in order to assemble the personal data export file. |
| 2135 | * @see wp_privacy_personal_data_export_page |
| 2136 | * @since 4.9.6 |
| 2137 | * |
| 2138 | * @param array $response The response from the personal data exporter for the given page. |
| 2139 | * @param int $exporter_index The index of the personal data exporter. Begins at 1. |
| 2140 | * @param string $email_address The email address of the user whose personal data this is. |
| 2141 | * @param int $page The page of personal data for this exporter. Begins at 1. |
| 2142 | * @param int $request_id The request ID for this personal data export. |
| 2143 | * @return array The filtered response. |
| 2144 | */ |
| 2145 | function wp_privacy_process_personal_data_export_page( $response, $exporter_index, $email_address, $page, $request_id ) { |
| 2146 | // Do some simple checks on the shape of the response from the exporter |
| 2147 | // If the exporter response is malformed, don't attempt to consume it - let it |
| 2148 | // pass through to generate a warning to the user by default ajax processing |
| 2149 | if ( ! is_array( $response ) ) { |
| 2150 | return $response; |
| 2151 | } |
| 2152 | |
| 2153 | if ( ! array_key_exists( 'done', $response ) ) { |
| 2154 | return $response; |
| 2155 | } |
| 2156 | |
| 2157 | if ( ! array_key_exists( 'data', $response ) ) { |
| 2158 | return $response; |
| 2159 | } |
| 2160 | |
| 2161 | if ( ! is_array( $response['data'] ) ) { |
| 2162 | return $response; |
| 2163 | } |
| 2164 | |
| 2165 | // Get the request |
| 2166 | $request = get_post( $request_id ); |
| 2167 | if ( 'user_export_request' !== $request->post_type ) { |
| 2168 | wp_send_json_error( __( 'Invalid request ID when merging exporter data' ) ); |
| 2169 | } |
| 2170 | |
| 2171 | $export_data = array(); |
| 2172 | |
| 2173 | // First exporter, first page? Reset the report data accumulation array |
| 2174 | if ( 1 === $exporter_index && 1 === $page ) { |
| 2175 | update_post_meta( $request_id, '_export_data_raw', $export_data ); |
| 2176 | } else { |
| 2177 | $export_data = get_post_meta( $request_id, '_export_data_raw', true ); |
| 2178 | } |
| 2179 | |
| 2180 | // Now, merge the data from the exporter response into the data we have accumulated already |
| 2181 | $export_data = array_merge( $export_data, $response['data'] ); |
| 2182 | update_post_meta( $request_id, '_export_data_raw', $export_data ); |
| 2183 | |
| 2184 | // If we are not yet on the last page of the last exporter, return now |
| 2185 | $exporters = apply_filters( 'wp_privacy_personal_data_exporters', array() ); |
| 2186 | $is_last_exporter = $exporter_index === count( $exporters ); |
| 2187 | $exporter_done = $response['done']; |
| 2188 | if ( ! $is_last_exporter || ! $exporter_done ) { |
| 2189 | return $response; |
| 2190 | } |
| 2191 | |
| 2192 | // Now we need to re-organize the raw data hierarchically in groups and items. |
| 2193 | $groups = array(); |
| 2194 | foreach ( (array) $export_data as $export_datum ) { |
| 2195 | $group_id = $export_datum['group_id']; |
| 2196 | $group_label = $export_datum['group_label']; |
| 2197 | if ( ! array_key_exists( $group_id, $groups ) ) { |
| 2198 | $groups[ $group_id ] = array( |
| 2199 | 'group_label' => $group_label, |
| 2200 | 'items' => array(), |
| 2201 | ); |
| 2202 | } |
| 2203 | |
| 2204 | $item_id = $export_datum['item_id']; |
| 2205 | if ( ! array_key_exists( $item_id, $groups[ $group_id ]['items'] ) ) { |
| 2206 | $groups[ $group_id ]['items'][ $item_id ] = array(); |
| 2207 | } |
| 2208 | |
| 2209 | $old_item_data = $groups[ $group_id ]['items'][ $item_id ]; |
| 2210 | $merged_item_data = array_merge( $export_datum['data'], $old_item_data ); |
| 2211 | $groups[ $group_id ]['items'][ $item_id ] = $merged_item_data; |
| 2212 | } |
| 2213 | |
| 2214 | // Save the grouped data into the request |
| 2215 | delete_post_meta( $request_id, '_export_data_raw' ); |
| 2216 | update_post_meta( $request_id, '_export_data_grouped', $groups ); |
| 2217 | |
| 2218 | // And now, generate the export file |
| 2219 | $report_path = get_post_meta( $request_id, '_export_file_path', true ); |
| 2220 | if ( ! empty( $request_path ) ) { |
| 2221 | delete_post_meta( $request_id, '_export_file_path' ); |
| 2222 | @unlink( $request_path ); |
| 2223 | } |
| 2224 | delete_post_meta( $request_id, '_export_file_url' ); |
| 2225 | |
| 2226 | // Act on the collected, grouped personal data |
| 2227 | do_action( 'wp_privacy_personal_data_export_file', $request_id ); |
| 2228 | |
| 2229 | // Clear the grouped data now that it is no longer needed |
| 2230 | delete_post_meta( $request_id, '_export_data_grouped' ); |
| 2231 | |
| 2232 | // Modify the response to include the URL of the export file |
| 2233 | $export_file_url = get_post_meta( $request_id, '_export_file_url', true ); |
| 2234 | if ( ! empty( $export_file_url ) ) { |
| 2235 | $response['url'] = $export_file_url; |
| 2236 | } |
| 2237 | |
| 2238 | return $response; |
| 2239 | } |
| 2240 | |
| 2241 | /** |
| 2242 | * Cleans up export files older than three days old |
| 2243 | * |
| 2244 | * @since 4.9.6 |
| 2245 | */ |
| 2246 | function wp_privacy_delete_old_export_files() { |
| 2247 | $upload_dir = wp_upload_dir(); |
| 2248 | $exports_dir = trailingslashit( $upload_dir['basedir'] . '/exports' ); |
| 2249 | $export_files = list_files( $exports_dir ); |
| 2250 | |
| 2251 | foreach( (array) $export_files as $export_file ) { |
| 2252 | $file_age_in_seconds = time() - filemtime( $export_file ); |
| 2253 | |
| 2254 | if ( 3 * DAY_IN_SECONDS < $file_age_in_seconds ) { |
| 2255 | @unlink( $export_file ); |
| 2256 | } |
| 2257 | } |
| 2258 | } |