Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
40.60% covered (danger)
40.60%
54 / 133
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Backup_Import_Manager
40.91% covered (danger)
40.91%
54 / 132
50.00% covered (danger)
50.00%
6 / 12
828.75
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 import
26.98% covered (danger)
26.98%
17 / 63
0.00% covered (danger)
0.00%
0 / 1
380.34
 update_status
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 bump_import_stats
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 determine_importer_type
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 get_importer
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 should_bail_out
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
7.35
 reset_import_status
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
6.50
 delete_backup_import_status
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 is_import_cancelled
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 get_backup_import_status
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 force_cache_unset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Backup_Import_Manager file.
4 *
5 * @package wpcomsh
6 */
7
8namespace Imports;
9
10// Include the file extractor.
11require_once __DIR__ . '/utils/class-fileextractor.php';
12
13use WP_Error;
14
15/**
16 * Class Backup_Import_Manager
17 *
18 * This class is responsible for managing the import of backups.
19 */
20class Backup_Import_Manager {
21    /**
22     * The path to the ZIP or TAR file to be imported.
23     *
24     * @var string
25     */
26    protected $zip_or_tar_file_path;
27    /**
28     * The path where the backup will be imported.
29     *
30     * @var string
31     */
32    protected $destination_path;
33    /**
34     * An array of actions that the importer needs to perform.
35     *
36     * @var array
37     */
38    protected $importer_actions = array(
39        'preprocess',
40        'process_files',
41        'recreate_database',
42        'postprocess_database',
43        'verify_site_integrity',
44        'clean_up',
45    );
46    /**
47     * An array of options.
48     *
49     * @var array
50     */
51    protected $options = array();
52
53    /**
54     * An array of valid option keys.
55     *
56     * @var array
57     */
58    protected $valid_option_keys = array(
59        'actions',
60        'bump_stats',
61        'dry_run',
62        'skip_clean_up',
63        'skip_unpack',
64    );
65
66    /**
67     * Importer type.
68     *
69     * @var string
70     */
71    protected $importer_type = null;
72
73    /**
74     * Constant representing the WordPress Playground importer type.
75     */
76    const WORDPRESS_PLAYGROUND = 'wordpress_playground';
77    /**
78     * Constant representing the Jetpack Backup importer type.
79     */
80    const JETPACK_BACKUP = 'jetpack_backup';
81    /**
82     * The prefix to use for temporary databases.
83     */
84    const TEMPORARY_DB_PREFIX = 'tmp_';
85    /**
86     * Constant representing the success status.
87     */
88    const SUCCESS = 'success';
89    /**
90     * Constant representing the failed status.
91     */
92    const FAILED = 'failed';
93    /**
94     * Constant representing the cancelled status.
95     */
96    const CANCELLED = 'cancelled';
97
98    /**
99     * Backup import status option name.
100     *
101     * @var string
102     */
103    public static $backup_import_status_option = 'backup_import_status';
104
105    /**
106     * Constructor for the Backup_Import_Manager class.
107     *
108     * This method initializes the $zip_or_tar_file_path and $destination_path properties.
109     *
110     * @param string $zip_or_tar_file_path The path to the ZIP or TAR file to be imported.
111     * @param string $destination_path The path where the backup will be imported.
112     * @param array  $options An array of options.
113     */
114    public function __construct( $zip_or_tar_file_path, $destination_path, $options = array() ) {
115        $this->zip_or_tar_file_path = $zip_or_tar_file_path;
116        $this->destination_path     = trailingslashit( $destination_path );
117        $this->options              = array_intersect_key( $options, array_flip( $this->valid_option_keys ) );
118    }
119    /**
120     * Import the backup.
121     *
122     * This method performs the following steps:
123     * 1. Extract the ZIP or TAR file to the destination path.
124     * 2. Determine the type of the importer based on the destination path.
125     * 3. Get an instance of the appropriate importer based on the type.
126     * 4. Call the importer's methods in the order specified in the $importer_actions array.
127     *
128     * @return bool|WP_Error True on success, or a WP_Error on failure.
129     */
130    public function import() {
131        $skip_clean_up = false;
132        if ( isset( $this->options['skip_clean_up'] ) && is_bool( $this->options['skip_clean_up'] ) ) {
133            $skip_clean_up = $this->options['skip_clean_up'];
134        }
135
136        $skip_unpack = false;
137        if ( isset( $this->options['skip_unpack'] ) && is_bool( $this->options['skip_unpack'] ) ) {
138            $skip_unpack = $this->options['skip_unpack'];
139        }
140
141        $bump_stats = true;
142        if ( isset( $this->options['bump_stats'] ) && is_bool( $this->options['bump_stats'] ) ) {
143            $bump_stats = $this->options['bump_stats'];
144        }
145
146        // check if there are import process that's already running
147        $check_bail_result = $this->should_bail_out();
148
149        if ( is_wp_error( $check_bail_result ) ) {
150
151            // We don't update status to failed here, because we don't want to overwrite the status
152
153            if ( $bump_stats ) {
154                $this->bump_import_stats( $check_bail_result->get_error_code() );
155            }
156
157            return $check_bail_result;
158        }
159
160        // reset the import status before everything starts
161        self::delete_backup_import_status();
162
163        // unzip/untar the file
164        if ( ! $skip_unpack ) {
165            $this->update_status( array( 'status' => 'unpack_file' ) );
166            $result = Utils\FileExtractor::extract( $this->zip_or_tar_file_path, $this->destination_path );
167
168            if ( is_wp_error( $result ) ) {
169                $this->update_status( array( 'status' => self::FAILED ) );
170
171                if ( $bump_stats ) {
172                    $this->bump_import_stats( $result->get_error_code() );
173                }
174
175                return $result;
176            }
177        }
178
179        // validate the type of the file
180        $importer_type = self::determine_importer_type( $this->destination_path );
181        if ( is_wp_error( $importer_type ) ) {
182            $this->update_status( array( 'status' => self::FAILED ) );
183
184            if ( $bump_stats ) {
185                $this->bump_import_stats( $importer_type->get_error_code() );
186            }
187
188            return $importer_type;
189        }
190
191        // get the importer
192        $importer = self::get_importer( $importer_type, $this->zip_or_tar_file_path, $this->destination_path );
193        if ( is_wp_error( $importer ) ) {
194            $this->update_status( array( 'status' => self::FAILED ) );
195
196            if ( $bump_stats ) {
197                $this->bump_import_stats( $importer->get_error_code() );
198            }
199
200            return $importer;
201        } else {
202            $this->importer_type = $importer_type;
203        }
204
205        $execute_actions = isset( $this->options['actions'] ) && count( $this->options['actions'] ) ? $this->options['actions'] : $this->importer_actions;
206        $dry_run         = isset( $this->options['dry_run'] ) && $this->options['dry_run'];
207
208        if ( $skip_clean_up ) {
209            foreach ( $execute_actions as $key => $action ) {
210                // Remove the cleanup action if the user has specified to skip cleanup.
211                if ( $action === 'clean_up' ) {
212                    unset( $execute_actions[ $key ] );
213                }
214            }
215        }
216
217        foreach ( $execute_actions as $action ) {
218            if ( ! method_exists( $importer, $action ) ) {
219                continue;
220            }
221
222            // Before calling the importer's method, let's check if the status is cancelled.
223            $cancel_result = $this->is_import_cancelled();
224
225            if ( true === $cancel_result ) {
226                // Clear the status.
227                self::delete_backup_import_status();
228
229                if ( $bump_stats ) {
230                    $this->bump_import_stats( 'backup_import_cancelled' );
231                }
232
233                return new WP_Error( 'backup_import_cancelled', __( 'The backup import has been cancelled.', 'wpcomsh' ) );
234            }
235
236            $this->update_status( array( 'status' => $action ) );
237
238            if ( $dry_run ) {
239                // Wait for 15-20 seconds in dry-run mode.
240                sleep( \wp_rand( 15, 20 ) );
241            } else {
242                // Call the importer's method.
243                $result = $importer->$action();
244
245                if ( is_wp_error( $result ) ) {
246                    $this->update_status( array( 'status' => self::FAILED ) );
247
248                    if ( $bump_stats ) {
249                        $this->bump_import_stats( $result->get_error_code() );
250                    }
251
252                    return $result;
253                }
254            }
255        }
256
257        if ( $bump_stats ) {
258            $this->bump_import_stats( 'success' );
259        }
260
261        return $this->update_status( array( 'status' => self::SUCCESS ) );
262    }
263
264    /**
265     * Updates the deployment status option.
266     *
267     * @param array $content The contents to be merged to the existing option.
268     *
269     * @return bool
270     */
271    private function update_status( array $content ): bool {
272        $existing = \get_option( self::$backup_import_status_option, array() );
273        $new      = array_merge( $existing, $content );
274
275        \update_option( self::$backup_import_status_option, $new );
276        self::force_cache_unset();
277
278        return true;
279    }
280
281    /**
282     * Bump the import stats.
283     *
284     * @param string $status The status of the import.
285     *
286     * @return bool|WP_Error True on success, or a WP_Error on failure.
287     */
288    private function bump_import_stats( string $status ) {
289        if ( isset( $this->options['dry_run'] ) && $this->options['dry_run'] ) {
290            return true;
291        }
292
293        // Bumping at the same time the status and the type.
294        $query_args = array(
295            'x_backup-import'      => $status,
296            'x_backup-import-type' => null === $this->importer_type ? 'unknown' : $this->importer_type,
297            'v'                    => 'wpcom-no-pv',
298        );
299
300        $stats_track_url = 'http://pixel.wp.com/b.gif?' . http_build_query( $query_args );
301        $result          = wp_remote_get( $stats_track_url );
302
303        if ( is_wp_error( $result ) ) {
304            return $result;
305        }
306
307        return true;
308    }
309
310    /**
311     * Determine the type of the importer based on the file in destination path.
312     *
313     * @param string $destination_path The path where the backup will be imported.
314     *
315     * @return string|WP_Error The type of the importer or a WP_Error if the type could not be determined.
316     */
317    public static function determine_importer_type( $destination_path ) {
318        if ( file_exists( $destination_path . 'wp-content/database/.ht.sqlite' ) ) {
319            return self::WORDPRESS_PLAYGROUND;
320        }
321
322        if ( file_exists( $destination_path . 'wp-config.php' ) ) {
323            return self::JETPACK_BACKUP;
324        }
325
326        return new WP_Error( 'unknown_importer_type', __( 'Could not determine importer type.', 'wpcomsh' ) );
327    }
328
329    /**
330     * Get an instance of the appropriate importer based on the type.
331     *
332     * @param string $type The type of the importer.
333     * @param string $zip_or_tar_file_path The path to the ZIP or TAR file to be imported.
334     * @param string $destination_path The path where the backup will be imported.
335     *
336     * @return Backup_Importer|WP_Error An instance of the appropriate importer or a WP_Error if the type is unknown.
337     */
338    public static function get_importer( string $type, string $zip_or_tar_file_path, string $destination_path ) {
339        switch ( $type ) {
340            case self::WORDPRESS_PLAYGROUND:
341                require_once __DIR__ . '/playground/class-playground-importer.php';
342                return new Playground_Importer( $zip_or_tar_file_path, $destination_path, self::TEMPORARY_DB_PREFIX );
343
344            default:
345                return new WP_Error( 'unknown_importer_type', __( 'Could not determine importer type.', 'wpcomsh' ) );
346        }
347    }
348
349    /**
350     * Checks if an import process is already running.
351     *
352     * @return false|WP_Error Returns WP_Error if an import process is running, false otherwise.
353     */
354    private function should_bail_out() {
355        $additional_status_to_check = array( 'unpack_file' );
356        $import_status              = self::get_backup_import_status();
357        $import_in_progress         = false;
358
359        if ( ! empty( $import_status ) ) {
360            // check if the status is one of other status
361            if ( in_array( $import_status['status'], $additional_status_to_check, true ) ) {
362                $import_in_progress = true;
363            }
364            // check if the status is one of the actions
365            if ( in_array( $import_status['status'], $this->importer_actions, true ) ) {
366                $import_in_progress = true;
367            }
368        }
369
370        if ( $import_in_progress ) {
371            return new WP_Error( 'import_in_progress', __( 'An import is already running.', 'wpcomsh' ) );
372        }
373        return false;
374    }
375
376    /**
377     * Reset the import status.
378     *
379     * @return bool|WP_Error True on success, or a WP_Error on failure.
380     */
381    public static function reset_import_status() {
382        $backup_import_status = self::get_backup_import_status();
383
384        if ( empty( $backup_import_status ) ) {
385            return new WP_Error( 'no_backup_import_found', __( 'No backup import found.', 'wpcomsh' ) );
386        }
387
388        if ( $backup_import_status['status'] === self::SUCCESS || $backup_import_status['status'] === self::FAILED ) {
389            // if it's a success or failed, we can delete the option directly
390            self::delete_backup_import_status();
391        } else {
392            // Otherwise we set the status to cancelled and update the option
393            update_option(
394                self::$backup_import_status_option,
395                array(
396                    'status' => self::CANCELLED,
397                ),
398            );
399            self::force_cache_unset();
400        }
401
402        return true;
403    }
404
405    /**
406     * Deletes the backup import status option.
407     *
408     * @return void
409     */
410    public static function delete_backup_import_status() {
411        delete_option( self::$backup_import_status_option );
412        self::force_cache_unset();
413    }
414
415    /**
416     * Checks if the import process has been cancelled.
417     *
418     * @return mixed Returns WP_Error if the import has been cancelled, false otherwise.
419     */
420    public function is_import_cancelled() {
421
422        $backup_import_status = self::get_backup_import_status();
423
424        if ( empty( $backup_import_status ) ) {
425            // The import status doesn't exist, so we should stop here.
426            return new WP_Error( 'no_backup_import_found', __( 'No backup import found.', 'wpcomsh' ) );
427        }
428
429        if ( isset( $backup_import_status['status'] ) && $backup_import_status['status'] === self::CANCELLED ) {
430            // The import has been cancelled, so we should stop here.
431            return true;
432        }
433
434        return false;
435    }
436    /**
437     * Get the backup import status.
438     *
439     * @return array|null Returns the backup import status or null if it doesn't exist.
440     */
441    public static function get_backup_import_status() {
442        $backup_import_status = get_option( self::$backup_import_status_option, null );
443        if ( is_array( $backup_import_status ) ) {
444            return $backup_import_status;
445        }
446        return null;
447    }
448    /**
449     * Force unset the cache for the backup import status option.
450     *
451     * @return void
452     */
453    public static function force_cache_unset() {
454        $alloptions = wp_load_alloptions();
455        if ( isset( $alloptions[ self::$backup_import_status_option ] ) ) {
456            unset( $alloptions[ self::$backup_import_status_option ] );
457            wp_cache_set( 'alloptions', $alloptions, 'options' );
458        } else {
459            wp_cache_delete( self::$backup_import_status_option, 'options' );
460        }
461    }
462}