PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / backup / exporter.php

exporter.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/backup/exporter.php

420 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * StoreEngine full backup — exporter.
4 *
5 * Streams all StoreEngine data (custom tables + options + CPT posts/meta/terms,
6 * optionally WP users and uploaded files) into a single ZIP archive. Memory-safe
7 * (batched reads, per-row fwrite) and progress-reporting (pluggable callback for
8 * SSE or CLI).
9 *
10 * @version 1.0.0
11 */
12
13 namespace StoreEngine\Backup;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 class Exporter {
20
21 /** @var array{licensing:bool,logs:bool,users:bool,files:bool} */
22 protected array $opts;
23
24 /** @var callable|null function(float $percent, string $message): void */
25 protected $progress;
26
27 protected int $batch_size;
28
29 protected int $rows_total = 0;
30 protected int $rows_done = 0;
31
32 /**
33 * @param array $opts licensing(bool,true), deployments(bool,false), logs(bool,false), users(bool,false), files(bool,false)
34 * @param callable|null $progress function(float $percent, string $message): void
35 */
36 public function __construct( array $opts = [], ?callable $progress = null ) {
37 $this->opts = [
38 'licensing' => (bool) ( $opts['licensing'] ?? true ),
39 'deployments' => (bool) ( $opts['deployments'] ?? false ),
40 'logs' => (bool) ( $opts['logs'] ?? false ),
41 'users' => (bool) ( $opts['users'] ?? false ),
42 'files' => (bool) ( $opts['files'] ?? false ),
43 ];
44 $this->progress = $progress;
45 $this->batch_size = (int) apply_filters( 'storeengine/backup/batch_size', 2000 );
46 }
47
48 protected function report( float $percent, string $message ): void {
49 if ( $this->progress ) {
50 call_user_func( $this->progress, min( 99.0, round( $percent, 1 ) ), $message );
51 }
52 }
53
54 /**
55 * Run the export. Returns the absolute path to the created .zip.
56 *
57 * @throws \StoreEngine\Classes\Exceptions\StoreEngineException
58 */
59 public function run(): string {
60 global $wpdb;
61
62 $dir = BackupManager::ensure_backups_dir();
63 $stamp = gmdate( 'Ymd-His' );
64 $rand = wp_generate_password( 8, false );
65 $work = trailingslashit( $dir ) . 'tmp-' . $stamp . '-' . $rand;
66 wp_mkdir_p( $work );
67 wp_mkdir_p( $work . '/tables' );
68
69 try {
70 $tables = BackupManager::tables_for( $this->opts );
71
72 // Pre-compute total work (rows) for weighted progress.
73 $this->rows_total = 1; // avoid div-by-zero
74 $table_counts = [];
75 foreach ( $tables as $table ) {
76 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier bound via %i in prepare(); row count for export progress over a custom StoreEngine table.
77 $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) );
78 $table_counts[ $table ] = $count;
79 $this->rows_total += $count;
80 }
81 $post_types = BackupManager::post_types();
82 $post_total = $this->count_posts( $post_types );
83 $this->rows_total += $post_total;
84 if ( $this->opts['users'] ) {
85 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
86 $this->rows_total += (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" );
87 }
88
89 $manifest = [
90 'format_version' => BackupManager::FORMAT_VERSION,
91 'generated_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
92 'site_url' => site_url(),
93 'wp_prefix' => $wpdb->prefix,
94 'plugin_version' => defined( 'STOREENGINE_VERSION' ) ? STOREENGINE_VERSION : '',
95 'db_version' => defined( 'STOREENGINE_DB_VERSION' ) ? STOREENGINE_DB_VERSION : '',
96 'schema_hash' => get_option( 'storeengine_schema_hash', '' ),
97 'active_addons' => (array) get_option( 'storeengine_addons', [] ),
98 'options' => $this->opts,
99 'post_types' => $post_types,
100 'includes_users' => $this->opts['users'],
101 'includes_files' => $this->opts['files'],
102 'excluded' => BackupManager::DENYLIST,
103 'tables' => [],
104 ];
105
106 // 1) Tables.
107 foreach ( $tables as $table ) {
108 $base = BackupManager::basename( $table );
109 $file = 'tables/' . $base . '.jsonl';
110 $cols = $this->dump_table( $table, $work . '/' . $file );
111 $manifest['tables'][] = [
112 'name' => $base,
113 'full_name' => $table,
114 'file' => $file,
115 'row_count' => $table_counts[ $table ],
116 'columns' => $cols,
117 'group' => BackupManager::table_group( $table ),
118 ];
119 }
120
121 // 2) Options (raw values; + user_roles when users included).
122 $manifest['options_count'] = $this->dump_options( $work . '/options.json' );
123
124 // 3) CPT posts / postmeta / terms.
125 $manifest['post_count'] = $this->dump_posts( $post_types, $work );
126
127 // 4) WP users (opt-in).
128 if ( $this->opts['users'] ) {
129 $manifest['user_count'] = $this->dump_users( $work );
130 }
131
132 // Write manifest.
133 $this->write_file( $work . '/manifest.json', wp_json_encode( $manifest, JSON_PRETTY_PRINT ) );
134
135 // 5) Zip it.
136 $this->report( 99, __( 'Compressing archive…', 'storeengine' ) );
137 $archive = trailingslashit( $dir ) . 'storeengine-backup-' . $stamp . '-' . $rand . '.zip';
138 ArchiveWriter::zip_dir( $work, $archive );
139
140 // 6) Optional uploaded files appended (streamed, no temp copy).
141 // The deployment package files (versioned-files) are large and live
142 // under their own group, so they're excluded here and appended in
143 // (6b) only when the "deployments" group is selected. Both land at
144 // the same `files/...` archive path, so restore stays symmetric.
145 $backups_real = realpath( BackupManager::backups_dir() ) ?: null;
146 $versioned_dir = BackupManager::versioned_files_dir();
147 $versioned_real = $versioned_dir ? ( realpath( $versioned_dir ) ?: null ) : null;
148
149 if ( $this->opts['files'] && defined( 'STOREENGINE_SECURE_UPLOADS_DIR' ) ) {
150 ArchiveWriter::append_dir(
151 $archive,
152 STOREENGINE_SECURE_UPLOADS_DIR,
153 'files',
154 array_filter( [ $backups_real, $versioned_real ] )
155 );
156 }
157
158 // 6b) Deployment package files — only with the "deployments" group.
159 if ( $this->opts['deployments'] && $versioned_dir && is_dir( $versioned_dir ) ) {
160 ArchiveWriter::append_dir(
161 $archive,
162 $versioned_dir,
163 'files/' . BackupManager::VERSIONED_FILES_SUBDIR,
164 $backups_real
165 );
166 }
167
168 return $archive;
169 } finally {
170 ArchiveWriter::rrmdir( $work );
171 }
172 }
173
174 /* -------------------------------------------------------------------- */
175
176 /**
177 * Stream one table to a jsonl file in batches. Returns its column list.
178 *
179 * @return string[]
180 */
181 protected function dump_table( string $table, string $out_path ): array {
182 global $wpdb;
183
184 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier bound via %i in prepare(); reading column list of a custom StoreEngine table for export.
185 $columns = $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', $table ), 0 );
186 $columns = is_array( $columns ) ? $columns : [];
187
188 $handle = $this->open( $out_path );
189 $offset = 0;
190 $base = BackupManager::basename( $table );
191
192 do {
193 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier bound via %i and LIMIT/OFFSET via %d in prepare(); batched read of a custom StoreEngine table for streaming export.
194 $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY 1 LIMIT %d OFFSET %d', $table, $this->batch_size, $offset ), ARRAY_A );
195 if ( ! $rows ) {
196 break;
197 }
198 foreach ( $rows as $row ) {
199 fwrite( $handle, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering.
200 }
201 $this->rows_done += count( $rows );
202 $offset += $this->batch_size;
203 $this->report(
204 ( $this->rows_done / $this->rows_total ) * 100,
205 /* translators: %s table name */
206 sprintf( __( 'Exporting %s…', 'storeengine' ), $base )
207 );
208 } while ( count( $rows ) === $this->batch_size );
209
210 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
211
212 return $columns;
213 }
214
215 protected function dump_options( string $out_path ): int {
216 global $wpdb;
217
218 $names = [];
219 foreach ( BackupManager::option_like_patterns() as $pattern ) {
220 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
221 $found = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $pattern ) );
222 $names = array_merge( $names, (array) $found );
223 }
224 // Custom roles/caps live here; bundle with the users group.
225 if ( $this->opts['users'] ) {
226 $names[] = $wpdb->prefix . 'user_roles';
227 }
228 $names = array_values( array_unique( $names ) );
229
230 $options = [];
231 foreach ( $names as $name ) {
232 // Raw value straight from the table — exact serialization round-trip.
233 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
234 $value = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
235 if ( null !== $value ) {
236 $options[ $name ] = $value;
237 }
238 }
239
240 $this->write_file( $out_path, wp_json_encode( $options ) );
241
242 return count( $options );
243 }
244
245 protected function count_posts( array $post_types ): int {
246 global $wpdb;
247 if ( empty( $post_types ) ) {
248 return 0;
249 }
250 $in = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
251 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %s IN() list interpolated; post types bound via prepare() (placeholders present at runtime); core posts table.
252 return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type IN ($in)", $post_types ) );
253 }
254
255 protected function dump_posts( array $post_types, string $work ): int {
256 global $wpdb;
257 if ( empty( $post_types ) ) {
258 $this->write_file( $work . '/posts.jsonl', '' );
259 $this->write_file( $work . '/postmeta.jsonl', '' );
260 $this->write_file( $work . '/terms.jsonl', '' );
261 $this->write_file( $work . '/term_taxonomy.jsonl', '' );
262 $this->write_file( $work . '/term_relationships.jsonl', '' );
263
264 return 0;
265 }
266
267 $in_types = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
268
269 // posts
270 $posts_h = $this->open( $work . '/posts.jsonl' );
271 $offset = 0;
272 $count = 0;
273 do {
274 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic %s IN() list interpolated; post types + LIMIT/OFFSET bound via prepare() (count correct at runtime); core posts table.
275 $sql = $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_type IN ($in_types) ORDER BY ID LIMIT %d OFFSET %d", array_merge( $post_types, [ $this->batch_size, $offset ] ) );
276 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- $sql is built via $wpdb->prepare() on the line above; direct read for streaming export.
277 $rows = $wpdb->get_results( $sql, ARRAY_A );
278 if ( ! $rows ) {
279 break;
280 }
281 foreach ( $rows as $row ) {
282 fwrite( $posts_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering.
283 }
284 $count += count( $rows );
285 $this->rows_done += count( $rows );
286 $offset += $this->batch_size;
287 $this->report( ( $this->rows_done / $this->rows_total ) * 100, __( 'Exporting posts…', 'storeengine' ) );
288 } while ( count( $rows ) === $this->batch_size );
289 fclose( $posts_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
290
291 // postmeta (joined to our post types)
292 $meta_h = $this->open( $work . '/postmeta.jsonl' );
293 $offset = 0;
294 do {
295 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic %s IN() list interpolated; post types + LIMIT/OFFSET bound via prepare() (count correct at runtime); core postmeta table.
296 $sql = $wpdb->prepare(
297 "SELECT pm.* FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type IN ($in_types) ORDER BY pm.meta_id LIMIT %d OFFSET %d",
298 array_merge( $post_types, [ $this->batch_size, $offset ] )
299 );
300 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
301 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- $sql is built via $wpdb->prepare() on the line above; direct read for streaming export.
302 $rows = $wpdb->get_results( $sql, ARRAY_A );
303 if ( ! $rows ) {
304 break;
305 }
306 foreach ( $rows as $row ) {
307 fwrite( $meta_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering.
308 }
309 $offset += $this->batch_size;
310 } while ( count( $rows ) === $this->batch_size );
311 fclose( $meta_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
312
313 // terms / term_taxonomy / term_relationships for our taxonomies + posts
314 $taxonomies = [];
315 foreach ( $post_types as $pt ) {
316 $taxonomies = array_merge( $taxonomies, get_object_taxonomies( $pt ) );
317 }
318 $taxonomies = array_values( array_unique( $taxonomies ) );
319 $this->dump_terms( $taxonomies, $post_types, $work );
320
321 return $count;
322 }
323
324 protected function dump_terms( array $taxonomies, array $post_types, string $work ): void {
325 global $wpdb;
326
327 $tt_h = $this->open( $work . '/term_taxonomy.jsonl' );
328 $trm_h = $this->open( $work . '/terms.jsonl' );
329 $rel_h = $this->open( $work . '/term_relationships.jsonl' );
330
331 if ( ! empty( $taxonomies ) ) {
332 $in_tax = implode( ',', array_fill( 0, count( $taxonomies ), '%s' ) );
333 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %s IN() list interpolated; taxonomies bound via prepare() (placeholders present at runtime); core term_taxonomy table.
334 $tt_rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->term_taxonomy} WHERE taxonomy IN ($in_tax)", $taxonomies ), ARRAY_A );
335 $term_ids = [];
336 foreach ( (array) $tt_rows as $row ) {
337 fwrite( $tt_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering.
338 $term_ids[ (int) $row['term_id'] ] = true;
339 }
340 if ( $term_ids ) {
341 $ids = implode( ',', array_map( 'intval', array_keys( $term_ids ) ) );
342 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
343 $term_rows = $wpdb->get_results( "SELECT * FROM {$wpdb->terms} WHERE term_id IN ($ids)", ARRAY_A );
344 foreach ( (array) $term_rows as $row ) {
345 fwrite( $trm_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering.
346 }
347 }
348 }
349
350 // term_relationships for objects of our post types.
351 if ( ! empty( $post_types ) ) {
352 $in_types = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
353 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Dynamic %s IN() list interpolated; post types bound via prepare() (placeholders present at runtime); core term_relationships table.
354 $rel_rows = $wpdb->get_results( $wpdb->prepare( "SELECT tr.* FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id WHERE p.post_type IN ($in_types)", $post_types ), ARRAY_A );
355 foreach ( (array) $rel_rows as $row ) {
356 fwrite( $rel_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering.
357 }
358 }
359
360 fclose( $tt_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
361 fclose( $trm_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
362 fclose( $rel_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
363 }
364
365 protected function dump_users( string $work ): int {
366 global $wpdb;
367
368 $users_h = $this->open( $work . '/users.jsonl' );
369 $meta_h = $this->open( $work . '/usermeta.jsonl' );
370 $offset = 0;
371 $count = 0;
372 do {
373 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- LIMIT/OFFSET bound via %d in prepare(); batched read of the core users table for streaming export.
374 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->users} ORDER BY ID LIMIT %d OFFSET %d", $this->batch_size, $offset ), ARRAY_A );
375 if ( ! $rows ) {
376 break;
377 }
378 $ids = [];
379 foreach ( $rows as $row ) {
380 fwrite( $users_h, wp_json_encode( $row ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering.
381 $ids[] = (int) $row['ID'];
382 }
383 $id_in = implode( ',', $ids );
384 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
385 $metas = $wpdb->get_results( "SELECT * FROM {$wpdb->usermeta} WHERE user_id IN ($id_in)", ARRAY_A );
386 foreach ( (array) $metas as $m ) {
387 fwrite( $meta_h, wp_json_encode( $m ) . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming batched export; WP_Filesystem has no append and would force whole-table buffering.
388 }
389 $count += count( $rows );
390 $this->rows_done += count( $rows );
391 $offset += $this->batch_size;
392 $this->report( ( $this->rows_done / $this->rows_total ) * 100, __( 'Exporting users…', 'storeengine' ) );
393 } while ( count( $rows ) === $this->batch_size );
394
395 fclose( $users_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
396 fclose( $meta_h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
397
398 return $count;
399 }
400
401 /* -------------------------------------------------------------------- */
402
403 protected function open( string $path ) {
404 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
405 $handle = fopen( $path, 'wb' );
406 if ( ! $handle ) {
407 throw new \StoreEngine\Classes\Exceptions\StoreEngineException( 'Unable to open backup work file.', 'backup-open-fail' );
408 }
409
410 return $handle;
411 }
412
413 protected function write_file( string $path, string $contents ): void {
414 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
415 file_put_contents( $path, $contents );
416 }
417 }
418
419 // End of file exporter.php.
420