Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.90% covered (warning)
78.90%
86 / 109
66.67% covered (warning)
66.67%
12 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Backup
80.37% covered (warning)
80.37%
86 / 107
66.67% covered (warning)
66.67%
12 / 18
77.87
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
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 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_eligibility
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 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_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
160        return array(
161            'state'       => $state,
162            'domain'      => $domain,
163            // A null result means the library was unavailable, not that the site
164            // failed a check; let the transfer flow reject it instead.
165            'isEligible'  => null === $eligibility || ! empty( $eligibility['is_eligible'] ),
166            'errors'      => self::get_transfer_errors( $eligibility ),
167            'warnings'    => $warnings,
168            'upgradeUrl'  => self::get_upgrade_url( $domain ),
169            'activateUrl' => self::get_activate_url( $warnings ),
170        );
171    }
172
173    /**
174     * Whether the site's plan includes self-serve backups.
175     *
176     * @return bool
177     */
178    public static function has_backup_feature() {
179        return self::site_has_feature( 'BACKUPS_SELF_SERVE' );
180    }
181
182    /**
183     * Whether the site's plan includes WordPress.com's Atomic hosting, which backups run on.
184     *
185     * @return bool
186     */
187    public static function has_atomic_feature() {
188        return self::site_has_feature( 'ATOMIC' );
189    }
190
191    /**
192     * Whether the site's plan includes a WordPress.com feature.
193     *
194     * @param string $name Name of a `WPCOM_Features` constant.
195     * @return bool
196     */
197    private static function site_has_feature( $name ) {
198        $constant = 'WPCOM_Features::' . $name;
199
200        if ( ! function_exists( 'wpcom_site_has_feature' ) || ! defined( $constant ) ) {
201            return false;
202        }
203
204        // Called without a blog ID.
205        // WoA sites would pass the local blog ID, which is not the WordPress.com blog ID.
206        // wpcom resolves the current site.
207        return (bool) wpcom_site_has_feature( constant( $constant ) );
208    }
209
210    /**
211     * Whether a transfer to WoA is already underway.
212     *
213     * @param int $blog_id Blog ID.
214     * @return bool
215     */
216    public static function is_transfer_in_progress( $blog_id ) {
217        if ( ! function_exists( 'require_lib' ) ) {
218            return false;
219        }
220
221        // These checks are only relevant for simple sites.
222        if ( self::is_atomic() ) {
223            return false;
224        }
225
226        require_lib( 'atomic' );
227
228        if ( function_exists( '\A8C\Atomic\has_site_pending_automated_transfer' ) ) {
229            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
230            if ( \A8C\Atomic\has_site_pending_automated_transfer( $blog_id ) ) {
231                return true;
232            }
233        }
234
235        if ( function_exists( '\A8C\Atomic\is_wpcom_atomic' ) ) {
236            // Second argument includes pending/active/provisioned transfers.
237            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
238            return (bool) \A8C\Atomic\is_wpcom_atomic( $blog_id, true );
239        }
240
241        return false;
242    }
243
244    /**
245     * Transfer eligibility for the site, or null when it cannot be determined.
246     *
247     * An in-progress transfer is reported as a `transfer_already_exists` error, so
248     * that state is checked first and never reaches here as a blocker.
249     *
250     * @param int $blog_id Blog ID.
251     * @param int $user_id User ID.
252     * @return array|null { is_eligible: bool, errors: array, warnings: array }
253     */
254    public static function get_eligibility( $blog_id, $user_id ) {
255        if ( ! function_exists( 'require_lib' ) ) {
256            return null;
257        }
258
259        // These checks are only relevant for simple sites.
260        if ( self::is_atomic() ) {
261            return null;
262        }
263
264        require_lib( 'atomic' );
265
266        if ( ! function_exists( '\A8C\Atomic\Eligibility\get_status_for_site' ) ) {
267            return null;
268        }
269
270        // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
271        return \A8C\Atomic\Eligibility\get_status_for_site( $blog_id, $user_id );
272    }
273
274    /**
275     * Resolve which prompt the page should show.
276     *
277     * @param int $blog_id Blog ID.
278     * @return string One of the STATE_* constants.
279     */
280    public static function get_state( $blog_id ) {
281        // On WoA the plan is the only question: the site is already on the
282        // infrastructure that runs backups, so there is nothing to transfer, and
283        // should_register() has already stepped the page aside if it has the plan.
284        // On Simple, activation also needs the hosting platform, as Calypso requires.
285        if ( ! self::has_backup_feature() || ! self::has_atomic_feature() || self::is_atomic() ) {
286            $state = self::STATE_UPGRADE;
287        } elseif ( self::is_transfer_in_progress( $blog_id ) ) {
288            $state = self::STATE_IN_PROGRESS;
289        } else {
290            // Eligibility failures are explained by the activation prompt's own
291            // confirmation step, so they do not get a state of their own.
292            $state = self::STATE_ACTIVATE;
293        }
294
295        return $state;
296    }
297
298    /**
299     * Reasons a site cannot be transferred, in the shape the API returns them.
300     *
301     * The code is kept because the page maps it to its own copy; the API's message comes
302     * along as the fallback the page renders for codes it does not recognize.
303     *
304     * @param array|null $eligibility Result of get_eligibility().
305     * @return array[] Errors as { code, message }.
306     */
307    public static function get_transfer_errors( $eligibility ) {
308        $errors = array();
309
310        if ( ! empty( $eligibility['errors'] ) && is_array( $eligibility['errors'] ) ) {
311            foreach ( $eligibility['errors'] as $error ) {
312                if ( empty( $error['code'] ) ) {
313                    continue;
314                }
315
316                $errors[] = array(
317                    'code'    => (string) $error['code'],
318                    'message' => isset( $error['message'] ) ? (string) $error['message'] : '',
319                );
320            }
321        }
322
323        return $errors;
324    }
325
326    /**
327     * Non-blocking warnings to confirm before a transfer starts.
328     *
329     * The API groups warnings by type; they are flattened here because the page
330     * renders one list and a PHP map would localize as a JSON array once empty.
331     *
332     * @param array|null $eligibility Result of get_eligibility().
333     * @return array[] Warnings in the API's own shape, minus the grouping.
334     */
335    public static function get_transfer_warnings( $eligibility ) {
336        $warnings = array();
337
338        if ( ! empty( $eligibility['warnings'] ) && is_array( $eligibility['warnings'] ) ) {
339            foreach ( $eligibility['warnings'] as $group ) {
340                if ( ! is_array( $group ) ) {
341                    continue;
342                }
343
344                foreach ( $group as $warning ) {
345                    if ( empty( $warning['id'] ) ) {
346                        continue;
347                    }
348
349                    $warnings[] = array(
350                        'id'           => (string) $warning['id'],
351                        'description'  => isset( $warning['description'] ) ? (string) $warning['description'] : '',
352                        'domain_names' => ( ! empty( $warning['domain_names']['current'] ) && ! empty( $warning['domain_names']['new'] ) )
353                            ? array(
354                                'current' => (string) $warning['domain_names']['current'],
355                                'new'     => (string) $warning['domain_names']['new'],
356                            )
357                            : null,
358                        // Rendered as a link, so only a web URL gets through.
359                        'support_url'  => isset( $warning['support_url'] ) ? esc_url_raw( (string) $warning['support_url'], array( 'http', 'https' ) ) : '',
360                    );
361                }
362            }
363        }
364
365        return $warnings;
366    }
367
368    /**
369     * Checkout, with the plan that includes backups already in the cart.
370     *
371     * `redirect_to` returns a buyer here to activate what they just bought, and
372     * `checkoutBackUrl` returns someone who backs out; without it checkout falls back to /plans.
373     *
374     * @param string $domain Site domain.
375     * @return string
376     */
377    public static function get_upgrade_url( $domain ) {
378        $page_url = rawurlencode( self::get_page_url() );
379
380        return add_query_arg(
381            array(
382                'redirect_to'     => $page_url,
383                'checkoutBackUrl' => $page_url,
384            ),
385            'https://wordpress.com/checkout/' . rawurlencode( (string) $domain ) . '/business'
386        );
387    }
388
389    /**
390     * Where this page will live once the transfer lands.
391     *
392     * Uses the new address from the address-change warning rather than trusting the old
393     * one to redirect, which may not have propagated when the flow sends the reader back.
394     *
395     * @param array[] $warnings Result of get_transfer_warnings().
396     * @return string
397     */
398    public static function get_post_transfer_page_url( array $warnings ) {
399        $page_url = self::get_page_url();
400
401        foreach ( $warnings as $warning ) {
402            $new_host = $warning['domain_names']['new'] ?? '';
403
404            // A bare hostname only, so nothing in the payload can steer the scheme or path.
405            if ( is_string( $new_host ) && filter_var( $new_host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME ) ) {
406                $parts = wp_parse_url( $page_url );
407
408                return 'https://' . strtolower( $new_host ) . ( $parts['path'] ?? '' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
409            }
410        }
411
412        return $page_url;
413    }
414
415    /**
416     * URL of the Calypso flow that transfers the site and switches backups on.
417     *
418     * The flow waits for the transfer to land before following `redirect_to`.
419     *
420     * @param array[] $warnings Result of get_transfer_warnings(), for the post-transfer address.
421     * @return string
422     */
423    public static function get_activate_url( array $warnings = array() ) {
424        return add_query_arg(
425            array(
426                'siteId'                    => get_current_blog_id(),
427                'initiate_transfer_context' => self::TRANSFER_CONTEXT,
428                'redirect_to'               => rawurlencode( self::get_post_transfer_page_url( $warnings ) ),
429            ),
430            self::TRANSFER_FLOW_URL
431        );
432    }
433}
434
435add_action( 'init', array( WPCOM_Backup::class, 'init' ) );