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 0.1.0
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            if ( empty( $filename ) ) {
50                $filename = 'report-export-' . gmdate( 'Y-m-d-His' );
51            }
52
53            $file_path = $this->create_temp_file( $filename );
54            if ( is_wp_error( $file_path ) ) {
55                return $file_path;
56            }
57
58            $handle = fopen( $file_path, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
59            if ( false === $handle ) {
60                $this->logger->log_error( 'Failed to open CSV file for writing: ' . $file_path, __METHOD__ );
61                return new WP_Error(
62                    'csv_file_open_failed',
63                    __( 'Failed to open CSV file for writing.', 'jetpack-premium-analytics-pkg' )
64                );
65            }
66
67            $rows = $data['data'] ?? array();
68
69            // Use try/finally so the file handle is always closed, even if the formatter throws.
70            try {
71                // Helps Excel recognize the UTF-8 encoding.
72                $this->write_bom( $handle );
73
74                // Labels are our own strings, but escape them for consistency.
75                $this->write_csv_row( $handle, array_map( array( self::class, 'escape_csv_value' ), array_values( $columns ) ) );
76
77                foreach ( $rows as $row ) {
78                    $formatted_row = call_user_func( $formatter, $row );
79
80                    // An empty array is the formatter's skip signal.
81                    if ( empty( $formatted_row ) ) {
82                        continue;
83                    }
84
85                    $csv_row = array();
86                    foreach ( array_keys( $columns ) as $column_key ) {
87                        $csv_row[] = self::escape_csv_value( $formatted_row[ $column_key ] ?? '' );
88                    }
89
90                    $this->write_csv_row( $handle, $csv_row );
91                }
92            } finally {
93                fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
94            }
95
96            $this->logger->log_message(
97                sprintf( 'CSV file generated successfully: %s (%d rows)', $file_path, count( $rows ) ),
98                __METHOD__
99            );
100
101            return $file_path;
102
103        } catch ( \Throwable $e ) {
104            // Remove any partially written file so it does not linger in the exports dir.
105            if ( isset( $file_path ) && is_string( $file_path ) && file_exists( $file_path ) ) {
106                wp_delete_file( $file_path );
107            }
108            // Catch Throwable (not just Exception) so a formatter TypeError still returns WP_Error
109            // and cleans up; log_error keeps within the logger's Exception-typed log_exception().
110            $this->logger->log_error( $e->getMessage(), __METHOD__ );
111            return new WP_Error(
112                'csv_generation_failed',
113                __( 'Failed to generate CSV file.', 'jetpack-premium-analytics-pkg' ),
114                array( 'exception' => $e->getMessage() )
115            );
116        }
117    }
118
119    /**
120     * Neutralize CSV formula injection.
121     *
122     * Spreadsheet apps execute a cell starting with =, +, -, @, tab, or CR; a leading single
123     * quote renders it as literal text. Exported values (e.g. product names) are untrusted store data.
124     *
125     * @param mixed $value The cell value.
126     * @return string The escaped value.
127     */
128    private static function escape_csv_value( $value ): string {
129        $value = (string) $value;
130
131        // Leave legitimate numbers (including negatives like -12.00) untouched; only neutralize
132        // values that begin with a formula trigger and are not numeric.
133        if ( '' !== $value && ! is_numeric( $value ) && in_array( $value[0], array( '=', '+', '-', '@', "\t", "\r" ), true ) ) {
134            return "'" . $value;
135        }
136
137        return $value;
138    }
139
140    /**
141     * Write the UTF-8 BOM.
142     *
143     * @param resource $handle File handle.
144     * @return void
145     * @throws \RuntimeException When the BOM cannot be written fully.
146     */
147    private function write_bom( $handle ): void {
148        $bom           = "\xEF\xBB\xBF";
149        $bytes_written = fwrite( $handle, $bom ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
150
151        if ( strlen( $bom ) !== $bytes_written ) {
152            throw new \RuntimeException( 'Failed to write CSV BOM.' );
153        }
154    }
155
156    /**
157     * Write one CSV row.
158     *
159     * @param resource $handle File handle.
160     * @param array    $row    CSV row.
161     * @return void
162     * @throws \RuntimeException When the row cannot be written.
163     */
164    private function write_csv_row( $handle, array $row ): void {
165        $bytes_written = fputcsv( $handle, $row, ',', '"', '' );
166
167        if ( false === $bytes_written || 0 === $bytes_written ) {
168            throw new \RuntimeException( 'Failed to write CSV row.' );
169        }
170    }
171
172    /**
173     * Create a temporary file for CSV export.
174     *
175     * @param string $filename The filename (without extension).
176     * @return string|WP_Error File path on success, WP_Error on failure.
177     */
178    private function create_temp_file( string $filename ) {
179        $upload_dir = wp_upload_dir();
180
181        if ( ! empty( $upload_dir['error'] ) ) {
182            $this->logger->log_error( 'Upload directory error: ' . $upload_dir['error'], __METHOD__ );
183            return new WP_Error(
184                'upload_dir_error',
185                $upload_dir['error']
186            );
187        }
188
189        $export_dir = trailingslashit( $upload_dir['basedir'] ) . 'jetpack-premium-analytics-exports';
190
191        if ( ! file_exists( $export_dir ) ) {
192            wp_mkdir_p( $export_dir );
193        }
194
195        if ( ! wp_is_writable( $export_dir ) ) {
196            $this->logger->log_error( 'Export directory is not writable: ' . $export_dir, __METHOD__ );
197            return new WP_Error(
198                'directory_not_writable',
199                __( 'Export directory is not writable.', 'jetpack-premium-analytics-pkg' )
200            );
201        }
202
203        // Drop directory-listing/access protection so exports are not enumerable or web-served.
204        $this->protect_export_dir( $export_dir );
205
206        // Files are delivered as email attachments; the random suffix is defense-in-depth
207        // against URL guessing.
208        $safe_filename = sanitize_file_name( $filename ) . '-' . wp_generate_password( 12, false ) . '.csv';
209
210        return trailingslashit( $export_dir ) . $safe_filename;
211    }
212
213    /**
214     * Write index.html + .htaccess guards into the export directory (best-effort, idempotent).
215     *
216     * @param string $export_dir The export directory path.
217     * @return void
218     */
219    private function protect_export_dir( string $export_dir ): void {
220        $index = trailingslashit( $export_dir ) . 'index.html';
221        if ( ! file_exists( $index ) ) {
222            @file_put_contents( $index, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged
223        }
224
225        $htaccess = trailingslashit( $export_dir ) . '.htaccess';
226        if ( ! file_exists( $htaccess ) ) {
227            // Dual syntax so it denies on both Apache 2.4 (mod_authz_core) and 2.2, and is inert on nginx.
228            $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";
229            @file_put_contents( $htaccess, $rules ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged
230        }
231    }
232
233    /**
234     * Delete a CSV file.
235     *
236     * @param string $file_path The file path.
237     * @return bool True on success, false on failure.
238     */
239    public function delete_file( string $file_path ): bool {
240        if ( ! file_exists( $file_path ) ) {
241            return false;
242        }
243
244        $deleted = wp_delete_file( $file_path );
245
246        if ( $deleted ) {
247            $this->logger->log_message( 'CSV file deleted: ' . $file_path, __METHOD__ );
248        } else {
249            $this->logger->log_error( 'Failed to delete CSV file: ' . $file_path, __METHOD__ );
250        }
251
252        return $deleted;
253    }
254
255    /**
256     * Stream a CSV file for download.
257     *
258     * @param string $file_path The file path.
259     * @param string $filename  Optional download filename.
260     * @return bool True on success, false on failure.
261     */
262    public function stream_file( string $file_path, string $filename = '' ): bool {
263        if ( ! file_exists( $file_path ) ) {
264            $this->logger->log_error( 'CSV file not found for streaming: ' . $file_path, __METHOD__ );
265            return false;
266        }
267
268        if ( empty( $filename ) ) {
269            $filename = basename( $file_path );
270        }
271
272        if ( headers_sent() ) {
273            $this->logger->log_error( 'Headers already sent, cannot stream file', __METHOD__ );
274            return false;
275        }
276
277        header( 'Content-Type: text/csv; charset=utf-8' );
278        header( 'X-Content-Type-Options: nosniff' );
279        // Strip path + CR/LF/quotes so the filename cannot inject additional headers.
280        $safe_filename = str_replace( array( "\r", "\n", '"' ), '', basename( $filename ) );
281        header( 'Content-Disposition: attachment; filename="' . $safe_filename . '"' );
282        header( 'Content-Length: ' . filesize( $file_path ) );
283        header( 'Pragma: no-cache' );
284        header( 'Expires: 0' );
285
286        readfile( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
287
288        return true;
289    }
290}