Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.41% covered (warning)
59.41%
60 / 101
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Report_Csv_Generator
60.00% covered (warning)
60.00%
60 / 100
55.56% covered (warning)
55.56%
5 / 9
113.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generate
60.53% covered (warning)
60.53%
23 / 38
0.00% covered (danger)
0.00%
0 / 1
18.44
 escape_csv_value
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 write_bom
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 write_csv_row
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 create_temp_file
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
6.33
 protect_export_dir
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 delete_file
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 stream_file
17.65% covered (danger)
17.65%
3 / 17
0.00% covered (danger)
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
10declare( strict_types=1 );
11
12namespace Automattic\Jetpack\PremiumAnalytics\Reports\Export;
13
14defined( 'ABSPATH' ) || exit;
15
16use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Logging\Logger_Interface;
17use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Logger_Trait;
18use WP_Error;
19
20/**
21 * CSV Generator class for creating CSV files from report data.
22 *
23 * @since $$next-version$$
24 */
25class 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}