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