Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.17% covered (warning)
79.17%
152 / 192
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SQL_Postprocessor
79.58% covered (warning)
79.58%
152 / 191
46.15% covered (danger)
46.15%
6 / 13
73.14
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 postprocess
44.00% covered (danger)
44.00%
11 / 25
0.00% covered (danger)
0.00%
0 / 1
23.22
 replace_urls
70.83% covered (warning)
70.83%
17 / 24
0.00% covered (danger)
0.00%
0 / 1
8.22
 save_whitelist_options
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
5.02
 remove_options
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 replace_users
33.33% covered (danger)
33.33%
6 / 18
0.00% covered (danger)
0.00%
0 / 1
12.41
 merge_plugins
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 get_tables_replace_query
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
10
 search_replace
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
2.00
 run_command
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 tmp_table_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_app_scope
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 get_url_scope
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * SQL_Postprocessor file.
4 *
5 * @package wpcomsh
6 */
7
8// This class performs multiple low-level operations on the database.
9
10// phpcs:disable phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
11// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
12// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
13// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
14// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange
15
16namespace Imports;
17
18require_once __DIR__ . '/../class-backup-import-action.php';
19
20use Automattic\Jetpack\Connection\Manager as Connection_Manager;
21
22/**
23 * Postprocess a SQL database.
24 */
25class SQL_Postprocessor extends \Imports\Backup_Import_Action {
26    const PLAYGROUND_SCOPED_URL = 'https://playground.wordpress.net/scope:';
27
28    /**
29     * The home URL.
30     *
31     * @var string
32     */
33    private string $home_url;
34
35    /**
36     * The site URL.
37     *
38     * @var string
39     */
40    private string $site_url;
41
42    /**
43     * The table temporary prefix.
44     *
45     * @var string
46     */
47    private string $tmp_prefix;
48
49    /**
50     * Whether to run the command in dry run mode.
51     *
52     * @var bool
53     */
54    private bool $dry_run;
55
56    /**
57     * SQL_Postprocessor constructor.
58     *
59     * @param string                       $home_url   The home URL.
60     * @param string                       $site_url   The site URL.
61     * @param string                       $tmp_prefix The table temporary prefix.
62     * @param bool                         $dry_run    Whether to run the command in dry run mode.
63     * @param null|Utils\Logger\FileLogger $logger     An optional logger for logging operations.
64     */
65    public function __construct( string $home_url, string $site_url, string $tmp_prefix, $dry_run = true, $logger = null ) {
66        parent::__construct( $logger );
67        $this->home_url   = $home_url;
68        $this->site_url   = $site_url;
69        $this->tmp_prefix = $tmp_prefix;
70        $this->dry_run    = $dry_run;
71    }
72
73    /**
74     * Postprocess the database after importing.
75     *
76     * @return bool|\WP_Error
77     */
78    public function postprocess() {
79        global $wpdb;
80
81        // 1. Replace the URLs.
82        $ret = $this->replace_urls();
83
84        if ( is_wp_error( $ret ) ) {
85            return $ret;
86        }
87
88        // 2. Preserve a whitelist of options.
89        $ret = $this->save_whitelist_options();
90
91        if ( is_wp_error( $ret ) ) {
92            return $ret;
93        }
94
95        // 3. Remove a list of options.
96        $ret = $this->remove_options();
97
98        if ( is_wp_error( $ret ) ) {
99            return $ret;
100        }
101
102        // 4. Merge plugins.
103        $ret = $this->merge_plugins();
104
105        if ( is_wp_error( $ret ) ) {
106            return $ret;
107        }
108
109        // 5. Replace temporary users.
110        $ret = $this->replace_users();
111
112        if ( is_wp_error( $ret ) ) {
113            return $ret;
114        }
115
116        // Query used to replace the tables.
117        // We do not replace the users and usermeta tables.
118        $exclude_list  = array( $wpdb->prefix . 'users', $wpdb->prefix . 'usermeta' );
119        $replace_query = $this->get_tables_replace_query( $exclude_list );
120
121        if ( ! is_array( $replace_query ) ) {
122            return $replace_query;
123        }
124
125        if ( ! $this->dry_run ) {
126            $this->log( 'Replace tables' );
127
128            // 6. Replace tables with temporary ones.
129            foreach ( $replace_query as $query ) {
130                $wpdb->query( $query );
131            }
132
133            // Flush the cache. This is needed to have fresh data.
134            wp_cache_flush();
135        }
136
137        return true;
138    }
139
140    /**
141     * Postprocess the database after importing.
142     *
143     * @return bool|\WP_Error
144     */
145    public function replace_urls() {
146        global $wpdb;
147
148        $this->log( 'Replace URLs' );
149
150        if ( ! wp_http_validate_url( $this->home_url ) ) {
151            return $this->error( 'invalid-home-url', __( 'The home URL is not valid.', 'wpcomsh' ) );
152        }
153
154        if ( ! wp_http_validate_url( $this->site_url ) ) {
155            return $this->error( 'invalid-site-url', __( 'The site URL is not valid.', 'wpcomsh' ) );
156        }
157
158        // Get the options from Playground database.
159        $tmp_options  = $this->tmp_table_name( 'options' );
160        $query        = "SELECT option_value FROM {$tmp_options} WHERE option_name = '%s'";
161        $prev_siteurl = $wpdb->get_var( sprintf( $query, 'siteurl' ) );
162
163        if ( $prev_siteurl === null ) {
164            return $this->error( 'missing-siteurl', __( 'Missing site URL.', 'wpcomsh' ) );
165        }
166
167        // 1. Replace the URLs.
168        $this->log( "Replace siteurl '{$prev_siteurl}' with '{$this->site_url}'" );
169        $this->search_replace( $prev_siteurl, esc_url_raw( $this->site_url ), $this->tmp_prefix . '*' );
170
171        $prev_home = $wpdb->get_var( sprintf( $query, 'home' ) );
172
173        if ( $prev_home === null ) {
174            return $this->error( 'missing-home', __( 'Missing home URL.', 'wpcomsh' ) );
175        }
176
177        if ( $prev_home !== $prev_siteurl ) {
178            // 2. Replace the (home) URLs.
179            $this->log( "Replace home '{$prev_home}' with '{$this->home_url}'" );
180            $this->search_replace( $prev_home, esc_url_raw( $this->home_url ), $this->tmp_prefix . '*' );
181        }
182
183        $app_scope = $this->get_app_scope();
184
185        if ( $app_scope !== null ) {
186            $scope = self::PLAYGROUND_SCOPED_URL . $app_scope;
187            // 3. Replace the app scope.
188            $this->log( "Replace app scope '{$scope}' with '{$this->site_url}'" );
189            $this->search_replace( $scope, esc_url_raw( $this->site_url ), $this->tmp_prefix . '*' );
190        }
191
192        return true;
193    }
194
195    /**
196     * Save a whitelist of options in temporary tables.
197     *
198     * @return bool|\WP_Error
199     */
200    public function save_whitelist_options() {
201        global $wpdb;
202
203        // A list of options to save.
204        $whitelist = array(
205            'admin_email',
206            'jetpack_active_modules',
207            'jetpack_options',
208            'jetpack_private_options',
209            'permalink_structure',
210            'db_version',
211        );
212
213        // Substitute the options.
214        $tmp_options = $this->tmp_table_name( 'options' );
215        $options     = implode( "', '", $whitelist );
216        $query       = "SELECT option_name, option_value FROM %s WHERE option_name IN ('{$options}')";
217        $options     = $wpdb->get_results( sprintf( $query, $wpdb->prefix . 'options' ), ARRAY_A );
218
219        if ( ! count( $options ) ) {
220            $this->log( 'No whitelist options' );
221
222            return false;
223        }
224
225        $inserted = 0;
226
227        foreach ( $options as $option ) {
228            $this->log( 'Save ' . $option['option_name'] );
229
230            // Replace the option.
231            $last_insert_id = $wpdb->replace(
232                $tmp_options,
233                array(
234                    'option_name'  => $option['option_name'],
235                    'option_value' => $option['option_value'],
236                ),
237                array( '%s', '%s' )
238            );
239
240            if ( $last_insert_id !== false ) {
241                ++$inserted;
242            }
243        }
244
245        if ( $inserted > 0 ) {
246            $this->log( 'Whitelist options saved' );
247
248            return true;
249        } else {
250            return $this->error( 'error-save-whitelist-option', __( 'Error saving whitelist options.', 'wpcomsh' ) );
251        }
252    }
253
254    /**
255     * Remove a list of options.
256     */
257    public function remove_options() {
258        global $wpdb;
259
260        // A list of options to remove.
261        $remove = array(
262            'new_admin_email', // This option is saved by default in Playground, not needed.
263        );
264
265        // Substitute the options.
266        $tmp_options = $this->tmp_table_name( 'options' );
267        $options     = implode( "', '", $remove );
268        $query       = "DELETE FROM %s WHERE option_name IN ('{$options}')";
269        $deleted     = $wpdb->query( sprintf( $query, $tmp_options ) );
270
271        $this->log( 'Options removed: ' . (int) $deleted );
272
273        return true;
274    }
275
276    /**
277     * Replace the users.
278     *
279     * @return bool|\WP_Error
280     */
281    public function replace_users() {
282        global $wpdb;
283
284        $this->log( 'Replace users' );
285
286        if ( ! class_exists( '\Automattic\Jetpack\Connection\Manager' ) ) {
287            // Jetpack is not installed.
288            return $this->error( 'jetpack-not-installed', __( 'Jetpack is not installed.', 'wpcomsh' ) );
289        }
290
291        $manager  = new Connection_Manager( 'jetpack' );
292        $owner_id = $manager->get_connection_owner_id();
293
294        if ( $owner_id === false ) {
295            // The site is not connected.
296            return $this->error( 'site-not-connected', __( 'The site is not connected.', 'wpcomsh' ) );
297        }
298
299        // Remap all posts.
300        $posts_table = $this->tmp_table_name( 'posts' );
301        $changed     = $wpdb->query( $wpdb->prepare( 'UPDATE ' . $posts_table . ' SET post_author=%d', $owner_id ) );
302
303        if ( $changed === false ) {
304            return $this->error( 'error-update-posts', __( 'Error update posts.', 'wpcomsh' ) );
305        } else {
306            $this->log( 'Posts updated: ' . $changed );
307        }
308
309        // Remap all links.
310        $links_table = $this->tmp_table_name( 'links' );
311        $changed     = $wpdb->query( $wpdb->prepare( 'UPDATE ' . $links_table . ' SET link_owner=%d', $owner_id ) );
312
313        if ( $changed === false ) {
314            return $this->error( 'error-update-links', __( 'Error update links.', 'wpcomsh' ) );
315        } else {
316            $this->log( 'Links updated: ' . $changed );
317        }
318
319        return true;
320    }
321
322    /**
323     * Merge the plugins.
324     *
325     * @return bool
326     */
327    public function merge_plugins(): bool {
328        global $wpdb;
329
330        $tmp_options = $this->tmp_table_name( 'options' );
331        $query       = "SELECT option_value FROM {$tmp_options} WHERE option_name = 'active_plugins'";
332
333        // Get the active plugins and the temporary ones.
334        $active_plugins     = (array) get_option( 'active_plugins', array() );
335        $tmp_active_plugins = $wpdb->get_var( $query );
336
337        if ( ! empty( $tmp_active_plugins ) ) {
338            $tmp_active_plugins = maybe_unserialize( $tmp_active_plugins );
339
340            if ( is_array( $tmp_active_plugins ) ) {
341                // Playground has some incompatible plugins installed by default.
342                $incompatible_plugins = array(
343                    'sqlite-database-integration/load.php',
344                    'wordpress-importer/wordpress-importer.php',
345                );
346
347                // Remove the incompatible plugins.
348                $tmp_active_plugins = array_diff( $tmp_active_plugins, $incompatible_plugins );
349
350                // Merge the plugins.
351                $active_plugins = array_merge( $active_plugins, $tmp_active_plugins );
352                $active_plugins = array_unique( $active_plugins );
353            }
354        }
355
356        $new_option = array( 'option_value' => maybe_serialize( $active_plugins ) );
357        $result     = $wpdb->update( $tmp_options, $new_option, array( 'option_name' => 'active_plugins' ) );
358
359        return $result !== false;
360    }
361
362    /**
363     * Get the replace table SQL query.
364     *
365     * @param array $exclude The tables to exclude.
366     *
367     * @return array|\WP_Error
368     */
369    public function get_tables_replace_query( $exclude = array() ) {
370        global $wpdb;
371
372        $this->log( 'Generate replace query' );
373
374        // Can't change the prefix if it's not different.
375        if ( $this->tmp_prefix === $wpdb->prefix ) {
376            return $this->error( 'invalid-prefix', __( 'Temporary prefix is equals to current prefix.', 'wpcomsh' ) );
377        }
378
379        $results = $wpdb->get_results( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $this->tmp_prefix ) . '%' ), ARRAY_N );
380
381        // Check if the temporary tables exist.
382        if ( null === $results || ( is_array( $results ) && ! count( $results ) ) ) {
383            return $this->error( 'missing-tables', __( 'Missing temporary tables.', 'wpcomsh' ) );
384        }
385
386        $prefix_len = strlen( $this->tmp_prefix );
387        $tmp_tables = array();
388        $tables     = array();
389        $renames    = array();
390
391        // Build the list of tables to rename, from tmp_wp_* to wp_*.
392        foreach ( $results as $result ) {
393            $from = $result[0];
394            $to   = substr( $result[0], $prefix_len );
395
396            // Save the temporary tables to drop them later.
397            $tmp_tables[] = $from;
398
399            // Skip the tables to exclude.
400            if ( in_array( $to, $exclude, true ) ) {
401                continue;
402            }
403
404            $tables[]  = $to;
405            $renames[] = $from . ' TO ' . $to; // The string 'tmp_wp_table TO wp_table'
406        }
407
408        // Drop production wp_* tables.
409        // Rename temporary tables tmp_wp_* with wp_*.
410        // Drop tmp_* temporary tables.
411        $ret = array( 'START TRANSACTION' );
412
413        if ( count( $tables ) ) {
414            $ret[] = 'DROP TABLE IF EXISTS ' . implode( ', ', $tables );
415        }
416
417        if ( count( $renames ) ) {
418            $ret[] = 'RENAME TABLE ' . implode( ', ', $renames );
419        }
420
421        if ( count( $tmp_tables ) ) {
422            $ret[] = 'DROP TABLE IF EXISTS ' . implode( ', ', $tmp_tables );
423        }
424
425        $ret[] = 'COMMIT';
426
427        return $ret;
428    }
429
430    /**
431     * Search and replace a string in the database.
432     *
433     * @param string $search  The string to search.
434     * @param string $replace The string to replace.
435     * @param string $tables  The tables to search and replace.
436     * @param bool   $dry_run Whether to run the command in dry run mode.
437     *
438     * @return mixed
439     */
440    public function search_replace( string $search, string $replace, string $tables, bool $dry_run = false ) {
441        $replace_query = 'search-replace \'%s\' \'%s\' \'%s\' %s';
442        $options       = array(
443            '--all-tables',
444            '--precise',
445            '--no-report',
446            '--format=count',
447        );
448
449        if ( $dry_run ) {
450            $options[] = '--dry-run';
451        }
452
453        $options = implode( ' ', $options );
454        $command = sprintf(
455            $replace_query,
456            $search,
457            $replace,
458            $tables,
459            $options
460        );
461
462        // Replace the site URL.
463        return $this->run_command( $command, array( 'return' => true ) );
464    }
465
466    /**
467     * Run a WP-CLI command.
468     *
469     * @param string $command The command to run.
470     * @param array  $args    The arguments to pass to the command.
471     *
472     * @return mixed
473     */
474    public function run_command( $command, $args = array() ) {
475        if ( class_exists( 'WP_CLI' ) ) {
476            return \WP_CLI::runcommand( $command, $args );
477        }
478
479        return false;
480    }
481
482    /**
483     * Get the temporary table name.
484     *
485     * @param string $table_name The table name.
486     *
487     * @return string
488     */
489    public function tmp_table_name( string $table_name ): string {
490        global $wpdb;
491
492        return $this->tmp_prefix . $wpdb->prefix . $table_name;
493    }
494
495    /**
496     * Get the app scope.
497     *
498     * The app scope is a guid like https://playground.wordpress.net/scope:app-scope.
499     *
500     * @see https://wordpress.github.io/wordpress-playground/architecture/browser-scopes/
501     *
502     * @return null|string|\WP_Error
503     */
504    public function get_app_scope() {
505        global $wpdb;
506
507        // Get the first scoped URL from database, if any.
508        $tmp_posts = $this->tmp_table_name( 'posts' );
509        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
510        $query = $wpdb->prepare( "SELECT guid FROM {$tmp_posts} WHERE guid LIKE %s LIMIT 1", self::PLAYGROUND_SCOPED_URL . '%' );
511        $guid  = $wpdb->get_var( $query );
512
513        if ( $guid === null ) {
514            // Nothing to replace.
515            return null;
516        }
517
518        // See setURLScope function in playground.js.
519        return $this->get_url_scope( $guid );
520    }
521
522    /**
523     * Get the scope from a URL.
524     *
525     * @param string $url The URL.
526     * @param string $scope The scope.
527     *
528     * @return string|null
529     */
530    public function get_url_scope( string $url, string $scope = self::PLAYGROUND_SCOPED_URL ): ?string {
531        // See setURLScope function in Playground.
532        $pattern = '/' . preg_quote( $scope, '/' ) . '([^\/]+)\/.*/';
533        $matches = array();
534        $ret     = preg_match( $pattern, $url, $matches );
535
536        if ( $ret !== 1 || ! isset( $matches[1] ) ) {
537            return null;
538        }
539
540        return $matches[1];
541    }
542}
543
544// phpcs:enable