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