Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
25.68% covered (danger)
25.68%
47 / 183
11.76% covered (danger)
11.76%
2 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileRestorer
25.82% covered (danger)
25.82%
47 / 182
11.76% covered (danger)
11.76%
2 / 17
1335.86
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_files
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 restore_files
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
110
 get_symlinked_dirs
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
110
 should_enqueue_file
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 create_file_info_array
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 is_in_symlinked_directory
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 get_extension_slug_from_path
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 get_file_type
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 is_theme_file
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 is_plugin_file
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 is_plugin_symlinked
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 is_file_excluded
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 install_default_themes
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 get_file_exclusion_list
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
1
 install_theme
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 run_command
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * FileRestorer file.
4 *
5 * @package wpcomsh
6 */
7
8namespace Imports\Utils;
9
10require_once __DIR__ . '/../class-backup-import-action.php';
11
12use RecursiveDirectoryIterator;
13use RecursiveIteratorIterator;
14use SplQueue;
15use WP_Error;
16
17/**
18 * Class FileRestorer
19 *
20 * The FileRestorer class is used to restore files from a source directory
21 * to a destination directory. It uses a queue to manage the files to be
22 * restored, and it can optionally log its operations.
23 */
24class FileRestorer extends \Imports\Backup_Import_Action {
25    /**
26     * The source directory from which files will be restored.
27     *
28     * @var string
29     */
30    private $source_dir;
31    /**
32     * The destination directory to which files will be restored.
33     *
34     * @var string
35     */
36    private $dest_dir;
37    /**
38     * The queue that manages the files to be restored.
39     *
40     * @var \SplQueue
41     */
42    private $queue;
43    /**
44     * The list of symlinked directories.
45     *
46     * @var array
47     */
48    private $symlinked_dirs;
49    /**
50     * The total number of files to be restored.
51     *
52     * @var int
53     */
54    private $total_count;
55    const THEMES_DIR  = 'wp-content/themes/';
56    const PLUGINS_DIR = 'wp-content/plugins/';
57
58    /**
59     * FileRestorer constructor.
60     *
61     * Initializes a new instance of the FileRestorer class with the specified
62     * source directory, destination directory, and optional logger.
63     *
64     * @param string               $source_dir The source directory.
65     * @param string               $dest_dir The destination directory.
66     * @param null|LoggerInterface $logger An optional logger.
67     */
68    public function __construct( $source_dir, $dest_dir, $logger = null ) {
69        parent::__construct( $logger );
70        $this->source_dir     = trailingslashit( $source_dir );
71        $this->dest_dir       = trailingslashit( $dest_dir );
72        $this->queue          = new SplQueue();
73        $this->total_count    = 0;
74        $this->symlinked_dirs = array();
75    }
76
77    /**
78     * Enqueues the files to be restored.
79     *
80     * This method iterate over the files in the source directory and enqueue them for restoration.
81     *
82     * @return bool|\WP_Error True if at least one file was enqueued, or a WP_Error if no files were enqueued.
83     */
84    public function enqueue_files() {
85        $dir_iterator         = new RecursiveDirectoryIterator( $this->source_dir, RecursiveDirectoryIterator::SKIP_DOTS );
86        $iterator             = new RecursiveIteratorIterator( $dir_iterator, RecursiveIteratorIterator::SELF_FIRST );
87        $this->symlinked_dirs = $this->get_symlinked_dirs();
88        foreach ( $iterator as $fileinfo ) {
89            if ( $fileinfo->isFile() && $this->should_enqueue_file( $fileinfo ) ) {
90                $this->queue->enqueue( $this->create_file_info_array( $fileinfo ) );
91                ++$this->total_count;
92            }
93        }
94
95        $this->log( "Total files to copy: $this->total_count" );
96        if ( $this->total_count === 0 ) {
97            return new WP_Error( 'file_queue_failed', __( 'No files are queued.', 'wpcomsh' ) );
98        }
99        // This shouldn't be a hard stop, but we should log it.
100        $this->install_default_themes();
101
102        return true;
103    }
104
105    /**
106     * Restores files from the source directory to the destination directory.
107     *
108     * This method dequeues files from the queue and copies them from the source directory to the destination directory.
109     * It skips files that are inside a symlinked theme or plugin, or that are symbolic links themselves.
110     * It also creates any necessary directories in the destination directory.
111     * If a file cannot be copied for any reason, it is logged and the copy operation continues with the next file.
112     * After all files have been processed, it logs the number of files copied, skipped, and failed.
113     * If no files were copied, it returns a WP_Error; otherwise, it returns true.
114     *
115     * @return bool|\WP_Error True if at least one file was copied, or a WP_Error if no files were copied.
116     */
117    public function restore_files() {
118        $file_seen_count = 0;
119        $copied_count    = 0;
120        $skipped_count   = 0;
121        $failed_count    = 0;
122
123        while ( ! $this->queue->isEmpty() ) {
124            $file_info_array = $this->queue->dequeue();
125            $file_path       = $file_info_array['source_file_path'];
126            $dest_path       = $file_info_array['dest_file_path'];
127            $relative_path   = $file_info_array['relative_file_path'];
128            $file_type       = $file_info_array['file_type'];
129            ++$file_seen_count;
130
131            // Skip if the file is inside a symlinked theme or plugin
132            if ( $file_type === 'theme_files' || $file_type === 'plugin_files' ) {
133                if ( $this->is_in_symlinked_directory( $file_type, $relative_path ) ) {
134                    ++$skipped_count;
135                    $this->log( "$file_seen_count/$this->total_count entries seen. $relative_path is inside a symlinked directory, skipping..." );
136                    continue;
137                }
138            }
139
140            if ( is_link( $dest_path ) ) {
141                ++$skipped_count;
142                $this->log( "$file_seen_count/$this->total_count entries seen. $relative_path is a symbolic link, skipping..." );
143                continue;
144            }
145
146            if ( ! is_dir( dirname( $dest_path ) ) ) {
147                if ( ! wp_mkdir_p( dirname( $dest_path ) ) ) {
148                    $this->log( 'Failed to create directory ' . dirname( $dest_path ) );
149                    continue;
150                } else {
151                    $this->log( 'Created directory ' . dirname( $dest_path ) );
152                }
153            }
154
155            if ( ! copy( $file_path, $dest_path ) ) {
156                ++$failed_count;
157                $this->log( "$file_seen_count/$this->total_count entries seen. Failed to copy: $relative_path" );
158            } else {
159                ++$copied_count;
160                $this->log( "$file_seen_count/$this->total_count entries seen. Restoring: $relative_path" );
161            }
162        }
163
164        $this->log( "Finished copying $this->total_count files. Copied: $copied_count files. Skipped: $skipped_count files. Failed: $failed_count files." );
165
166        if ( $copied_count === 0 ) {
167            return new \WP_Error( 'file_restoration_failed', __( 'No files are restored.', 'wpcomsh' ) );
168        }
169
170        return true;
171    }
172
173    /**
174     * Retrieves the list of symlinked directories.
175     *
176     * This method iterates over the themes and plugins directories in the source directory,
177     * and checks each subdirectory to determine if it is symlinked in the destination directory.
178     * If a subdirectory is symlinked, or if it is a plugin directory managed by us, its name is added to the list.
179     *
180     * @return array The list of symlinked directory names.
181     */
182    private function get_symlinked_dirs() {
183        $dirs           = array(
184            $this->source_dir . self::THEMES_DIR,
185            $this->source_dir . self::PLUGINS_DIR,
186        );
187        $symlinked_dirs = array();
188
189        foreach ( $dirs as $dir ) {
190            $dir_iterator = array();
191
192            try {
193                $dir_iterator = new \DirectoryIterator( $dir );
194            } catch ( \Exception $e ) {
195                // The directory does not exist.
196                continue;
197            }
198
199            foreach ( $dir_iterator as $fileinfo ) {
200                $dest_dir = str_replace( $this->source_dir, $this->dest_dir, $fileinfo->getPathname() );
201                // check if it's symlinked and not a dot directory on destination
202                if ( $fileinfo->isDir() && ! $fileinfo->isDot() ) {
203                    $dir_name = str_replace( $this->dest_dir, '', $dest_dir );
204                    // if it's not exist on destination, skip it
205                    if ( ! is_dir( $dest_dir ) ) {
206                        continue;
207                    }
208                    if ( is_link( $dest_dir ) ) {
209                        $symlinked_dirs[] = $dir_name;
210                    } elseif ( strpos( $dir_name, self::PLUGINS_DIR ) === 0 ) {
211                        // if it's a plugin directory, check if the plugin is managed by us
212                        $parts = explode( '/', $dir_name );
213                        // Last part is the plugin directory name
214                        $plugin_to_symlink = array_pop( $parts );
215                        if ( $this->is_plugin_symlinked( $plugin_to_symlink ) ) {
216                            $symlinked_dirs[] = $dir_name;
217                        }
218                    }
219                }
220            }
221        }
222
223        return $symlinked_dirs;
224    }
225
226    /**
227     * Determines if a file should be enqueued for restoration.
228     *
229     * This method checks if a file is excluded from restoration. If the file is excluded,
230     * it logs a message and returns false. Otherwise, it returns true.
231     *
232     * @param \SplFileInfo $fileinfo The file to check.
233     * @return bool True if the file should be enqueued, false otherwise.
234     */
235    private function should_enqueue_file( $fileinfo ) {
236        $source_file_path   = $fileinfo->getRealPath();
237        $relative_file_path = str_replace( $this->source_dir, '', $source_file_path );
238
239        if ( $this->is_file_excluded( $source_file_path ) ) {
240            $this->log( "Excluded: $relative_file_path" );
241            return false;
242        }
243
244        return true;
245    }
246
247    /**
248     * Creates an array of file information for a given file.
249     *
250     * This method takes a SplFileInfo object and returns an array containing the source file path,
251     * destination file path, relative file path, and file type.
252     *
253     * @param \SplFileInfo $fileinfo The file to create information for.
254     * @return array An array containing the source file path, destination file path, relative file path, and file type.
255     */
256    private function create_file_info_array( $fileinfo ) {
257        $source_file_path   = $fileinfo->getRealPath();
258        $relative_file_path = str_replace( $this->source_dir, '', $source_file_path );
259
260        return array(
261            'source_file_path'   => $source_file_path,
262            'dest_file_path'     => str_replace( $this->source_dir, $this->dest_dir, $source_file_path ),
263            'relative_file_path' => $relative_file_path,
264            'file_type'          => $this->get_file_type( $relative_file_path ),
265        );
266    }
267
268    /**
269     * Checks if a file is in a symlinked directory.
270     *
271     * This method checks if the specified file, identified by its type and path,
272     * is located within a symlinked directory. It does this by extracting the
273     * extension slug from the file path and checking if the resulting directory
274     * is in the list of symlinked directories.
275     *
276     * @param string $file_type The type of the file ('theme_files', 'plugin_files', or 'regular_files').
277     * @param string $file_path The path of the file to check.
278     * @return bool True if the file is in a symlinked directory, false otherwise.
279     */
280    private function is_in_symlinked_directory( $file_type, $file_path ) {
281        $slug = $this->get_extension_slug_from_path( $file_path );
282        $dir  = null;
283        if ( $slug ) {
284            if ( $file_type === 'theme_files' ) {
285                $dir = self::THEMES_DIR . $slug;
286            } else {
287                $dir = self::PLUGINS_DIR . $slug;
288            }
289        }
290        if ( in_array( $dir, $this->symlinked_dirs, true ) ) {
291            return true;
292        }
293
294        return false;
295    }
296
297    /**
298     * Extracts the extension slug from a file path.
299     *
300     * This method checks if the specified file path is part of a theme or a plugin,
301     * and if so, extracts and returns the extension slug. If the file is not part of a theme or a plugin,
302     * it returns null.
303     *
304     * @param string $file_path The path of the file to check.
305     * @return string|null The extension slug if the file is part of a theme or a plugin, or null otherwise.
306     */
307    private function get_extension_slug_from_path( $file_path ) {
308        $dir = dirname( $file_path );
309        if ( strpos( $dir, self::THEMES_DIR ) === 0 ) {
310            $relative_path = substr( $dir, strlen( self::THEMES_DIR ) );
311            $slug          = explode( '/', $relative_path )[0];
312            return $slug;
313        }
314
315        if ( strpos( $dir, self::PLUGINS_DIR ) === 0 ) {
316            $relative_path = substr( $dir, strlen( self::PLUGINS_DIR ) );
317            $slug          = explode( '/', $relative_path )[0];
318            return $slug;
319        }
320
321        return null;
322    }
323
324    /**
325     * Determines the type of a file based on its path.
326     *
327     * This method checks if the specified file path is part of a theme or a plugin,
328     * and returns a string indicating its type.
329     *
330     * @param string $file_path The path of the file to check.
331     * @return string 'theme_files' if the file is part of a theme, 'plugin_files' if the file is part of a plugin, or 'regular_files' otherwise.
332     */
333    public function get_file_type( $file_path ) {
334
335        if ( $this->is_theme_file( $file_path ) ) {
336            return 'theme_files';
337        }
338
339        if ( $this->is_plugin_file( $file_path ) ) {
340            return 'plugin_files';
341        }
342
343        return 'regular_files';
344    }
345
346    /**
347     * Checks if a file is part of a themes.
348     *
349     * @param string $file_path The path of the file to check.
350     * @return bool True if the file is part of a themes, false otherwise.
351     */
352    public function is_theme_file( $file_path ) {
353        if ( strpos( $file_path, self::THEMES_DIR ) !== false ) {
354            return true;
355        }
356
357        return false;
358    }
359    /**
360     * Checks if a file is part of a plugins.
361     *
362     * @param string $file_path The path of the file to check.
363     * @return bool True if the file is part of a plugins, false otherwise.
364     */
365    public function is_plugin_file( $file_path ) {
366        if ( strpos( $file_path, self::PLUGINS_DIR ) !== false ) {
367            return true;
368        }
369
370        return false;
371    }
372
373    /**
374     * Checks if a plugin is symlinked.
375     *
376     * This method checks if the specified plugin is symlinked by checking
377     * if the realpath of the plugin's relative path exists.
378     *
379     * @param string $plugin_to_symlink The name of the plugin to check.
380     * @return bool True if the plugin is symlinked, false otherwise.
381     */
382    public function is_plugin_symlinked( $plugin_to_symlink ) {
383        $managed_plugin_relative_path = "/../../../../wordpress/plugins/$plugin_to_symlink/latest";
384        if ( realpath( $managed_plugin_relative_path ) !== false ) {
385            return true;
386        }
387        return false;
388    }
389
390    /**
391     * Checks if a file should be excluded from restoration.
392     *
393     * This method checks if a file matches any of the exclusion patterns
394     * defined in the get_file_exclusion_list method.
395     *
396     * @param string $path The path of the file to check.
397     * @return bool True if the file should be excluded, false otherwise.
398     */
399    public function is_file_excluded( $path ) {
400        foreach ( $this->get_file_exclusion_list() as $exclusion ) {
401            if ( preg_match( $exclusion['pattern'], $path ) ) {
402                return true;
403            }
404        }
405
406        return false;
407    }
408
409    /**
410     * Installs default themes on the destination site via wp cli and skip if it's already symlinked.
411     *
412     * This function scans the themes directory in the source directory, identifies the default themes,
413     * and installs them on the destination site if they are not already installed.
414     */
415    public function install_default_themes() {
416        $source_themes_dir = $this->source_dir . self::THEMES_DIR;
417        $dest_themes_dir   = $this->dest_dir . self::THEMES_DIR;
418
419        if ( ! is_dir( $source_themes_dir ) ) {
420            $this->log( 'Install default themes: Source themes directory does not exist.' );
421            // Handle the case where the source themes directory does not exist
422            return;
423        }
424
425        $default_themes = glob( $source_themes_dir . '/twentytwenty*', GLOB_ONLYDIR );
426
427        foreach ( $default_themes as $theme_dir ) {
428            $theme_slug = basename( $theme_dir );
429            if ( ! is_dir( $dest_themes_dir . '/' . $theme_slug ) ) {
430                // The theme is not installed on the destination site, so install it
431                $this->log( 'Installing theme: ' . $theme_slug );
432                $result = self::install_theme( $theme_slug );
433                $this->log( 'Install theme result: ' . $result );
434            }
435        }
436    }
437
438    /**
439     * Returns the list of file exclusion patterns.
440     *
441     * @return array The list of file exclusion patterns.
442     */
443    public function get_file_exclusion_list() {
444        return array(
445            array(
446                'pattern' => '/\/wp-admin\//',
447                'message' => 'Excluded because path includes /wp-admin/.',
448            ),
449            array(
450                'pattern' => '/\/wp-includes\//',
451                'message' => 'Excluded because path includes /wp-includes/.',
452            ),
453            array(
454                'pattern' => '/\.sql$/',
455                'message' => 'Excluded because path is a .sql file.',
456            ),
457            array(
458                'pattern' => '/\/wp-content\/mu-plugins\//',
459                'message' => 'Excluded because path includes /wp-content/mu-plugins/',
460            ),
461            array(
462                'pattern' => '/\/wp-content\/database\//',
463                'message' => 'Excluded because path includes /wp-content/database/.',
464            ),
465            array(
466                'pattern' => '/\/wp-content\/plugins\/wordpress-importer\//',
467                'message' => 'Excluded because path includes /wp-content/plugins/wordpress-importer/.',
468            ),
469            array(
470                'pattern' => '/\/wp-content\/plugins\/sqlite-database-integration\//',
471                'message' => 'Excluded because path includes /wp-content/plugins/sqlite-database-integration/.',
472            ),
473            array(
474                'pattern' => '/\/wp-config\.php$/',
475                'message' => 'Excluded because file is wp-config.php.',
476            ),
477            array(
478                'pattern' => '/\/wp-content\/themes\/twentytwenty.*/',
479                'message' => 'Excluded because path includes a theme starting with twentytwenty.',
480            ),
481        );
482    }
483
484    /**
485     * Installs a theme using WP-CLI.
486     *
487     * @param string $theme_slug The slug of the theme to install.
488     * @return mixed
489     */
490    public static function install_theme( $theme_slug ) {
491        return self::run_command( '--skip-plugins --skip-themes --format=json theme install ' . $theme_slug, array( 'return' => true ) );
492    }
493
494    /**
495     * Run a WP-CLI command.
496     *
497     * @param string $command The command to run.
498     * @param array  $args    The arguments to pass to the command.
499     *
500     * @return mixed
501     */
502    public static function run_command( $command, $args = array() ) {
503        if ( class_exists( 'WP_CLI' ) ) {
504            return \WP_CLI::runcommand( $command, $args );
505        }
506
507        return false;
508    }
509}