PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.5.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.5.0
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / import-export / import / csv-file.php

csv-file.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.5.0, at inc/import-export/import/csv-file.php

378 lines 9.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Uploaded-CSV storage + reader for the own-data importer.
4 *
5 * An uploaded import file is stashed in a protected uploads subdirectory under
6 * a random token, then read back in ranges across import batches. Provides the
7 * header + sample for the analyze step and offset/limit row ranges for the
8 * batched import. Strips a UTF-8 BOM so the first header cell always matches.
9 *
10 * @package SureDonation
11 * @since 1.3.0
12 */
13
14 namespace SureDonation\Inc\Import_Export\Import;
15
16 // Exit if accessed directly.
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 /**
22 * CSV file store + reader.
23 *
24 * @since 1.3.0
25 */
26 class Csv_File {
27
28 /**
29 * Uploads subdirectory (relative to the uploads basedir) for import files.
30 *
31 * @var string
32 * @since 1.3.0
33 */
34 const SUBDIR = 'suredonation-import';
35
36 /**
37 * Absolute path to the protected import directory, creating and hardening
38 * it (deny-all .htaccess + silent index.php) on first use.
39 *
40 * @return string|false Directory path with trailing slash, or false on failure.
41 * @since 1.3.0
42 */
43 private static function dir() {
44 $uploads = wp_upload_dir();
45 if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) {
46 return false;
47 }
48
49 $dir = trailingslashit( $uploads['basedir'] ) . self::SUBDIR . '/';
50 if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
51 return false;
52 }
53
54 $htaccess = $dir . '.htaccess';
55 if ( ! file_exists( $htaccess ) ) {
56 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- One-time hardening of a private plugin directory.
57 file_put_contents( $htaccess, "Order allow,deny\nDeny from all\n" );
58 }
59 $index = $dir . 'index.php';
60 if ( ! file_exists( $index ) ) {
61 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- One-time hardening of a private plugin directory.
62 file_put_contents( $index, "<?php\n// Silence is golden.\n" );
63 }
64
65 return $dir;
66 }
67
68 /**
69 * Move an uploaded temp file into the protected directory under a token.
70 *
71 * @param string $tmp_path Uploaded file's tmp_name.
72 * @return string|false Token on success, false otherwise.
73 * @since 1.3.0
74 */
75 public static function store( $tmp_path ) {
76 $dir = self::dir();
77 if ( false === $dir || ! is_uploaded_file( $tmp_path ) ) {
78 return false;
79 }
80
81 // Opportunistically sweep abandoned uploads (analyze without a start,
82 // or a crashed run) so donor-PII files don't accumulate on disk.
83 self::gc();
84
85 $token = wp_generate_uuid4();
86 $dest = $dir . $token . '.csv';
87
88 if ( ! move_uploaded_file( $tmp_path, $dest ) ) {
89 return false;
90 }
91
92 return $token;
93 }
94
95 /**
96 * Delete stored import files older than $max_age.
97 *
98 * A safety net for files whose session ended without an explicit delete
99 * (abandoned analyze, fatal error mid-run). Completed/failed runs delete
100 * their own file immediately; this only catches the leftovers.
101 *
102 * @param int $max_age Maximum file age in seconds.
103 * @return void
104 * @since 1.3.0
105 */
106 public static function gc( $max_age = HOUR_IN_SECONDS ) {
107 $dir = self::dir();
108 if ( false === $dir ) {
109 return;
110 }
111
112 $files = glob( $dir . '*.csv' );
113 if ( ! is_array( $files ) ) {
114 return;
115 }
116
117 $threshold = time() - max( 0, (int) $max_age );
118 foreach ( $files as $file ) {
119 if ( is_file( $file ) && (int) filemtime( $file ) < $threshold ) {
120 wp_delete_file( $file );
121 }
122 }
123 }
124
125 /**
126 * Resolve a token to its file path, confined to the import directory.
127 *
128 * @param string $token File token.
129 * @return string|false Absolute path, or false if invalid/missing.
130 * @since 1.3.0
131 */
132 public static function path_for( $token ) {
133 $dir = self::dir();
134 if ( false === $dir || ! is_string( $token ) || ! preg_match( '/^[a-f0-9\-]{8,64}$/i', $token ) ) {
135 return false;
136 }
137
138 $path = $dir . $token . '.csv';
139 $real = realpath( $path );
140 if ( false === $real || 0 !== strpos( $real, (string) realpath( $dir ) ) || ! is_file( $real ) ) {
141 return false;
142 }
143
144 return $real;
145 }
146
147 /**
148 * Delete a stored import file.
149 *
150 * @param string $token File token.
151 * @return bool True when the file is gone.
152 * @since 1.3.0
153 */
154 public static function delete( $token ) {
155 $path = self::path_for( $token );
156 if ( false === $path ) {
157 return false;
158 }
159 wp_delete_file( $path );
160 return true;
161 }
162
163 /**
164 * Read the header row (BOM-stripped).
165 *
166 * @param string $token File token.
167 * @return array<int, string> Header cells, or empty array.
168 * @since 1.3.0
169 */
170 public static function read_header( $token ) {
171 $path = self::path_for( $token );
172 if ( false === $path ) {
173 return [];
174 }
175
176 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading a private import file line by line to bound memory.
177 $handle = fopen( $path, 'r' );
178 if ( false === $handle ) {
179 return [];
180 }
181
182 $header = fgetcsv( $handle, 0, ',', '"', '' );
183 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the import file handle.
184 fclose( $handle );
185
186 if ( ! is_array( $header ) ) {
187 return [];
188 }
189
190 if ( isset( $header[0] ) ) {
191 $header[0] = self::strip_bom( (string) $header[0] );
192 }
193
194 return array_map(
195 static function ( $cell ) {
196 return (string) $cell;
197 },
198 $header
199 );
200 }
201
202 /**
203 * Read a range of data rows (excluding the header), 0-indexed.
204 *
205 * @param string $token File token.
206 * @param int $offset Data-row offset.
207 * @param int $limit Max rows to return.
208 * @return array<int, array<int, string>> Rows of cell values.
209 * @since 1.3.0
210 */
211 public static function read_range( $token, $offset, $limit ) {
212 $path = self::path_for( $token );
213 if ( false === $path ) {
214 return [];
215 }
216
217 $offset = max( 0, (int) $offset );
218 $limit = max( 0, (int) $limit );
219 if ( 0 === $limit ) {
220 return [];
221 }
222
223 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading a private import file line by line to bound memory.
224 $handle = fopen( $path, 'r' );
225 if ( false === $handle ) {
226 return [];
227 }
228
229 fgetcsv( $handle, 0, ',', '"', '' ); // Skip header.
230
231 $row_index = 0;
232 $rows = [];
233 while ( true ) {
234 $row = fgetcsv( $handle, 0, ',', '"', '' );
235 if ( false === $row ) {
236 break;
237 }
238 if ( $row_index >= $offset && count( $rows ) < $limit ) {
239 $rows[] = array_map(
240 static function ( $cell ) {
241 return (string) $cell;
242 },
243 $row
244 );
245 }
246 ++$row_index;
247 if ( count( $rows ) >= $limit ) {
248 break;
249 }
250 }
251
252 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the import file handle.
253 fclose( $handle );
254
255 return $rows;
256 }
257
258 /**
259 * Read the next batch of data rows starting from a byte offset.
260 *
261 * Unlike read_range() (which rewinds and re-scans from the header every
262 * call), this seeks straight to $byte_offset, so a full import costs one
263 * linear pass instead of O(n^2/batch) re-scans. Pass 0 for the first batch
264 * (the header is skipped); the updated position is written back by
265 * reference for the next call.
266 *
267 * @param string $token File token.
268 * @param int $byte_offset Byte position to resume from (updated by reference).
269 * @param int $limit Max rows to return.
270 * @return array<int, array<int, string>> Rows of cell values.
271 * @since 1.3.0
272 */
273 public static function read_batch( $token, &$byte_offset, $limit ) {
274 $path = self::path_for( $token );
275 if ( false === $path ) {
276 return [];
277 }
278
279 $limit = max( 0, (int) $limit );
280 if ( 0 === $limit ) {
281 return [];
282 }
283 $byte_offset = max( 0, (int) $byte_offset );
284
285 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading a private import file line by line to bound memory.
286 $handle = fopen( $path, 'r' );
287 if ( false === $handle ) {
288 return [];
289 }
290
291 if ( $byte_offset > 0 ) {
292 fseek( $handle, $byte_offset );
293 } else {
294 fgetcsv( $handle, 0, ',', '"', '' ); // Skip header on the first batch.
295 }
296
297 $rows = [];
298 $read = 0;
299 while ( $read < $limit ) {
300 $row = fgetcsv( $handle, 0, ',', '"', '' );
301 if ( false === $row ) {
302 break;
303 }
304 $rows[] = array_map(
305 static function ( $cell ) {
306 return (string) $cell;
307 },
308 $row
309 );
310 ++$read;
311 }
312
313 $pos = ftell( $handle );
314 $byte_offset = false === $pos ? $byte_offset : (int) $pos;
315
316 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the import file handle.
317 fclose( $handle );
318
319 return $rows;
320 }
321
322 /**
323 * Read the first N data rows (for the analyze preview).
324 *
325 * @param string $token File token.
326 * @param int $limit Number of sample rows.
327 * @return array<int, array<int, string>> Sample rows.
328 * @since 1.3.0
329 */
330 public static function read_sample( $token, $limit = 5 ) {
331 return self::read_range( $token, 0, $limit );
332 }
333
334 /**
335 * Count data rows (excluding the header).
336 *
337 * @param string $token File token.
338 * @return int Row count.
339 * @since 1.3.0
340 */
341 public static function count_rows( $token ) {
342 $path = self::path_for( $token );
343 if ( false === $path ) {
344 return 0;
345 }
346
347 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading a private import file line by line to bound memory.
348 $handle = fopen( $path, 'r' );
349 if ( false === $handle ) {
350 return 0;
351 }
352
353 $count = -1; // Start at -1 so the header row isn't counted.
354 while ( fgetcsv( $handle, 0, ',', '"', '' ) !== false ) {
355 ++$count;
356 }
357
358 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the import file handle.
359 fclose( $handle );
360
361 return max( 0, $count );
362 }
363
364 /**
365 * Strip a leading UTF-8 BOM from a value.
366 *
367 * @param string $value Value.
368 * @return string Value without a leading BOM.
369 * @since 1.3.0
370 */
371 private static function strip_bom( $value ) {
372 if ( 0 === strncmp( $value, "\xEF\xBB\xBF", 3 ) ) {
373 return substr( $value, 3 );
374 }
375 return $value;
376 }
377 }
378