Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.94% covered (warning)
72.94%
124 / 170
47.83% covered (danger)
47.83%
11 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Backup
73.37% covered (warning)
73.37%
124 / 169
47.83% covered (danger)
47.83%
11 / 23
200.83
0.00% covered (danger)
0.00%
0 / 1
 init
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 is_atomic
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 should_register
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 filter_jetpack_backup_dashboard
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_backup_admin_request
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
4.59
 register_page
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 owns_page
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 slug_is_claimed
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
7.23
 load_wp_build
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 alias_screen_id
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 restore_screen_id
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 enqueue_initial_state
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
6
 has_backup_feature
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 is_transfer_in_progress
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 get_eligibility
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 get_state
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 get_transfer_errors
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 get_transfer_warnings
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
11.02
 get_upgrade_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 get_page_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_post_transfer_page_url
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 get_activate_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- Feature entry file, named after the feature, also holds the WPCOM_Backup bootstrap class.
2/**
3 * An in-wp-admin Backup page for WordPress.com Simple and WoA sites.
4 * Prompts for a plan upgrade or activation of backups.
5 *
6 * @package automattic/jetpack-mu-wpcom
7 */
8
9namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;
10
11use Automattic\Jetpack\Constants;
12use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
13
14/**
15 * Registers the Backup admin page and its wp-build assets.
16 */
17class WPCOM_Backup {
18
19    /**
20     * Admin page slug. Matches Jetpack_Backup::JETPACK_BACKUP_SLUG so the URL survives a
21     * transfer to WoA, where the Jetpack plugin takes the slug over once backups are live.
22     */
23    const MENU_SLUG = 'jetpack-backup';
24
25    /**
26     * Page name for wp-build, as declared in routes/wpcom-backup/package.json.
27     *
28     * Deliberately not the menu slug: wp-build derives its own page slug and render
29     * function from this name.
30     */
31    const WP_BUILD_PAGE = 'wpcom-backup';
32
33    /**
34     * Render callback generated by wp-build in build/pages/wpcom-backup/page-wp-admin.php.
35     */
36    const RENDER_CALLBACK = 'jetpack_mu_wpcom_wpcom_backup_wp_admin_render_page';
37
38    /**
39     * `admin_menu` priority for register_page().
40     */
41    const REGISTER_PRIORITY = 999998;
42
43    /**
44     * Calypso flow that initiates the transfer, reports progress and returns here.
45     */
46    const TRANSFER_FLOW_URL = 'https://wordpress.com/setup/transferring-hosted-site';
47
48    /**
49     * Tracks context recorded for the WoA transfer.
50     */
51    const TRANSFER_CONTEXT = 'jetpack_product_activation';
52
53    /**
54     * Site has no backup-capable plan. Offer an upgrade.
55     */
56    const STATE_UPGRADE = 'upgrade';
57
58    /**
59     * A transfer to WoA is already running or queued. Offer nothing; report status.
60     */
61    const STATE_IN_PROGRESS = 'in_progress';
62
63    /**
64     * Site has the plan and can transfer. Offer activation.
65     */
66    const STATE_ACTIVATE = 'activate';
67
68    /**
69     * Whether this page registered the Backup slug on this request.
70     *
71     * @var bool
72     */
73    private static $owns_page = false;
74
75    /**
76     * The screen ID displaced by alias_screen_id(), or null when nothing is aliased.
77     *
78     * @var string|null
79     */
80    private static $displaced_screen_id = null;
81
82    /**
83     * Boot the feature, loading the wp-build assets only on the Backup page itself.
84     *
85     * @return void
86     */
87    public static function init() {
88        if ( ! self::should_register() ) {
89            return;
90        }
91
92        // After Admin_Menu (1000) so a claimed slug is visible, and before wpcom_add_jetpack_submenu()
93        // (999999), which hides the entry from the sidebar.
94        add_action( 'admin_menu', array( __CLASS__, 'register_page' ), self::REGISTER_PRIORITY );
95
96        if ( ! self::is_backup_admin_request() ) {
97            return;
98        }
99
100        self::load_wp_build();
101
102        // wp-build's generated enqueue runs at the default priority 10; the alias is
103        // scoped to that one pass so nothing else sees the rewritten screen.
104        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id' ), 9 );
105        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id' ), 11 );
106        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_initial_state' ), 20 );
107    }
108
109    /**
110     * Whether the site runs on WordPress.com's Atomic infrastructure.
111     *
112     * Read through Constants rather than `defined()` so tests can exercise both
113     * platforms in one process.
114     *
115     * @return bool
116     */
117    public static function is_atomic() {
118        return Constants::is_true( 'IS_ATOMIC' );
119    }
120
121    /**
122     * Whether this page should claim the Backup slug at all.
123     *
124     * A plan that includes backups only makes them live on Atomic infrastructure, so on
125     * WoA an entitled site already has working backups and this page steps aside. A Simple
126     * site is never Atomic, so the page always applies there.
127     *
128     * @return bool
129     */
130    public static function should_register() {
131        if ( ! self::is_atomic() ) {
132            return true;
133        }
134
135        return ! self::has_backup_feature();
136    }
137
138    /**
139     * Keep the Jetpack plugin's Backup dashboard off a WoA site whose plan lacks backups.
140     *
141     * Otherwise it claims the slug first and this page's upgrade prompt steps aside.
142     *
143     * @param bool $enabled Whether the Jetpack plugin offers its Backup dashboard.
144     * @return bool
145     */
146    public static function filter_jetpack_backup_dashboard( $enabled ) {
147        return $enabled && self::has_backup_feature();
148    }
149
150    /**
151     * Whether the current request targets the Backup admin page.
152     *
153     * @return bool
154     */
155    public static function is_backup_admin_request() {
156        // admin-ajax.php is also `is_admin()`, and has no menu, screen or enqueue pass to set up.
157        if ( ! is_admin() || 'admin.php' !== ( $GLOBALS['pagenow'] ?? '' ) ) {
158            return false;
159        }
160
161        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing check; no form data is processed.
162        return isset( $_GET['page'] ) && self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
163    }
164
165    /**
166     * Register the Backup page, unless something else already serves the slug.
167     *
168     * @return void
169     */
170    public static function register_page() {
171        // A second entry under a slug someone else owns would leave two.
172        if ( self::slug_is_claimed() ) {
173            return;
174        }
175
176        self::$owns_page = true;
177
178        // build/build.php defines the render callback but only loads on this page's
179        // own request, so elsewhere fall back rather than fataling.
180        $callback = function_exists( self::RENDER_CALLBACK )
181            ? self::RENDER_CALLBACK
182            : '__return_empty_string';
183
184        add_submenu_page(
185            'jetpack',
186            // Product names, do not translate.
187            'Jetpack VaultPress Backup',
188            'Backup',
189            'manage_options',
190            self::MENU_SLUG,
191            $callback
192        );
193    }
194
195    /**
196     * Whether this page registered the Backup slug on this request.
197     *
198     * @return bool
199     */
200    public static function owns_page() {
201        return self::$owns_page;
202    }
203
204    /**
205     * Reset the registration state. Test seam; production sets this once per request.
206     *
207     * @return void
208     */
209    public static function reset() {
210        self::$owns_page           = false;
211        self::$displaced_screen_id = null;
212    }
213
214    /**
215     * Whether something has already registered the Backup page.
216     *
217     * The Jetpack plugin serves this slug through `Admin_Menu` at `admin_menu` priority
218     * 1000 â€” before this page's own hook â€” so the entry is there to find.
219     *
220     * @return bool
221     */
222    public static function slug_is_claimed() {
223        global $submenu;
224
225        if ( empty( $submenu['jetpack'] ) || ! is_array( $submenu['jetpack'] ) ) {
226            return false;
227        }
228
229        foreach ( $submenu['jetpack'] as $item ) {
230            if ( is_array( $item ) && isset( $item[2] ) && self::MENU_SLUG === $item[2] ) {
231                return true;
232            }
233        }
234
235        return false;
236    }
237
238    /**
239     * Load the wp-build generated asset registrations and the boot polyfills.
240     *
241     * `build/` is produced at release time, so an unbuilt checkout has none; bail
242     * quietly, since register_page() already falls back to an empty render.
243     *
244     * @return void
245     */
246    public static function load_wp_build() {
247        $wp_build_index = dirname( __DIR__, 3 ) . '/build/build.php';
248
249        if ( ! file_exists( $wp_build_index ) ) {
250            return;
251        }
252
253        require_once $wp_build_index;
254
255        // Register polyfills for WP < 7.0 (must run before enqueue).
256        WP_Build_Polyfills::register(
257            'jetpack-mu-wpcom',
258            array_merge(
259                WP_Build_Polyfills::SCRIPT_HANDLES,
260                WP_Build_Polyfills::MODULE_IDS
261            )
262        );
263    }
264
265    /**
266     * Make wp-build's enqueue check pass without changing the page URL.
267     *
268     * Its enqueue callback needs a screen ID of `<page>`, which our `jetpack-backup`
269     * slug does not produce. Paired with restore_screen_id() so the rewritten ID never
270     * outlives the enqueue pass â€” `$screen->base`, the body class and the page-view
271     * tracker all keep reading the real one.
272     *
273     * @return void
274     */
275    public static function alias_screen_id() {
276        $screen = get_current_screen();
277
278        if ( ! self::owns_page() || ! is_object( $screen ) ) {
279            return;
280        }
281
282        self::$displaced_screen_id = $screen->id;
283        $screen->id                = self::WP_BUILD_PAGE;
284    }
285
286    /**
287     * Put back the screen ID displaced by alias_screen_id().
288     *
289     * @return void
290     */
291    public static function restore_screen_id() {
292        $screen = get_current_screen();
293
294        if ( null === self::$displaced_screen_id || ! is_object( $screen ) ) {
295            return;
296        }
297
298        $screen->id                = self::$displaced_screen_id;
299        self::$displaced_screen_id = null;
300    }
301
302    /**
303     * Hand the resolved state to the page's boot script.
304     *
305     * @return void
306     */
307    public static function enqueue_initial_state() {
308        $handle = self::WP_BUILD_PAGE . '-wp-admin-prerequisites';
309
310        if ( ! self::owns_page() || ! wp_script_is( $handle, 'registered' ) ) {
311            return;
312        }
313
314        // The menu's own capability gate keeps unauthorized users from reaching this hook at
315        // all; restated here because get_status_for_site() is called directly rather than
316        // through the wpcom endpoint that gates it.
317        if ( ! current_user_can( 'manage_options' ) ) {
318            return;
319        }
320
321        $blog_id = get_current_blog_id();
322        $user_id = get_current_user_id();
323        $domain  = wp_parse_url( home_url(), PHP_URL_HOST );
324        $state   = self::get_state( $blog_id );
325
326        // Only the activation prompt reads eligibility, and both lists come from the
327        // same result, so resolve it once and only for that state.
328        $eligibility = self::STATE_ACTIVATE === $state
329            ? self::get_eligibility( $blog_id, $user_id )
330            : null;
331        $warnings    = self::get_transfer_warnings( $eligibility );
332
333        Common\wpcom_enqueue_tracking_scripts( $handle );
334
335        wp_localize_script(
336            $handle,
337            'wpcomBackupInitialState',
338            array(
339                'state'       => $state,
340                'domain'      => $domain,
341                // A null result means the library was unavailable, not that the site
342                // failed a check; let the transfer flow reject it instead.
343                'isEligible'  => null === $eligibility || ! empty( $eligibility['is_eligible'] ),
344                'errors'      => self::get_transfer_errors( $eligibility ),
345                'warnings'    => $warnings,
346                'upgradeUrl'  => self::get_upgrade_url( $domain ),
347                'activateUrl' => self::get_activate_url( $warnings ),
348            )
349        );
350    }
351
352    /**
353     * Whether the site's plan includes self-serve backups.
354     *
355     * @return bool
356     */
357    public static function has_backup_feature() {
358        if ( ! function_exists( 'wpcom_site_has_feature' ) || ! defined( '\WPCOM_Features::BACKUPS_SELF_SERVE' ) ) {
359            return false;
360        }
361
362        // Called without a blog ID.
363        // WoA sites would pass the local blog ID, which is not the WordPress.com blog ID.
364        // wpcom resolves the current site.
365        return (bool) wpcom_site_has_feature( \WPCOM_Features::BACKUPS_SELF_SERVE );
366    }
367
368    /**
369     * Whether a transfer to WoA is already underway.
370     *
371     * @param int $blog_id Blog ID.
372     * @return bool
373     */
374    public static function is_transfer_in_progress( $blog_id ) {
375        if ( ! function_exists( 'require_lib' ) ) {
376            return false;
377        }
378
379        // These checks are only relevant for simple sites.
380        if ( self::is_atomic() ) {
381            return false;
382        }
383
384        require_lib( 'atomic' );
385
386        if ( function_exists( '\A8C\Atomic\has_site_pending_automated_transfer' ) ) {
387            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
388            if ( \A8C\Atomic\has_site_pending_automated_transfer( $blog_id ) ) {
389                return true;
390            }
391        }
392
393        if ( function_exists( '\A8C\Atomic\is_wpcom_atomic' ) ) {
394            // Second argument includes pending/active/provisioned transfers.
395            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
396            return (bool) \A8C\Atomic\is_wpcom_atomic( $blog_id, true );
397        }
398
399        return false;
400    }
401
402    /**
403     * Transfer eligibility for the site, or null when it cannot be determined.
404     *
405     * An in-progress transfer is reported as a `transfer_already_exists` error, so
406     * that state is checked first and never reaches here as a blocker.
407     *
408     * @param int $blog_id Blog ID.
409     * @param int $user_id User ID.
410     * @return array|null { is_eligible: bool, errors: array, warnings: array }
411     */
412    public static function get_eligibility( $blog_id, $user_id ) {
413        if ( ! function_exists( 'require_lib' ) ) {
414            return null;
415        }
416
417        // These checks are only relevant for simple sites.
418        if ( self::is_atomic() ) {
419            return null;
420        }
421
422        require_lib( 'atomic' );
423
424        if ( ! function_exists( '\A8C\Atomic\Eligibility\get_status_for_site' ) ) {
425            return null;
426        }
427
428        // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
429        return \A8C\Atomic\Eligibility\get_status_for_site( $blog_id, $user_id );
430    }
431
432    /**
433     * Resolve which prompt the page should show.
434     *
435     * @param int $blog_id Blog ID.
436     * @return string One of the STATE_* constants.
437     */
438    public static function get_state( $blog_id ) {
439        // On WoA the plan is the only question: the site is already on the
440        // infrastructure that runs backups, so there is nothing to transfer, and
441        // should_register() has already stepped the page aside if it has the plan.
442        if ( ! self::has_backup_feature() || self::is_atomic() ) {
443            $state = self::STATE_UPGRADE;
444        } elseif ( self::is_transfer_in_progress( $blog_id ) ) {
445            $state = self::STATE_IN_PROGRESS;
446        } else {
447            // Eligibility failures are explained by the activation prompt's own
448            // confirmation step, so they do not get a state of their own.
449            $state = self::STATE_ACTIVATE;
450        }
451
452        return $state;
453    }
454
455    /**
456     * Reasons a site cannot be transferred, in the shape the API returns them.
457     *
458     * The code is kept because the page maps it to its own copy; the API's message comes
459     * along as the fallback the page renders for codes it does not recognize.
460     *
461     * @param array|null $eligibility Result of get_eligibility().
462     * @return array[] Errors as { code, message }.
463     */
464    public static function get_transfer_errors( $eligibility ) {
465        $errors = array();
466
467        if ( ! empty( $eligibility['errors'] ) && is_array( $eligibility['errors'] ) ) {
468            foreach ( $eligibility['errors'] as $error ) {
469                if ( empty( $error['code'] ) ) {
470                    continue;
471                }
472
473                $errors[] = array(
474                    'code'    => (string) $error['code'],
475                    'message' => isset( $error['message'] ) ? (string) $error['message'] : '',
476                );
477            }
478        }
479
480        return $errors;
481    }
482
483    /**
484     * Non-blocking warnings to confirm before a transfer starts.
485     *
486     * The API groups warnings by type; they are flattened here because the page
487     * renders one list and a PHP map would localize as a JSON array once empty.
488     *
489     * @param array|null $eligibility Result of get_eligibility().
490     * @return array[] Warnings in the API's own shape, minus the grouping.
491     */
492    public static function get_transfer_warnings( $eligibility ) {
493        $warnings = array();
494
495        if ( ! empty( $eligibility['warnings'] ) && is_array( $eligibility['warnings'] ) ) {
496            foreach ( $eligibility['warnings'] as $group ) {
497                if ( ! is_array( $group ) ) {
498                    continue;
499                }
500
501                foreach ( $group as $warning ) {
502                    if ( empty( $warning['id'] ) ) {
503                        continue;
504                    }
505
506                    $warnings[] = array(
507                        'id'           => (string) $warning['id'],
508                        'description'  => isset( $warning['description'] ) ? (string) $warning['description'] : '',
509                        'domain_names' => ( ! empty( $warning['domain_names']['current'] ) && ! empty( $warning['domain_names']['new'] ) )
510                            ? array(
511                                'current' => (string) $warning['domain_names']['current'],
512                                'new'     => (string) $warning['domain_names']['new'],
513                            )
514                            : null,
515                        'support_url'  => isset( $warning['support_url'] ) ? (string) $warning['support_url'] : '',
516                    );
517                }
518            }
519        }
520
521        return $warnings;
522    }
523
524    /**
525     * Checkout, with the plan that includes backups already in the cart.
526     *
527     * `redirect_to` returns a buyer here to activate what they just bought, and
528     * `checkoutBackUrl` returns someone who backs out; without it checkout falls back to /plans.
529     *
530     * @param string $domain Site domain.
531     * @return string
532     */
533    public static function get_upgrade_url( $domain ) {
534        $page_url = rawurlencode( self::get_page_url() );
535
536        return add_query_arg(
537            array(
538                'redirect_to'     => $page_url,
539                'checkoutBackUrl' => $page_url,
540            ),
541            'https://wordpress.com/checkout/' . rawurlencode( (string) $domain ) . '/business'
542        );
543    }
544
545    /**
546     * This page's own address, for the round trips off to WordPress.com and back.
547     *
548     * @return string
549     */
550    public static function get_page_url() {
551        return admin_url( 'admin.php?page=' . self::MENU_SLUG );
552    }
553
554    /**
555     * Where this page will live once the transfer lands.
556     *
557     * Uses the new address from the address-change warning rather than trusting the old
558     * one to redirect, which may not have propagated when the flow sends the reader back.
559     *
560     * @param array[] $warnings Result of get_transfer_warnings().
561     * @return string
562     */
563    public static function get_post_transfer_page_url( array $warnings ) {
564        $page_url = self::get_page_url();
565
566        foreach ( $warnings as $warning ) {
567            $new_host = $warning['domain_names']['new'] ?? '';
568
569            // A bare hostname only, so nothing in the payload can steer the scheme or path.
570            if ( is_string( $new_host ) && filter_var( $new_host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME ) ) {
571                $parts = wp_parse_url( $page_url );
572
573                return 'https://' . strtolower( $new_host ) . ( $parts['path'] ?? '' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
574            }
575        }
576
577        return $page_url;
578    }
579
580    /**
581     * URL of the Calypso flow that transfers the site and switches backups on.
582     *
583     * The flow waits for the transfer to land before following `redirect_to`.
584     *
585     * @param array[] $warnings Result of get_transfer_warnings(), for the post-transfer address.
586     * @return string
587     */
588    public static function get_activate_url( array $warnings = array() ) {
589        return add_query_arg(
590            array(
591                'siteId'                    => get_current_blog_id(),
592                'initiate_transfer_context' => self::TRANSFER_CONTEXT,
593                'redirect_to'               => rawurlencode( self::get_post_transfer_page_url( $warnings ) ),
594            ),
595            self::TRANSFER_FLOW_URL
596        );
597    }
598}
599
600add_action( 'init', array( WPCOM_Backup::class, 'init' ) );