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            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
235            if ( \A8C\Atomic\has_site_pending_automated_transfer( $blog_id ) ) {
236                return true;
237            }
238        }
239
240        if ( function_exists( '\A8C\Atomic\is_wpcom_atomic' ) ) {
241            // Second argument includes pending/active/provisioned transfers.
242            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
243            return (bool) \A8C\Atomic\is_wpcom_atomic( $blog_id, true );
244        }
245
246        return false;
247    }
248
249    /**
250     * Status of the site's latest transfer to WoA, or null when it has none.
251     *
252     * @param int $blog_id Blog ID.
253     * @return string|null A transfer record status, such as `reverted` or `error`.
254     */
255    public static function get_latest_transfer_status( $blog_id ) {
256        // These checks are only relevant for simple sites.
257        if ( self::is_atomic() ) {
258            return null;
259        }
260
261        if ( function_exists( 'require_lib' ) ) {
262            require_lib( 'atomic' );
263        }
264
265        if ( ! function_exists( 'woa_get_latest_transfer' ) ) {
266            return null;
267        }
268
269        // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
270        $transfer = woa_get_latest_transfer( $blog_id );
271
272        if ( is_wp_error( $transfer ) || ! is_object( $transfer ) || ! isset( $transfer->status ) ) {
273            return null;
274        }
275
276        return (string) $transfer->status;
277    }
278
279    /**
280     * Transfer eligibility for the site, or null when it cannot be determined.
281     *
282     * An in-progress transfer is reported as a `transfer_already_exists` error, so
283     * that state is checked first and never reaches here as a blocker.
284     *
285     * @param int $blog_id Blog ID.
286     * @param int $user_id User ID.
287     * @return array|null { is_eligible: bool, errors: array, warnings: array }
288     */
289    public static function get_eligibility( $blog_id, $user_id ) {
290        if ( ! function_exists( 'require_lib' ) ) {
291            return null;
292        }
293
294        // These checks are only relevant for simple sites.
295        if ( self::is_atomic() ) {
296            return null;
297        }
298
299        require_lib( 'atomic' );
300
301        if ( ! function_exists( '\A8C\Atomic\Eligibility\get_status_for_site' ) ) {
302            return null;
303        }
304
305        // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
306        return \A8C\Atomic\Eligibility\get_status_for_site( $blog_id, $user_id );
307    }
308
309    /**
310     * Resolve which prompt the page should show.
311     *
312     * @param int $blog_id Blog ID.
313     * @return string One of the STATE_* constants.
314     */
315    public static function get_state( $blog_id ) {
316        // On WoA the plan is the only question: the site is already on the
317        // infrastructure that runs backups, so there is nothing to transfer, and
318        // should_register() has already stepped the page aside if it has the plan.
319        // On Simple, activation also needs the hosting platform, as Calypso requires.
320        if ( ! self::has_backup_feature() || ! self::has_atomic_feature() || self::is_atomic() ) {
321            $state = self::STATE_UPGRADE;
322        } elseif ( self::is_transfer_in_progress( $blog_id ) ) {
323            $state = self::STATE_IN_PROGRESS;
324        } else {
325            // Eligibility failures are explained by the activation prompt's own
326            // confirmation step, so they do not get a state of their own.
327            $state = self::STATE_ACTIVATE;
328        }
329
330        return $state;
331    }
332
333    /**
334     * Reasons a site cannot be transferred, in the shape the API returns them.
335     *
336     * The code is kept because the page maps it to its own copy; the API's message comes
337     * along as the fallback the page renders for codes it does not recognize.
338     *
339     * @param array|null $eligibility Result of get_eligibility().
340     * @return array[] Errors as { code, message }.
341     */
342    public static function get_transfer_errors( $eligibility ) {
343        $errors = array();
344
345        if ( ! empty( $eligibility['errors'] ) && is_array( $eligibility['errors'] ) ) {
346            foreach ( $eligibility['errors'] as $error ) {
347                if ( empty( $error['code'] ) ) {
348                    continue;
349                }
350
351                $errors[] = array(
352                    'code'    => (string) $error['code'],
353                    'message' => isset( $error['message'] ) ? (string) $error['message'] : '',
354                );
355            }
356        }
357
358        return $errors;
359    }
360
361    /**
362     * Non-blocking warnings to confirm before a transfer starts.
363     *
364     * The API groups warnings by type; they are flattened here because the page
365     * renders one list and a PHP map would localize as a JSON array once empty.
366     *
367     * @param array|null $eligibility Result of get_eligibility().
368     * @return array[] Warnings in the API's own shape, minus the grouping.
369     */
370    public static function get_transfer_warnings( $eligibility ) {
371        $warnings = array();
372
373        if ( ! empty( $eligibility['warnings'] ) && is_array( $eligibility['warnings'] ) ) {
374            foreach ( $eligibility['warnings'] as $group ) {
375                if ( ! is_array( $group ) ) {
376                    continue;
377                }
378
379                foreach ( $group as $warning ) {
380                    if ( empty( $warning['id'] ) ) {
381                        continue;
382                    }
383
384                    $warnings[] = array(
385                        'id'           => (string) $warning['id'],
386                        'description'  => isset( $warning['description'] ) ? (string) $warning['description'] : '',
387                        'domain_names' => ( ! empty( $warning['domain_names']['current'] ) && ! empty( $warning['domain_names']['new'] ) )
388                            ? array(
389                                'current' => (string) $warning['domain_names']['current'],
390                                'new'     => (string) $warning['domain_names']['new'],
391                            )
392                            : null,
393                        // Rendered as a link, so only a web URL gets through.
394                        'support_url'  => isset( $warning['support_url'] ) ? esc_url_raw( (string) $warning['support_url'], array( 'http', 'https' ) ) : '',
395                    );
396                }
397            }
398        }
399
400        return $warnings;
401    }
402
403    /**
404     * Support's contact page, in the reader's language where WordPress.com has one.
405     *
406     * @return string
407     */
408    public static function get_support_url() {
409        $url = 'https://wordpress.com/help/contact';
410
411        return function_exists( 'localized_wpcom_url' ) ? localized_wpcom_url( $url ) : $url;
412    }
413
414    /**
415     * Checkout, with the plan that includes backups already in the cart.
416     *
417     * `redirect_to` returns a buyer here to activate what they just bought, and
418     * `checkoutBackUrl` returns someone who backs out; without it checkout falls back to /plans.
419     *
420     * @param string $domain Site domain.
421     * @return string
422     */
423    public static function get_upgrade_url( $domain ) {
424        $page_url = rawurlencode( self::get_page_url() );
425
426        return add_query_arg(
427            array(
428                'redirect_to'     => $page_url,
429                'checkoutBackUrl' => $page_url,
430            ),
431            'https://wordpress.com/checkout/' . rawurlencode( (string) $domain ) . '/business'
432        );
433    }
434
435    /**
436     * Where this page will live once the transfer lands.
437     *
438     * Uses the new address from the address-change warning rather than trusting the old
439     * one to redirect, which may not have propagated when the flow sends the reader back.
440     *
441     * @param array[] $warnings Result of get_transfer_warnings().
442     * @return string
443     */
444    public static function get_post_transfer_page_url( array $warnings ) {
445        $page_url = self::get_page_url();
446
447        foreach ( $warnings as $warning ) {
448            $new_host = $warning['domain_names']['new'] ?? '';
449
450            // A bare hostname only, so nothing in the payload can steer the scheme or path.
451            if ( is_string( $new_host ) && filter_var( $new_host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME ) ) {
452                $parts = wp_parse_url( $page_url );
453
454                return 'https://' . strtolower( $new_host ) . ( $parts['path'] ?? '' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
455            }
456        }
457
458        return $page_url;
459    }
460
461    /**
462     * URL of the Calypso flow that transfers the site and switches backups on.
463     *
464     * The flow waits for the transfer to land before following `redirect_to`.
465     *
466     * @param array[] $warnings Result of get_transfer_warnings(), for the post-transfer address.
467     * @return string
468     */
469    public static function get_activate_url( array $warnings = array() ) {
470        return add_query_arg(
471            array(
472                'siteId'                    => get_current_blog_id(),
473                'initiate_transfer_context' => self::TRANSFER_CONTEXT,
474                'redirect_to'               => rawurlencode( self::get_post_transfer_page_url( $warnings ) ),
475            ),
476            self::TRANSFER_FLOW_URL
477        );
478    }
479}
480
481add_action( 'init', array( WPCOM_Backup::class, 'init' ) );