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