Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.60% covered (warning)
81.60%
102 / 125
70.00% covered (warning)
70.00%
14 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Backup
82.93% covered (warning)
82.93%
102 / 123
70.00% covered (warning)
70.00%
14 / 20
89.34
0.00% covered (danger)
0.00%
0 / 1
 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
 is_modernized
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filter_jetpack_backup_dashboard
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_page_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_menu_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_initial_state
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 has_backup_feature
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_atomic_feature
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 site_has_feature
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 is_transfer_in_progress
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
25.72
 get_latest_transfer_status
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
8.32
 get_eligibility
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
10.75
 get_state
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 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_support_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_upgrade_url
100.00% covered (success)
100.00%
8 / 8
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;
12
13require_once __DIR__ . '/../../common/class-wpcom-wp-build-page.php';
14
15/**
16 * Registers the Backup admin page and its wp-build assets.
17 */
18class WPCOM_Backup extends WPCOM_WP_Build_Page {
19
20    /**
21     * Admin page slug. Matches Jetpack_Backup::JETPACK_BACKUP_SLUG so the URL survives a
22     * transfer to WoA, where the Jetpack plugin takes the slug over once backups are live.
23     */
24    const MENU_SLUG = 'jetpack-backup';
25
26    /**
27     * Filter that switches Backup to wp-admin, shared with the Backup package's dashboard.
28     */
29    const MODERNIZATION_FILTER = 'rsm_jetpack_ui_modernization_backup';
30
31    /**
32     * Page name for wp-build, as declared in routes/wpcom-backup/package.json.
33     */
34    const WP_BUILD_PAGE = 'wpcom-backup';
35
36    /**
37     * Global the page's boot script reads its initial state from.
38     */
39    const INITIAL_STATE_VAR = 'wpcomBackupInitialState';
40
41    /**
42     * The page records its prompts in Tracks.
43     */
44    const ENQUEUE_TRACKS = true;
45
46    /**
47     * Calypso flow that initiates the transfer, reports progress and returns here.
48     */
49    const TRANSFER_FLOW_URL = 'https://wordpress.com/setup/transferring-hosted-site';
50
51    /**
52     * Tracks context recorded for the WoA transfer.
53     */
54    const TRANSFER_CONTEXT = 'jetpack_product_activation';
55
56    /**
57     * Site has no backup-capable plan. Offer an upgrade.
58     */
59    const STATE_UPGRADE = 'upgrade';
60
61    /**
62     * A transfer to WoA is already running or queued. Offer nothing; report status.
63     */
64    const STATE_IN_PROGRESS = 'in_progress';
65
66    /**
67     * Site has the plan and can transfer. Offer activation.
68     */
69    const STATE_ACTIVATE = 'activate';
70
71    /**
72     * Whether the site runs on WordPress.com's Atomic infrastructure.
73     *
74     * Read through Constants rather than `defined()` so tests can exercise both
75     * platforms in one process.
76     *
77     * @return bool
78     */
79    public static function is_atomic() {
80        return Constants::is_true( 'IS_ATOMIC' );
81    }
82
83    /**
84     * Whether this page should claim the Backup slug at all.
85     *
86     * A plan that includes backups only makes them live on Atomic infrastructure, so on
87     * WoA an entitled site already has working backups and this page steps aside. A Simple
88     * site is never Atomic, so the page always applies there.
89     *
90     * @return bool
91     */
92    public static function should_register() {
93        if ( ! self::is_atomic() ) {
94            return true;
95        }
96
97        return ! self::has_backup_feature();
98    }
99
100    /**
101     * Whether Backup opens in wp-admin rather than Calypso.
102     *
103     * Defaults on for Automatticians only.
104     *
105     * @return bool
106     */
107    public static function is_modernized() {
108        return (bool) apply_filters( self::MODERNIZATION_FILTER, Wpcom_Feature_Flags::is_a11n() );
109    }
110
111    /**
112     * Keep the Jetpack plugin's Backup dashboard off a WoA site whose plan lacks backups.
113     *
114     * Otherwise it claims the slug first and this page's upgrade prompt steps aside.
115     *
116     * @param bool $enabled Whether the Jetpack plugin offers its Backup dashboard.
117     * @return bool
118     */
119    public static function filter_jetpack_backup_dashboard( $enabled ) {
120        return $enabled && self::has_backup_feature();
121    }
122
123    /**
124     * The page title.
125     *
126     * @return string
127     */
128    public static function get_page_title() {
129        // Product name, do not translate.
130        return 'Jetpack VaultPress Backup';
131    }
132
133    /**
134     * The menu entry's label.
135     *
136     * @return string
137     */
138    public static function get_menu_title() {
139        // Product name, do not translate.
140        return 'Backup';
141    }
142
143    /**
144     * The resolved prompt and everything it links to.
145     *
146     * @return array
147     */
148    public static function get_initial_state() {
149        $blog_id = get_current_blog_id();
150        $domain  = wp_parse_url( home_url(), PHP_URL_HOST );
151        $state   = self::get_state( $blog_id );
152
153        // Only the activation prompt reads eligibility, and both lists come from the
154        // same result, so resolve it once and only for that state.
155        $eligibility     = self::STATE_ACTIVATE === $state
156            ? self::get_eligibility( $blog_id, get_current_user_id() )
157            : null;
158        $warnings        = self::get_transfer_warnings( $eligibility );
159        $transfer_status = self::STATE_IN_PROGRESS === $state ? null : self::get_latest_transfer_status( $blog_id );
160
161        return array(
162            'state'               => $state,
163            'domain'              => $domain,
164            // A null result means the library was unavailable, not that the site
165            // failed a check; let the transfer flow reject it instead.
166            'isEligible'          => null === $eligibility || ! empty( $eligibility['is_eligible'] ),
167            'errors'              => self::get_transfer_errors( $eligibility ),
168            'warnings'            => $warnings,
169            'upgradeUrl'          => self::get_upgrade_url( $domain ),
170            'activateUrl'         => self::get_activate_url( $warnings ),
171            'supportUrl'          => self::get_support_url(),
172            // Each applies only to the prompt Calypso pairs it with.
173            'hasRevertedTransfer' => self::STATE_UPGRADE === $state && 'reverted' === $transfer_status,
174            'transferFailed'      => self::STATE_ACTIVATE === $state && 'error' === $transfer_status,
175        );
176    }
177
178    /**
179     * Whether the site's plan includes self-serve backups.
180     *
181     * @return bool
182     */
183    public static function has_backup_feature() {
184        return self::site_has_feature( 'BACKUPS_SELF_SERVE' );
185    }
186
187    /**
188     * Whether the site's plan includes WordPress.com's Atomic hosting, which backups run on.
189     *
190     * @return bool
191     */
192    public static function has_atomic_feature() {
193        return self::site_has_feature( 'ATOMIC' );
194    }
195
196    /**
197     * Whether the site's plan includes a WordPress.com feature.
198     *
199     * @param string $name Name of a `WPCOM_Features` constant.
200     * @return bool
201     */
202    private static function site_has_feature( $name ) {
203        $constant = 'WPCOM_Features::' . $name;
204
205        if ( ! function_exists( 'wpcom_site_has_feature' ) || ! defined( $constant ) ) {
206            return false;
207        }
208
209        // Called without a blog ID.
210        // WoA sites would pass the local blog ID, which is not the WordPress.com blog ID.
211        // wpcom resolves the current site.
212        return (bool) wpcom_site_has_feature( constant( $constant ) );
213    }
214
215    /**
216     * Whether a transfer to WoA is already underway.
217     *
218     * @param int $blog_id Blog ID.
219     * @return bool
220     */
221    public static function is_transfer_in_progress( $blog_id ) {
222        if ( ! function_exists( 'require_lib' ) ) {
223            return false;
224        }
225
226        // These checks are only relevant for simple sites.
227        if ( self::is_atomic() ) {
228            return false;
229        }
230
231        require_lib( 'atomic' );
232
233        if ( function_exists( '\A8C\Atomic\has_site_pending_automated_transfer' ) ) {
234            if ( \A8C\Atomic\has_site_pending_automated_transfer( $blog_id ) ) {
235                return true;
236            }
237        }
238
239        if ( function_exists( '\A8C\Atomic\is_wpcom_atomic' ) ) {
240            // Second argument includes pending/active/provisioned transfers.
241            return (bool) \A8C\Atomic\is_wpcom_atomic( $blog_id, true );
242        }
243
244        return false;
245    }
246
247    /**
248     * Status of the site's latest transfer to WoA, or null when it has none.
249     *
250     * @param int $blog_id Blog ID.
251     * @return string|null A transfer record status, such as `reverted` or `error`.
252     */
253    public static function get_latest_transfer_status( $blog_id ) {
254        // These checks are only relevant for simple sites.
255        if ( self::is_atomic() ) {
256            return null;
257        }
258
259        if ( function_exists( 'require_lib' ) ) {
260            require_lib( 'atomic' );
261        }
262
263        if ( ! function_exists( 'woa_get_latest_transfer' ) ) {
264            return null;
265        }
266
267        $transfer = woa_get_latest_transfer( $blog_id );
268
269        if ( is_wp_error( $transfer ) || ! is_object( $transfer ) || ! isset( $transfer->status ) ) {
270            return null;
271        }
272
273        return (string) $transfer->status;
274    }
275
276    /**
277     * Transfer eligibility for the site, or null when it cannot be determined.
278     *
279     * An in-progress transfer is reported as a `transfer_already_exists` error, so
280     * that state is checked first and never reaches here as a blocker.
281     *
282     * @param int $blog_id Blog ID.
283     * @param int $user_id User ID.
284     * @return array|null { is_eligible: bool, errors: array, warnings: array }
285     */
286    public static function get_eligibility( $blog_id, $user_id ) {
287        if ( ! function_exists( 'require_lib' ) ) {
288            return null;
289        }
290
291        // These checks are only relevant for simple sites.
292        if ( self::is_atomic() ) {
293            return null;
294        }
295
296        require_lib( 'atomic' );
297
298        if ( ! function_exists( '\A8C\Atomic\Eligibility\get_status_for_site' ) ) {
299            return null;
300        }
301
302        return \A8C\Atomic\Eligibility\get_status_for_site( $blog_id, $user_id );
303    }
304
305    /**
306     * Resolve which prompt the page should show.
307     *
308     * @param int $blog_id Blog ID.
309     * @return string One of the STATE_* constants.
310     */
311    public static function get_state( $blog_id ) {
312        // On WoA the plan is the only question: the site is already on the
313        // infrastructure that runs backups, so there is nothing to transfer, and
314        // should_register() has already stepped the page aside if it has the plan.
315        // On Simple, activation also needs the hosting platform, as Calypso requires.
316        if ( ! self::has_backup_feature() || ! self::has_atomic_feature() || self::is_atomic() ) {
317            $state = self::STATE_UPGRADE;
318        } elseif ( self::is_transfer_in_progress( $blog_id ) ) {
319            $state = self::STATE_IN_PROGRESS;
320        } else {
321            // Eligibility failures are explained by the activation prompt's own
322            // confirmation step, so they do not get a state of their own.
323            $state = self::STATE_ACTIVATE;
324        }
325
326        return $state;
327    }
328
329    /**
330     * Reasons a site cannot be transferred, in the shape the API returns them.
331     *
332     * The code is kept because the page maps it to its own copy; the API's message comes
333     * along as the fallback the page renders for codes it does not recognize.
334     *
335     * @param array|null $eligibility Result of get_eligibility().
336     * @return array[] Errors as { code, message }.
337     */
338    public static function get_transfer_errors( $eligibility ) {
339        $errors = array();
340
341        if ( ! empty( $eligibility['errors'] ) && is_array( $eligibility['errors'] ) ) {
342            foreach ( $eligibility['errors'] as $error ) {
343                if ( empty( $error['code'] ) ) {
344                    continue;
345                }
346
347                $errors[] = array(
348                    'code'    => (string) $error['code'],
349                    'message' => isset( $error['message'] ) ? (string) $error['message'] : '',
350                );
351            }
352        }
353
354        return $errors;
355    }
356
357    /**
358     * Non-blocking warnings to confirm before a transfer starts.
359     *
360     * The API groups warnings by type; they are flattened here because the page
361     * renders one list and a PHP map would localize as a JSON array once empty.
362     *
363     * @param array|null $eligibility Result of get_eligibility().
364     * @return array[] Warnings in the API's own shape, minus the grouping.
365     */
366    public static function get_transfer_warnings( $eligibility ) {
367        $warnings = array();
368
369        if ( ! empty( $eligibility['warnings'] ) && is_array( $eligibility['warnings'] ) ) {
370            foreach ( $eligibility['warnings'] as $group ) {
371                if ( ! is_array( $group ) ) {
372                    continue;
373                }
374
375                foreach ( $group as $warning ) {
376                    if ( empty( $warning['id'] ) ) {
377                        continue;
378                    }
379
380                    $warnings[] = array(
381                        'id'           => (string) $warning['id'],
382                        'description'  => isset( $warning['description'] ) ? (string) $warning['description'] : '',
383                        'domain_names' => ( ! empty( $warning['domain_names']['current'] ) && ! empty( $warning['domain_names']['new'] ) )
384                            ? array(
385                                'current' => (string) $warning['domain_names']['current'],
386                                'new'     => (string) $warning['domain_names']['new'],
387                            )
388                            : null,
389                        // Rendered as a link, so only a web URL gets through.
390                        'support_url'  => isset( $warning['support_url'] ) ? esc_url_raw( (string) $warning['support_url'], array( 'http', 'https' ) ) : '',
391                    );
392                }
393            }
394        }
395
396        return $warnings;
397    }
398
399    /**
400     * Support's contact page, in the reader's language where WordPress.com has one.
401     *
402     * @return string
403     */
404    public static function get_support_url() {
405        $url = 'https://wordpress.com/help/contact';
406
407        return function_exists( 'localized_wpcom_url' ) ? localized_wpcom_url( $url ) : $url;
408    }
409
410    /**
411     * Checkout, with the plan that includes backups already in the cart.
412     *
413     * `redirect_to` returns a buyer here to activate what they just bought, and
414     * `checkoutBackUrl` returns someone who backs out; without it checkout falls back to /plans.
415     *
416     * @param string $domain Site domain.
417     * @return string
418     */
419    public static function get_upgrade_url( $domain ) {
420        $page_url = rawurlencode( self::get_page_url() );
421
422        return add_query_arg(
423            array(
424                'redirect_to'     => $page_url,
425                'checkoutBackUrl' => $page_url,
426            ),
427            'https://wordpress.com/checkout/' . rawurlencode( (string) $domain ) . '/business'
428        );
429    }
430
431    /**
432     * Where this page will live once the transfer lands.
433     *
434     * Uses the new address from the address-change warning rather than trusting the old
435     * one to redirect, which may not have propagated when the flow sends the reader back.
436     *
437     * @param array[] $warnings Result of get_transfer_warnings().
438     * @return string
439     */
440    public static function get_post_transfer_page_url( array $warnings ) {
441        $page_url = self::get_page_url();
442
443        foreach ( $warnings as $warning ) {
444            $new_host = $warning['domain_names']['new'] ?? '';
445
446            // A bare hostname only, so nothing in the payload can steer the scheme or path.
447            if ( is_string( $new_host ) && filter_var( $new_host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME ) ) {
448                $parts = wp_parse_url( $page_url );
449
450                return 'https://' . strtolower( $new_host ) . ( $parts['path'] ?? '' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
451            }
452        }
453
454        return $page_url;
455    }
456
457    /**
458     * URL of the Calypso flow that transfers the site and switches backups on.
459     *
460     * The flow waits for the transfer to land before following `redirect_to`.
461     *
462     * @param array[] $warnings Result of get_transfer_warnings(), for the post-transfer address.
463     * @return string
464     */
465    public static function get_activate_url( array $warnings = array() ) {
466        return add_query_arg(
467            array(
468                'siteId'                    => get_current_blog_id(),
469                'initiate_transfer_context' => self::TRANSFER_CONTEXT,
470                'redirect_to'               => rawurlencode( self::get_post_transfer_page_url( $warnings ) ),
471            ),
472            self::TRANSFER_FLOW_URL
473        );
474    }
475}
476
477add_action( 'init', array( WPCOM_Backup::class, 'init' ) );