Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.49% covered (warning)
79.49%
155 / 195
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SQL_Postprocessor
79.90% covered (warning)
79.90%
155 / 194
46.15% covered (danger)
46.15%
6 / 13
72.13
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
91.43% covered (success)
91.43%
32 / 35
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            // The schemes Jetpack Sync has seen for the site URLs. The restore runs in WP-CLI, where
212            // is_ssl() is false, so without this history Sync reports the site to WordPress.com as
213            // http:// until a later web request syncs it again.
214            'jetpack_sync_https_history_home_url',
215            'jetpack_sync_https_history_site_url',
216            'jetpack_sync_https_history_main_network_site_url',
217        );
218
219        // Substitute the options.
220        $tmp_options = $this->tmp_table_name( 'options' );
221        $options     = implode( "', '", $whitelist );
222        $query       = "SELECT option_name, option_value FROM %s WHERE option_name IN ('{$options}')";
223        $options     = $wpdb->get_results( sprintf( $query, $wpdb->prefix . 'options' ), ARRAY_A );
224
225        if ( ! count( $options ) ) {
226            $this->log( 'No whitelist options' );
227
228            return false;
229        }
230
231        $inserted = 0;
232
233        foreach ( $options as $option ) {
234            $this->log( 'Save ' . $option['option_name'] );
235
236            // Replace the option.
237            $last_insert_id = $wpdb->replace(
238                $tmp_options,
239                array(
240                    'option_name'  => $option['option_name'],
241                    'option_value' => $option['option_value'],
242                ),
243                array( '%s', '%s' )
244            );
245
246            if ( $last_insert_id !== false ) {
247                ++$inserted;
248            }
249        }
250
251        if ( $inserted > 0 ) {
252            $this->log( 'Whitelist options saved' );
253
254            return true;
255        } else {
256            return $this->error( 'error-save-whitelist-option', __( 'Error saving whitelist options.', 'wpcomsh' ) );
257        }
258    }
259
260    /**
261     * Remove a list of options.
262     */
263    public function remove_options() {
264        global $wpdb;
265
266        // A list of options to remove.
267        $remove = array(
268            'new_admin_email', // This option is saved by default in Playground, not needed.
269        );
270
271        // Substitute the options.
272        $tmp_options = $this->tmp_table_name( 'options' );
273        $options     = implode( "', '", $remove );
274        $query       = "DELETE FROM %s WHERE option_name IN ('{$options}')";
275        $deleted     = $wpdb->query( sprintf( $query, $tmp_options ) );
276
277        $this->log( 'Options removed: ' . (int) $deleted );
278
279        return true;
280    }
281
282    /**
283     * Replace the users.
284     *
285     * @return bool|\WP_Error
286     */
287    public function replace_users() {
288        global $wpdb;
289
290        $this->log( 'Replace users' );
291
292        if ( ! class_exists( '\Automattic\Jetpack\Connection\Manager' ) ) {
293            // Jetpack is not installed.
294            return $this->error( 'jetpack-not-installed', __( 'Jetpack is not installed.', 'wpcomsh' ) );
295        }
296
297        $manager  = new Connection_Manager( 'jetpack' );
298        $owner_id = $manager->get_connection_owner_id();
299
300        if ( $owner_id === false ) {
301            // The site is not connected.
302            return $this->error( 'site-not-connected', __( 'The site is not connected.', 'wpcomsh' ) );
303        }
304
305        // Remap all posts.
306        $posts_table = $this->tmp_table_name( 'posts' );
307        $changed     = $wpdb->query( $wpdb->prepare( 'UPDATE ' . $posts_table . ' SET post_author=%d', $owner_id ) );
308
309        if ( $changed === false ) {
310            return $this->error( 'error-update-posts', __( 'Error update posts.', 'wpcomsh' ) );
311        } else {
312            $this->log( 'Posts updated: ' . $changed );
313        }
314
315        // Remap all links.
316        $links_table = $this->tmp_table_name( 'links' );
317        $changed     = $wpdb->query( $wpdb->prepare( 'UPDATE ' . $links_table . ' SET link_owner=%d', $owner_id ) );
318
319        if ( $changed === false ) {
320            return $this->error( 'error-update-links', __( 'Error update links.', 'wpcomsh' ) );
321        } else {
322            $this->log( 'Links updated: ' . $changed );
323        }
324
325        return true;
326    }
327
328    /**
329     * Merge the plugins.
330     *
331     * @return bool
332     */
333    public function merge_plugins(): bool {
334        global $wpdb;
335
336        $tmp_options = $this->tmp_table_name( 'options' );
337        $query       = "SELECT option_value FROM {$tmp_options} WHERE option_name = 'active_plugins'";
338
339        // Get the active plugins and the temporary ones.
340        $active_plugins     = (array) get_option( 'active_plugins', array() );
341        $tmp_active_plugins = $wpdb->get_var( $query );
342
343        if ( ! empty( $tmp_active_plugins ) ) {
344            $tmp_active_plugins = maybe_unserialize( $tmp_active_plugins );
345
346            if ( is_array( $tmp_active_plugins ) ) {
347                // Playground has some incompatible plugins installed by default.
348                $incompatible_plugins = array(
349                    'sqlite-database-integration/load.php',
350                    'wordpress-importer/wordpress-importer.php',
351                );
352
353                // Remove the incompatible plugins.
354                $tmp_active_plugins = array_diff( $tmp_active_plugins, $incompatible_plugins );
355
356                // Merge the plugins.
357                $active_plugins = array_merge( $active_plugins, $tmp_active_plugins );
358                $active_plugins = array_unique( $active_plugins );
359            }
360        }
361
362        $new_option = array( 'option_value' => maybe_serialize( $active_plugins ) );
363        $result     = $wpdb->update( $tmp_options, $new_option, array( 'option_name' => 'active_plugins' ) );
364
365        return $result !== false;
366    }
367
368    /**
369     * Get the replace table SQL query.
370     *
371     * @param array $exclude The tables to exclude.
372     *
373     * @return array|\WP_Error
374     */
375    public function get_tables_replace_query( $exclude = array() ) {
376        global $wpdb;
377
378        $this->log( 'Generate replace query' );
379
380        // Can't change the prefix if it's not different.
381        if ( $this->tmp_prefix === $wpdb->prefix ) {
382            return $this->error( 'invalid-prefix', __( 'Temporary prefix is equals to current prefix.', 'wpcomsh' ) );
383        }
384
385        $results = $wpdb->get_results( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $this->tmp_prefix ) . '%' ), ARRAY_N );
386
387        // Check if the temporary tables exist.
388        if ( null === $results || ( is_array( $results ) && ! count( $results ) ) ) {
389            return $this->error( 'missing-tables', __( 'Missing temporary tables.', 'wpcomsh' ) );
390        }
391
392        $prefix_len = strlen( $this->tmp_prefix );
393        $tmp_tables = array();
394        $tables     = array();
395        $renames    = array();
396
397        // Build the list of tables to rename, from tmp_wp_* to wp_*.
398        foreach ( $results as $result ) {
399            $from = $result[0];
400            $to   = substr( $result[0], $prefix_len );
401
402            // Save the temporary tables to drop them later.
403            $tmp_tables[] = $from;
404
405            // Skip the tables to exclude.
406            if ( in_array( $to, $exclude, true ) ) {
407                continue;
408            }
409
410            $tables[]  = $to;
411            $renames[] = $from . ' TO ' . $to; // The string 'tmp_wp_table TO wp_table'
412        }
413
414        // Drop production wp_* tables.
415        // Rename temporary tables tmp_wp_* with wp_*.
416        // Drop tmp_* temporary tables.
417        $ret = array( 'START TRANSACTION' );
418
419        if ( count( $tables ) ) {
420            $ret[] = 'DROP TABLE IF EXISTS ' . implode( ', ', $tables );
421        }
422
423        if ( count( $renames ) ) {
424            $ret[] = 'RENAME TABLE ' . implode( ', ', $renames );
425        }
426
427        if ( count( $tmp_tables ) ) {
428            $ret[] = 'DROP TABLE IF EXISTS ' . implode( ', ', $tmp_tables );
429        }
430
431        $ret[] = 'COMMIT';
432
433        return $ret;
434    }
435
436    /**
437     * Search and replace a string in the database.
438     *
439     * @param string $search  The string to search.
440     * @param string $replace The string to replace.
441     * @param string $tables  The tables to search and replace.
442     * @param bool   $dry_run Whether to run the command in dry run mode.
443     *
444     * @return mixed
445     */
446    public function search_replace( string $search, string $replace, string $tables, bool $dry_run = false ) {
447        $replace_query = 'search-replace \'%s\' \'%s\' \'%s\' %s';
448        $options       = array(
449            '--all-tables',
450            '--precise',
451            '--no-report',
452            '--format=count',
453        );
454
455        if ( $dry_run ) {
456            $options[] = '--dry-run';
457        }
458
459        $options = implode( ' ', $options );
460        $command = sprintf(
461            $replace_query,
462            $search,
463            $replace,
464            $tables,
465            $options
466        );
467
468        // Replace the site URL.
469        return $this->run_command( $command, array( 'return' => true ) );
470    }
471
472    /**
473     * Run a WP-CLI command.
474     *
475     * @param string $command The command to run.
476     * @param array  $args    The arguments to pass to the command.
477     *
478     * @return mixed
479     */
480    public function run_command( $command, $args = array() ) {
481        if ( class_exists( 'WP_CLI' ) ) {
482            return \WP_CLI::runcommand( $command, $args );
483        }
484
485        return false;
486    }
487
488    /**
489     * Get the temporary table name.
490     *
491     * @param string $table_name The table name.
492     *
493     * @return string
494     */
495    public function tmp_table_name( string $table_name ): string {
496        global $wpdb;
497
498        return $this->tmp_prefix . $wpdb->prefix . $table_name;
499    }
500
501    /**
502     * Get the app scope.
503     *
504     * The app scope is a guid like https://playground.wordpress.net/scope:app-scope.
505     *
506     * @see https://wordpress.github.io/wordpress-playground/architecture/browser-scopes/
507     *
508     * @return null|string|\WP_Error
509     */
510    public function get_app_scope() {
511        global $wpdb;
512
513        // Get the first scoped URL from database, if any.
514        $tmp_posts = $this->tmp_table_name( 'posts' );
515        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
516        $query = $wpdb->prepare( "SELECT guid FROM {$tmp_posts} WHERE guid LIKE %s LIMIT 1", self::PLAYGROUND_SCOPED_URL . '%' );
517        $guid  = $wpdb->get_var( $query );
518
519        if ( $guid === null ) {
520            // Nothing to replace.
521            return null;
522        }
523
524        // See setURLScope function in playground.js.
525        return $this->get_url_scope( $guid );
526    }
527
528    /**
529     * Get the scope from a URL.
530     *
531     * @param string $url The URL.
532     * @param string $scope The scope.
533     *
534     * @return string|null
535     */
536    public function get_url_scope( string $url, string $scope = self::PLAYGROUND_SCOPED_URL ): ?string {
537        // See setURLScope function in Playground.
538        $pattern = '/' . preg_quote( $scope, '/' ) . '([^\/]+)\/.*/';
539        $matches = array();
540        $ret     = preg_match( $pattern, $url, $matches );
541
542        if ( $ret !== 1 || ! isset( $matches[1] ) ) {
543            return null;
544        }
545
546        return $matches[1];
547    }
548}
549
550// phpcs:enable