Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
59.41% |
60 / 101 |
|
55.56% |
5 / 9 |
CRAP | |
0.00% |
0 / 1 |
| Report_Csv_Generator | |
60.00% |
60 / 100 |
|
55.56% |
5 / 9 |
113.40 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| generate | |
60.53% |
23 / 38 |
|
0.00% |
0 / 1 |
18.44 | |||
| escape_csv_value | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| write_bom | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| write_csv_row | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| create_temp_file | |
47.37% |
9 / 19 |
|
0.00% |
0 / 1 |
6.33 | |||
| protect_export_dir | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| delete_file | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
| stream_file | |
17.65% |
3 / 17 |
|
0.00% |
0 / 1 |
12.94 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Report CSV Generator |
| 4 | * |
| 5 | * Generates CSV files from report data arrays. |
| 6 | * |
| 7 | * @package Automattic\Jetpack\PremiumAnalytics\Reports\Export |
| 8 | */ |
| 9 | |
| 10 | declare( strict_types=1 ); |
| 11 | |
| 12 | namespace Automattic\Jetpack\PremiumAnalytics\Reports\Export; |
| 13 | |
| 14 | defined( 'ABSPATH' ) || exit; |
| 15 | |
| 16 | use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Logging\Logger_Interface; |
| 17 | use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Logger_Trait; |
| 18 | use WP_Error; |
| 19 | |
| 20 | /** |
| 21 | * CSV Generator class for creating CSV files from report data. |
| 22 | * |
| 23 | * @since $$next-version$$ |
| 24 | */ |
| 25 | class Report_Csv_Generator { |
| 26 | |
| 27 | use Logger_Trait; |
| 28 | |
| 29 | /** |
| 30 | * Constructor. |
| 31 | * |
| 32 | * @param Logger_Interface $logger The logger instance. |
| 33 | */ |
| 34 | public function __construct( Logger_Interface $logger ) { |
| 35 | $this->logger = $logger; |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Generate a CSV file from report data. |
| 40 | * |
| 41 | * @param array $data Report data array with 'data' key containing rows. |
| 42 | * @param array $columns Column definitions ['key' => 'Label']. |
| 43 | * @param callable $formatter Row formatter callback. |
| 44 | * @param string $filename Optional filename (without extension). |
| 45 | * @return string|WP_Error File path on success, WP_Error on failure. |
| 46 | */ |
| 47 | public function generate( array $data, array $columns, callable $formatter, string $filename = '' ) { |
| 48 | try { |
| 49 | // Generate filename if not provided. |
| 50 | if ( empty( $filename ) ) { |
| 51 | $filename = 'report-export-' . gmdate( 'Y-m-d-His' ); |
| 52 | } |
| 53 | |
| 54 | // Create temp file. |
| 55 | $file_path = $this->create_temp_file( $filename ); |
| 56 | if ( is_wp_error( $file_path ) ) { |
| 57 | return $file_path; |
| 58 | } |
| 59 | |
| 60 | // Open file for writing. |
| 61 | $handle = fopen( $file_path, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen |
| 62 | if ( false === $handle ) { |
| 63 | $this->logger->log_error( 'Failed to open CSV file for writing: ' . $file_path, __METHOD__ ); |
| 64 | return new WP_Error( |
| 65 | 'csv_file_open_failed', |
| 66 | __( 'Failed to open CSV file for writing.', 'jetpack-premium-analytics' ) |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | $rows = $data['data'] ?? array(); |
| 71 | |
| 72 | // Use try/finally so the file handle is always closed, even if the formatter throws. |
| 73 | try { |
| 74 | // Write BOM for UTF-8 (helps Excel recognize encoding). |
| 75 | $this->write_bom( $handle ); |
| 76 | |
| 77 | // Write header row (labels are our own strings, but escape for consistency). |
| 78 | $this->write_csv_row( $handle, array_map( array( self::class, 'escape_csv_value' ), array_values( $columns ) ) ); |
| 79 | |
| 80 | // Write data rows. |
| 81 | foreach ( $rows as $row ) { |
| 82 | $formatted_row = call_user_func( $formatter, $row ); |
| 83 | |
| 84 | // Skip empty rows (when formatter returns empty array). |
| 85 | if ( empty( $formatted_row ) ) { |
| 86 | continue; |
| 87 | } |
| 88 | |
| 89 | // Extract values in the same order as columns, neutralizing CSV formula injection. |
| 90 | $csv_row = array(); |
| 91 | foreach ( array_keys( $columns ) as $column_key ) { |
| 92 | $csv_row[] = self::escape_csv_value( $formatted_row[ $column_key ] ?? '' ); |
| 93 | } |
| 94 | |
| 95 | $this->write_csv_row( $handle, $csv_row ); |
| 96 | } |
| 97 | } finally { |
| 98 | fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose |
| 99 | } |
| 100 | |
| 101 | $this->logger->log_message( |
| 102 | sprintf( 'CSV file generated successfully: %s (%d rows)', $file_path, count( $rows ) ), |
| 103 | __METHOD__ |
| 104 | ); |
| 105 | |
| 106 | return $file_path; |
| 107 | |
| 108 | } catch ( \Throwable $e ) { |
| 109 | // Remove any partially written file so it does not linger in the exports dir. |
| 110 | if ( isset( $file_path ) && is_string( $file_path ) && file_exists( $file_path ) ) { |
| 111 | wp_delete_file( $file_path ); |
| 112 | } |
| 113 | // Catch Throwable (not just Exception) so a formatter TypeError still returns WP_Error |
| 114 | // and cleans up; log_error keeps within the logger's Exception-typed log_exception(). |
| 115 | $this->logger->log_error( $e->getMessage(), __METHOD__ ); |
| 116 | return new WP_Error( |
| 117 | 'csv_generation_failed', |
| 118 | __( 'Failed to generate CSV file.', 'jetpack-premium-analytics' ), |
| 119 | array( 'exception' => $e->getMessage() ) |
| 120 | ); |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * Neutralize CSV formula injection. |
| 126 | * |
| 127 | * Spreadsheet apps execute a cell whose value begins with =, +, -, @, tab, or CR. |
| 128 | * Prefixing with a single quote renders it as literal text. Exported values (e.g. |
| 129 | * product names) are store data and must not be trusted. |
| 130 | * |
| 131 | * @param mixed $value The cell value. |
| 132 | * @return string The escaped value. |
| 133 | */ |
| 134 | private static function escape_csv_value( $value ): string { |
| 135 | $value = (string) $value; |
| 136 | |
| 137 | // Leave legitimate numbers (including negatives like -12.00) untouched; only neutralize |
| 138 | // values that begin with a formula trigger and are not numeric. |
| 139 | if ( '' !== $value && ! is_numeric( $value ) && in_array( $value[0], array( '=', '+', '-', '@', "\t", "\r" ), true ) ) { |
| 140 | return "'" . $value; |
| 141 | } |
| 142 | |
| 143 | return $value; |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * Write the UTF-8 BOM. |
| 148 | * |
| 149 | * @param resource $handle File handle. |
| 150 | * @return void |
| 151 | * @throws \RuntimeException When the BOM cannot be written fully. |
| 152 | */ |
| 153 | private function write_bom( $handle ): void { |
| 154 | $bom = "\xEF\xBB\xBF"; |
| 155 | $bytes_written = fwrite( $handle, $bom ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite |
| 156 | |
| 157 | if ( strlen( $bom ) !== $bytes_written ) { |
| 158 | throw new \RuntimeException( 'Failed to write CSV BOM.' ); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Write one CSV row. |
| 164 | * |
| 165 | * @param resource $handle File handle. |
| 166 | * @param array $row CSV row. |
| 167 | * @return void |
| 168 | * @throws \RuntimeException When the row cannot be written. |
| 169 | */ |
| 170 | private function write_csv_row( $handle, array $row ): void { |
| 171 | $bytes_written = fputcsv( $handle, $row, ',', '"', '\\' ); |
| 172 | |
| 173 | if ( false === $bytes_written || 0 === $bytes_written ) { |
| 174 | throw new \RuntimeException( 'Failed to write CSV row.' ); |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Create a temporary file for CSV export. |
| 180 | * |
| 181 | * @param string $filename The filename (without extension). |
| 182 | * @return string|WP_Error File path on success, WP_Error on failure. |
| 183 | */ |
| 184 | private function create_temp_file( string $filename ) { |
| 185 | // Use WordPress upload directory. |
| 186 | $upload_dir = wp_upload_dir(); |
| 187 | |
| 188 | if ( ! empty( $upload_dir['error'] ) ) { |
| 189 | $this->logger->log_error( 'Upload directory error: ' . $upload_dir['error'], __METHOD__ ); |
| 190 | return new WP_Error( |
| 191 | 'upload_dir_error', |
| 192 | $upload_dir['error'] |
| 193 | ); |
| 194 | } |
| 195 | |
| 196 | // Create exports subdirectory. |
| 197 | $export_dir = trailingslashit( $upload_dir['basedir'] ) . 'jetpack-premium-analytics-exports'; |
| 198 | |
| 199 | if ( ! file_exists( $export_dir ) ) { |
| 200 | wp_mkdir_p( $export_dir ); |
| 201 | } |
| 202 | |
| 203 | // Ensure we can write to the directory. |
| 204 | if ( ! wp_is_writable( $export_dir ) ) { |
| 205 | $this->logger->log_error( 'Export directory is not writable: ' . $export_dir, __METHOD__ ); |
| 206 | return new WP_Error( |
| 207 | 'directory_not_writable', |
| 208 | __( 'Export directory is not writable.', 'jetpack-premium-analytics' ) |
| 209 | ); |
| 210 | } |
| 211 | |
| 212 | // Drop directory-listing/access protection so exports are not enumerable or web-served. |
| 213 | $this->protect_export_dir( $export_dir ); |
| 214 | |
| 215 | // Sanitize filename, add an unguessable suffix, and add extension. Files are delivered as |
| 216 | // email attachments; the random suffix is defense-in-depth against URL guessing. |
| 217 | $safe_filename = sanitize_file_name( $filename ) . '-' . wp_generate_password( 12, false ) . '.csv'; |
| 218 | |
| 219 | return trailingslashit( $export_dir ) . $safe_filename; |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * Write index.html + .htaccess guards into the export directory (best-effort, idempotent). |
| 224 | * |
| 225 | * @param string $export_dir The export directory path. |
| 226 | * @return void |
| 227 | */ |
| 228 | private function protect_export_dir( string $export_dir ): void { |
| 229 | $index = trailingslashit( $export_dir ) . 'index.html'; |
| 230 | if ( ! file_exists( $index ) ) { |
| 231 | @file_put_contents( $index, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged |
| 232 | } |
| 233 | |
| 234 | $htaccess = trailingslashit( $export_dir ) . '.htaccess'; |
| 235 | if ( ! file_exists( $htaccess ) ) { |
| 236 | // Dual syntax so it denies on both Apache 2.4 (mod_authz_core) and 2.2, and is inert on nginx. |
| 237 | $rules = "<IfModule mod_authz_core.c>\n\tRequire all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n\tOrder allow,deny\n\tDeny from all\n</IfModule>\n"; |
| 238 | @file_put_contents( $htaccess, $rules ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | /** |
| 243 | * Delete a CSV file. |
| 244 | * |
| 245 | * @param string $file_path The file path. |
| 246 | * @return bool True on success, false on failure. |
| 247 | */ |
| 248 | public function delete_file( string $file_path ): bool { |
| 249 | if ( ! file_exists( $file_path ) ) { |
| 250 | return false; |
| 251 | } |
| 252 | |
| 253 | $deleted = wp_delete_file( $file_path ); |
| 254 | |
| 255 | if ( $deleted ) { |
| 256 | $this->logger->log_message( 'CSV file deleted: ' . $file_path, __METHOD__ ); |
| 257 | } else { |
| 258 | $this->logger->log_error( 'Failed to delete CSV file: ' . $file_path, __METHOD__ ); |
| 259 | } |
| 260 | |
| 261 | return $deleted; |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * Stream a CSV file for download. |
| 266 | * |
| 267 | * @param string $file_path The file path. |
| 268 | * @param string $filename Optional download filename. |
| 269 | * @return bool True on success, false on failure. |
| 270 | */ |
| 271 | public function stream_file( string $file_path, string $filename = '' ): bool { |
| 272 | if ( ! file_exists( $file_path ) ) { |
| 273 | $this->logger->log_error( 'CSV file not found for streaming: ' . $file_path, __METHOD__ ); |
| 274 | return false; |
| 275 | } |
| 276 | |
| 277 | if ( empty( $filename ) ) { |
| 278 | $filename = basename( $file_path ); |
| 279 | } |
| 280 | |
| 281 | // Check if headers have already been sent. |
| 282 | if ( headers_sent() ) { |
| 283 | $this->logger->log_error( 'Headers already sent, cannot stream file', __METHOD__ ); |
| 284 | return false; |
| 285 | } |
| 286 | |
| 287 | // Set headers for file download. |
| 288 | header( 'Content-Type: text/csv; charset=utf-8' ); |
| 289 | header( 'X-Content-Type-Options: nosniff' ); |
| 290 | // Strip path + CR/LF/quotes so the filename cannot inject additional headers. |
| 291 | $safe_filename = str_replace( array( "\r", "\n", '"' ), '', basename( $filename ) ); |
| 292 | header( 'Content-Disposition: attachment; filename="' . $safe_filename . '"' ); |
| 293 | header( 'Content-Length: ' . filesize( $file_path ) ); |
| 294 | header( 'Pragma: no-cache' ); |
| 295 | header( 'Expires: 0' ); |
| 296 | |
| 297 | // Output file contents. |
| 298 | readfile( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile |
| 299 | |
| 300 | return true; |
| 301 | } |
| 302 | } |