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 / importer.php

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

408 lines 15.9 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 — importer / restore (Replace mode).
4 *
5 * Faithful, ID-preserving restore: custom tables are TRUNCATEd then re-inserted
6 * verbatim; options/posts/postmeta replaced; terms/users upserted by PK (never
7 * truncated, to protect data shared with other content). A pre-restore safety
8 * backup is taken first.
9 *
10 * @version 1.0.0
11 */
12
13 namespace StoreEngine\Backup;
14
15 use StoreEngine\Database;
16 use StoreEngine\Classes\Exceptions\StoreEngineException;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 class Importer {
23
24 /** @var callable|null function(float $percent, string $message): void */
25 protected $progress;
26
27 protected int $batch = 500;
28
29 public function __construct( ?callable $progress = null ) {
30 $this->progress = $progress;
31 $this->batch = (int) apply_filters( 'storeengine/backup/import_batch_size', 500 );
32 }
33
34 protected function report( float $percent, string $message ): void {
35 if ( $this->progress ) {
36 call_user_func( $this->progress, min( 99.0, round( $percent, 1 ) ), $message );
37 }
38 }
39
40 /**
41 * Read the manifest + a compatibility preview WITHOUT writing anything.
42 *
43 * @throws StoreEngineException
44 */
45 public function inspect( string $zip_path ): array {
46 $raw = ArchiveWriter::read_entry( $zip_path, 'manifest.json' );
47 if ( null === $raw ) {
48 throw new StoreEngineException( 'Not a valid StoreEngine backup (manifest.json missing).', 'backup-bad-manifest' );
49 }
50 $manifest = json_decode( $raw, true );
51 if ( ! is_array( $manifest ) || empty( $manifest['format_version'] ) ) {
52 throw new StoreEngineException( 'Corrupt or unsupported backup manifest.', 'backup-bad-manifest' );
53 }
54 if ( (int) $manifest['format_version'] > BackupManager::FORMAT_VERSION ) {
55 throw new StoreEngineException( 'This backup was made by a newer version of StoreEngine. Update first.', 'backup-version' );
56 }
57
58 global $wpdb;
59 $missing = [];
60 foreach ( (array) ( $manifest['tables'] ?? [] ) as $t ) {
61 $local = $wpdb->prefix . 'storeengine_' . $t['name'];
62 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
63 $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $local ) );
64 if ( ! $exists ) {
65 $missing[] = $t['name'];
66 }
67 }
68
69 return [
70 'manifest' => $manifest,
71 'missing_tables' => $missing, // addons not installed locally → will be skipped.
72 'prefix_mismatch' => ( $manifest['wp_prefix'] ?? $wpdb->prefix ) !== $wpdb->prefix,
73 ];
74 }
75
76 /**
77 * Restore. $opts: safety(bool,true), users(bool,true if present), files(bool,true if present).
78 *
79 * @throws StoreEngineException
80 */
81 public function run( string $zip_path, array $opts = [] ): array {
82 global $wpdb;
83
84 $take_safety = $opts['safety'] ?? true;
85
86 // 1) Pre-restore safety backup (DB-only) so a botched restore is recoverable.
87 if ( $take_safety ) {
88 $this->report( 1, __( 'Creating pre-restore safety backup…', 'storeengine' ) );
89 $safety = ( new Exporter( [ 'licensing' => true, 'logs' => true, 'users' => true, 'files' => false ] ) )->run();
90 // Re-label so it is recognisable as a pre-restore safety copy in the list.
91 $renamed = dirname( $safety ) . '/storeengine-prerestore-' . gmdate( 'Ymd-His' ) . '-' . wp_generate_password( 6, false ) . '.zip';
92 // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
93 if ( @rename( $safety, $renamed ) ) {
94 $safety = $renamed;
95 }
96 $safety_name = basename( $safety );
97 } else {
98 $safety_name = null;
99 }
100
101 // 2) Unzip.
102 $this->report( 4, __( 'Reading archive…', 'storeengine' ) );
103 $work = trailingslashit( BackupManager::ensure_backups_dir() ) . 'restore-' . wp_generate_password( 8, false );
104 ArchiveWriter::unzip( $zip_path, $work );
105
106 try {
107 $manifest = json_decode( (string) file_get_contents( $work . '/manifest.json' ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents
108 if ( ! is_array( $manifest ) ) {
109 throw new StoreEngineException( 'Corrupt backup manifest.', 'backup-bad-manifest' );
110 }
111
112 // 3) Ensure schema exists (core + addons) before inserting.
113 $this->report( 6, __( 'Ensuring database schema…', 'storeengine' ) );
114 Database::create_initial_custom_table();
115 do_action( 'storeengine/schema_synced' );
116
117 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
118 $wpdb->query( 'SET FOREIGN_KEY_CHECKS=0' );
119
120 $result = [ 'restored' => [], 'skipped' => [], 'safety_backup' => $safety_name ];
121
122 $tables = (array) ( $manifest['tables'] ?? [] );
123 $total = max( 1, count( $tables ) );
124 $i = 0;
125
126 // 4) Custom tables — TRUNCATE + faithful re-insert.
127 foreach ( $tables as $t ) {
128 $i++;
129 $base = $t['name'];
130 $local = $wpdb->prefix . 'storeengine_' . $base;
131 $file = $work . '/' . $t['file'];
132
133 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
134 $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $local ) );
135 if ( ! $exists || ! file_exists( $file ) ) {
136 $result['skipped'][] = $base;
137 continue;
138 }
139
140 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
141 $wpdb->query( "TRUNCATE TABLE `{$local}`" );
142 $this->restore_jsonl_into_table( $file, $local );
143 $result['restored'][] = $base;
144
145 $this->report( 6 + ( $i / $total ) * 78, sprintf( /* translators: %s table */ __( 'Restoring %s…', 'storeengine' ), $base ) );
146 }
147
148 // 5) Options.
149 $this->report( 86, __( 'Restoring settings…', 'storeengine' ) );
150 $this->restore_options( $work . '/options.json' );
151
152 // 6) Posts + postmeta (replace) and terms (upsert).
153 $this->report( 90, __( 'Restoring catalog…', 'storeengine' ) );
154 $this->restore_posts( $manifest, $work );
155 $this->restore_terms( $work );
156
157 // 7) Users (upsert) — only if present in the archive.
158 if ( ! empty( $manifest['includes_users'] ) && file_exists( $work . '/users.jsonl' ) ) {
159 $this->report( 94, __( 'Restoring users…', 'storeengine' ) );
160 $this->restore_users( $work );
161 }
162
163 // 8) Files.
164 if ( ! empty( $manifest['includes_files'] ) && is_dir( $work . '/files' ) && defined( 'STOREENGINE_SECURE_UPLOADS_DIR' ) ) {
165 $this->report( 96, __( 'Restoring uploaded files…', 'storeengine' ) );
166 $this->copy_tree( $work . '/files', STOREENGINE_SECURE_UPLOADS_DIR );
167 }
168
169 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
170 $wpdb->query( 'SET FOREIGN_KEY_CHECKS=1' );
171
172 // 9) Resync + flush.
173 Database::maybe_sync_schema();
174 update_option( 'storeengine_required_rewrite_flush', 'yes' );
175 wp_cache_flush();
176
177 return $result;
178 } finally {
179 ArchiveWriter::rrmdir( $work );
180 }
181 }
182
183 /* -------------------------------------------------------------------- */
184
185 /**
186 * Stream a jsonl file into a table in batches, preserving NULLs and PKs.
187 * Only inserts columns that exist in the (current) target table.
188 */
189 protected function restore_jsonl_into_table( string $file, string $table ): void {
190 global $wpdb;
191
192 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DESCRIBE on a prepared %i identifier; schema introspection, not cacheable.
193 $valid_cols = array_flip( (array) $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', $table ), 0 ) );
194
195 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
196 $h = fopen( $file, 'rb' );
197 if ( ! $h ) {
198 return;
199 }
200 $buffer = [];
201 while ( ( $line = fgets( $h ) ) !== false ) {
202 $line = trim( $line );
203 if ( '' === $line ) {
204 continue;
205 }
206 $row = json_decode( $line, true );
207 if ( ! is_array( $row ) ) {
208 continue;
209 }
210 // Keep only columns present in the current table (tolerate drift).
211 $buffer[] = array_intersect_key( $row, $valid_cols );
212 if ( count( $buffer ) >= $this->batch ) {
213 $this->insert_rows( $table, $buffer );
214 $buffer = [];
215 }
216 }
217 if ( $buffer ) {
218 $this->insert_rows( $table, $buffer );
219 }
220 fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
221 }
222
223 /**
224 * Multi-row INSERT preserving NULLs. Columns derived from the first row.
225 */
226 protected function insert_rows( string $table, array $rows ): void {
227 global $wpdb;
228 if ( empty( $rows ) ) {
229 return;
230 }
231 $columns = array_keys( $rows[0] );
232 $cols_sql = '`' . implode( '`,`', array_map( 'esc_sql', $columns ) ) . '`';
233
234 $tuples = [];
235 $args = [ $table ];
236 foreach ( $rows as $row ) {
237 $cells = [];
238 foreach ( $columns as $c ) {
239 $v = $row[ $c ] ?? null;
240 if ( null === $v ) {
241 $cells[] = 'NULL';
242 } else {
243 $cells[] = '%s';
244 $args[] = is_scalar( $v ) ? $v : wp_json_encode( $v );
245 }
246 }
247 $tuples[] = '(' . implode( ',', $cells ) . ')';
248 }
249
250 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table via %i; column list is esc_sql()'d identifiers; values via %s placeholders.
251 $sql = "INSERT INTO %i ({$cols_sql}) VALUES " . implode( ',', $tuples );
252 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared bulk insert into a restore target table; per-batch import write, not cacheable.
253 $wpdb->query( $wpdb->prepare( $sql, $args ) );
254 }
255
256 protected function restore_options( string $file ): void {
257 global $wpdb;
258 if ( ! file_exists( $file ) ) {
259 return;
260 }
261 $options = json_decode( (string) file_get_contents( $file ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents
262 if ( ! is_array( $options ) ) {
263 return;
264 }
265
266 // Replace: clear existing storeengine options first.
267 foreach ( BackupManager::option_like_patterns() as $pattern ) {
268 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
269 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $pattern ) );
270 }
271
272 foreach ( $options as $name => $value ) {
273 // Raw (already-serialized) value — insert directly, never via
274 // update_option (which would double-serialize).
275 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
276 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $name ) );
277 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
278 $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)", $name, $value, 'yes' ) );
279 }
280 }
281
282 protected function restore_posts( array $manifest, string $work ): void {
283 global $wpdb;
284
285 $post_types = (array) ( $manifest['post_types'] ?? [] );
286 if ( ! empty( $post_types ) ) {
287 $in = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
288 // Delete existing rows of these types (+ their meta) for a faithful replace.
289 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- %s placeholders built into $in and passed to prepare(); one placeholder per post type.
290 $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ($in)", $post_types ) );
291 if ( $ids ) {
292 $id_in = implode( ',', array_map( 'intval', $ids ) );
293 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
294 $wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($id_in)" );
295 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
296 $wpdb->query( "DELETE FROM {$wpdb->posts} WHERE ID IN ($id_in)" );
297 }
298 }
299
300 $this->restore_jsonl_into_table( $work . '/posts.jsonl', $wpdb->posts );
301 $this->restore_jsonl_into_table( $work . '/postmeta.jsonl', $wpdb->postmeta );
302 }
303
304 protected function restore_terms( string $work ): void {
305 global $wpdb;
306 // Upsert (replace by PK) so terms shared with non-StoreEngine content survive.
307 $this->upsert_jsonl( $work . '/terms.jsonl', $wpdb->terms );
308 $this->upsert_jsonl( $work . '/term_taxonomy.jsonl', $wpdb->term_taxonomy );
309 $this->upsert_jsonl( $work . '/term_relationships.jsonl', $wpdb->term_relationships );
310 }
311
312 protected function restore_users( string $work ): void {
313 global $wpdb;
314 $this->upsert_jsonl( $work . '/users.jsonl', $wpdb->users );
315 $this->upsert_jsonl( $work . '/usermeta.jsonl', $wpdb->usermeta );
316 }
317
318 /**
319 * REPLACE INTO from a jsonl file (upsert by PK) — used for shared WP tables
320 * we must not truncate (terms, users).
321 */
322 protected function upsert_jsonl( string $file, string $table ): void {
323 global $wpdb;
324 if ( ! file_exists( $file ) ) {
325 return;
326 }
327 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DESCRIBE on a prepared %i identifier; schema introspection, not cacheable.
328 $valid = array_flip( (array) $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', $table ), 0 ) );
329
330 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
331 $h = fopen( $file, 'rb' );
332 if ( ! $h ) {
333 return;
334 }
335 $buffer = [];
336 while ( ( $line = fgets( $h ) ) !== false ) {
337 $line = trim( $line );
338 if ( '' === $line ) {
339 continue;
340 }
341 $row = json_decode( $line, true );
342 if ( is_array( $row ) ) {
343 $buffer[] = array_intersect_key( $row, $valid );
344 }
345 if ( count( $buffer ) >= $this->batch ) {
346 $this->replace_rows( $table, $buffer );
347 $buffer = [];
348 }
349 }
350 if ( $buffer ) {
351 $this->replace_rows( $table, $buffer );
352 }
353 fclose( $h ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
354 }
355
356 protected function replace_rows( string $table, array $rows ): void {
357 global $wpdb;
358 if ( empty( $rows ) ) {
359 return;
360 }
361 $columns = array_keys( $rows[0] );
362 $cols_sql = '`' . implode( '`,`', array_map( 'esc_sql', $columns ) ) . '`';
363 $tuples = [];
364 $args = [ $table ];
365 foreach ( $rows as $row ) {
366 $cells = [];
367 foreach ( $columns as $c ) {
368 $v = $row[ $c ] ?? null;
369 if ( null === $v ) {
370 $cells[] = 'NULL';
371 } else {
372 $cells[] = '%s';
373 $args[] = is_scalar( $v ) ? $v : wp_json_encode( $v );
374 }
375 }
376 $tuples[] = '(' . implode( ',', $cells ) . ')';
377 }
378 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table via %i; column list is esc_sql()'d identifiers; values via %s placeholders.
379 $sql = "REPLACE INTO %i ({$cols_sql}) VALUES " . implode( ',', $tuples );
380 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared bulk upsert into a shared WP table; per-batch import write, not cacheable.
381 $wpdb->query( $wpdb->prepare( $sql, $args ) );
382 }
383
384 protected function copy_tree( string $src, string $dest ): void {
385 $src = rtrim( $src, '/\\' );
386 if ( ! is_dir( $dest ) ) {
387 wp_mkdir_p( $dest );
388 }
389 $items = new \RecursiveIteratorIterator(
390 new \RecursiveDirectoryIterator( $src, \FilesystemIterator::SKIP_DOTS ),
391 \RecursiveIteratorIterator::SELF_FIRST
392 );
393 foreach ( $items as $item ) {
394 $target = trailingslashit( $dest ) . ltrim( str_replace( '\\', '/', substr( $item->getPathname(), strlen( $src ) ) ), '/' );
395 if ( $item->isDir() ) {
396 if ( ! is_dir( $target ) ) {
397 wp_mkdir_p( $target );
398 }
399 } else {
400 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy
401 @copy( $item->getPathname(), $target );
402 }
403 }
404 }
405 }
406
407 // End of file importer.php.
408