Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.09% covered (warning)
84.09%
148 / 176
56.00% covered (warning)
56.00%
14 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Hosting_Feature_Page
84.09% covered (warning)
84.09%
148 / 176
56.00% covered (warning)
56.00%
14 / 25
117.48
0.00% covered (danger)
0.00%
0 / 1
 has_feature
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 init
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 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_page_request
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
4.59
 register_page
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 hide_menu_entry
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 owns_page
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 slug_is_claimed
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
7.23
 load_wp_build
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 alias_screen_id
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 restore_screen_id
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 enqueue_initial_state
100.00% covered (success)
100.00%
27 / 27
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_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
4
 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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 get_page_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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;
12use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
13
14/**
15 * Registers a hosting feature's admin page and its wp-build assets.
16 *
17 * Each page subclasses this, overriding the page constants.
18 */
19abstract class WPCOM_Hosting_Feature_Page {
20
21    /**
22     * Admin page slug. Matches the page the Jetpack plugin serves once the feature is live on
23     * WoA, so the URL survives a transfer.
24     */
25    const MENU_SLUG = '';
26
27    /**
28     * Page name for wp-build, as declared in routes/<page>/package.json.
29     *
30     * Deliberately not the menu slug: wp-build derives its own page slug and render
31     * function from this name.
32     */
33    const WP_BUILD_PAGE = '';
34
35    /**
36     * Render callback generated by wp-build in build/pages/<page>/page-wp-admin.php.
37     */
38    const RENDER_CALLBACK = '';
39
40    /**
41     * Document title of the page. A product name, so not translated.
42     */
43    const PAGE_TITLE = '';
44
45    /**
46     * Menu entry label. A product name, so not translated.
47     */
48    const MENU_TITLE = '';
49
50    /**
51     * Name of the `WPCOM_Features` constant the site's plan must include.
52     */
53    const FEATURE = '';
54
55    /**
56     * `admin_menu` priority for register_page().
57     */
58    const REGISTER_PRIORITY = 999998;
59
60    /**
61     * Calypso flow that initiates the transfer, reports progress and returns here.
62     */
63    const TRANSFER_FLOW_URL = 'https://wordpress.com/setup/transferring-hosted-site';
64
65    /**
66     * Tracks context recorded for the WoA transfer.
67     */
68    const TRANSFER_CONTEXT = 'jetpack_product_activation';
69
70    /**
71     * Global the page's boot script reads its state from.
72     */
73    const INITIAL_STATE_VAR = 'wpcomHostingFeatureInitialState';
74
75    /**
76     * Site has no plan that includes the feature. Offer an upgrade.
77     */
78    const STATE_UPGRADE = 'upgrade';
79
80    /**
81     * A transfer to WoA is already running or queued. Offer nothing; report status.
82     */
83    const STATE_IN_PROGRESS = 'in_progress';
84
85    /**
86     * Site has the plan and can transfer. Offer activation.
87     */
88    const STATE_ACTIVATE = 'activate';
89
90    /**
91     * Whether each page registered its slug on this request, keyed by class.
92     *
93     * @var bool[]
94     */
95    private static $owns_page = array();
96
97    /**
98     * The screen ID displaced by alias_screen_id(), keyed by class.
99     *
100     * @var string[]
101     */
102    private static $displaced_screen_id = array();
103
104    /**
105     * Whether the site's plan includes FEATURE. Without one it offers the upgrade, the one
106     * prompt that is never wrong for a site of unknown plan.
107     *
108     * @return bool
109     */
110    public static function has_feature() {
111        $feature = '\WPCOM_Features::' . static::FEATURE;
112
113        if ( '' === static::FEATURE || ! function_exists( 'wpcom_site_has_feature' ) || ! defined( $feature ) ) {
114            return false;
115        }
116
117        // Called without a blog ID: on WoA the local ID is not the WordPress.com one, so wpcom resolves the current site.
118        return (bool) wpcom_site_has_feature( constant( $feature ) );
119    }
120
121    /**
122     * Boot the page, loading the wp-build assets only on the page itself.
123     *
124     * @return void
125     */
126    public static function init() {
127        if ( ! static::should_register() ) {
128            return;
129        }
130
131        // After Admin_Menu (1000) so a claimed slug is visible, and before wpcom_add_jetpack_submenu()
132        // (999999), which hides the entry from the sidebar.
133        add_action( 'admin_menu', array( static::class, 'register_page' ), self::REGISTER_PRIORITY );
134
135        if ( ! static::is_page_request() ) {
136            return;
137        }
138
139        self::load_wp_build();
140
141        // wp-build's generated enqueue runs at the default priority 10; the alias is
142        // scoped to that one pass so nothing else sees the rewritten screen.
143        add_action( 'admin_enqueue_scripts', array( static::class, 'alias_screen_id' ), 9 );
144        add_action( 'admin_enqueue_scripts', array( static::class, 'restore_screen_id' ), 11 );
145        add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_initial_state' ), 20 );
146    }
147
148    /**
149     * Whether the site runs on WordPress.com's Atomic infrastructure.
150     *
151     * Read through Constants rather than `defined()` so tests can exercise both
152     * platforms in one process.
153     *
154     * @return bool
155     */
156    public static function is_atomic() {
157        return Constants::is_true( 'IS_ATOMIC' );
158    }
159
160    /**
161     * Whether this page should claim its slug at all.
162     *
163     * A plan that includes the feature only makes it live on Atomic infrastructure, so on
164     * WoA an entitled site already has it and this page steps aside. A Simple site is never
165     * Atomic, so the page always applies there.
166     *
167     * @return bool
168     */
169    public static function should_register() {
170        if ( ! self::is_atomic() ) {
171            return true;
172        }
173
174        return ! static::has_feature();
175    }
176
177    /**
178     * Whether the current request targets this admin page.
179     *
180     * @return bool
181     */
182    public static function is_page_request() {
183        // admin-ajax.php is also `is_admin()`, and has no menu, screen or enqueue pass to set up.
184        if ( ! is_admin() || 'admin.php' !== ( $GLOBALS['pagenow'] ?? '' ) ) {
185            return false;
186        }
187
188        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing check; no form data is processed.
189        return isset( $_GET['page'] ) && static::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
190    }
191
192    /**
193     * Register the page, unless something else already serves the slug.
194     *
195     * @return void
196     */
197    public static function register_page() {
198        // A second entry under a slug someone else owns would leave two.
199        if ( static::slug_is_claimed() ) {
200            return;
201        }
202
203        self::$owns_page[ static::class ] = true;
204
205        // build/build.php defines the render callback but only loads on this page's
206        // own request, so elsewhere fall back rather than fataling.
207        $callback = function_exists( static::RENDER_CALLBACK )
208            ? static::RENDER_CALLBACK
209            : '__return_empty_string';
210
211        add_submenu_page(
212            'jetpack',
213            static::PAGE_TITLE,
214            static::MENU_TITLE,
215            'manage_options',
216            static::MENU_SLUG,
217            $callback
218        );
219
220        self::hide_menu_entry();
221    }
222
223    /**
224     * Keep this page's entry out of the sidebar, since Calypso owns the nav; hidden rather
225     * than removed, so the page stays reachable.
226     *
227     * Done here rather than in wpcom-admin-menu.php, which not every admin loads.
228     *
229     * @return void
230     */
231    private static function hide_menu_entry() {
232        global $submenu;
233
234        foreach ( $submenu['jetpack'] ?? array() as $i => $item ) {
235            if ( static::MENU_SLUG !== ( $item[2] ?? null ) || str_contains( $item[4] ?? '', 'hide-if-js' ) ) {
236                continue;
237            }
238
239            // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
240            $submenu['jetpack'][ $i ][4] = trim( ( $item[4] ?? '' ) . ' hide-if-js' );
241        }
242    }
243
244    /**
245     * Whether this page registered its slug on this request.
246     *
247     * @return bool
248     */
249    public static function owns_page() {
250        return ! empty( self::$owns_page[ static::class ] );
251    }
252
253    /**
254     * Reset the registration state. Test seam; production sets this once per request.
255     *
256     * @return void
257     */
258    public static function reset() {
259        unset( self::$owns_page[ static::class ], self::$displaced_screen_id[ static::class ] );
260    }
261
262    /**
263     * Whether something has already registered this page's slug.
264     *
265     * Jetpack plugins serve their pages through `Admin_Menu` at `admin_menu` priority
266     * 1000 â€” before this page's own hook â€” so the entry is there to find.
267     *
268     * @return bool
269     */
270    public static function slug_is_claimed() {
271        global $submenu;
272
273        if ( empty( $submenu['jetpack'] ) || ! is_array( $submenu['jetpack'] ) ) {
274            return false;
275        }
276
277        foreach ( $submenu['jetpack'] as $item ) {
278            if ( is_array( $item ) && isset( $item[2] ) && static::MENU_SLUG === $item[2] ) {
279                return true;
280            }
281        }
282
283        return false;
284    }
285
286    /**
287     * Load the wp-build generated asset registrations and the boot polyfills.
288     *
289     * `build/` is produced at release time, so an unbuilt checkout has none; bail
290     * quietly, since register_page() already falls back to an empty render.
291     *
292     * @return void
293     */
294    public static function load_wp_build() {
295        $wp_build_index = dirname( __DIR__, 3 ) . '/build/build.php';
296
297        if ( ! file_exists( $wp_build_index ) ) {
298            return;
299        }
300
301        require_once $wp_build_index;
302
303        // Register polyfills for WP < 7.0 (must run before enqueue).
304        WP_Build_Polyfills::register(
305            'jetpack-mu-wpcom',
306            array_merge(
307                WP_Build_Polyfills::SCRIPT_HANDLES,
308                WP_Build_Polyfills::MODULE_IDS
309            )
310        );
311    }
312
313    /**
314     * Make wp-build's enqueue check pass without changing the page URL.
315     *
316     * Its enqueue callback needs a screen ID of `<page>`, which the menu slug does not
317     * produce. Paired with restore_screen_id() so the rewritten ID never outlives the
318     * enqueue pass â€” `$screen->base`, the body class and the page-view tracker all keep
319     * reading the real one.
320     *
321     * @return void
322     */
323    public static function alias_screen_id() {
324        $screen = get_current_screen();
325
326        if ( ! static::owns_page() || ! is_object( $screen ) ) {
327            return;
328        }
329
330        self::$displaced_screen_id[ static::class ] = $screen->id;
331        $screen->id                                 = static::WP_BUILD_PAGE;
332    }
333
334    /**
335     * Put back the screen ID displaced by alias_screen_id().
336     *
337     * @return void
338     */
339    public static function restore_screen_id() {
340        $screen = get_current_screen();
341
342        if ( ! isset( self::$displaced_screen_id[ static::class ] ) || ! is_object( $screen ) ) {
343            return;
344        }
345
346        $screen->id = self::$displaced_screen_id[ static::class ];
347        unset( self::$displaced_screen_id[ static::class ] );
348    }
349
350    /**
351     * Hand the resolved state to the page's boot script.
352     *
353     * @return void
354     */
355    public static function enqueue_initial_state() {
356        $handle = static::WP_BUILD_PAGE . '-wp-admin-prerequisites';
357
358        if ( ! static::owns_page() || ! wp_script_is( $handle, 'registered' ) ) {
359            return;
360        }
361
362        // The menu's own capability gate keeps unauthorized users from reaching this hook at
363        // all; restated here because get_status_for_site() is called directly rather than
364        // through the wpcom endpoint that gates it.
365        if ( ! current_user_can( 'manage_options' ) ) {
366            return;
367        }
368
369        $blog_id = get_current_blog_id();
370        $user_id = get_current_user_id();
371        $domain  = wp_parse_url( home_url(), PHP_URL_HOST );
372        $state   = static::get_state( $blog_id );
373
374        // Only the activation prompt reads eligibility, and both lists come from the
375        // same result, so resolve it once and only for that state.
376        $eligibility = self::STATE_ACTIVATE === $state
377            ? self::get_eligibility( $blog_id, $user_id )
378            : null;
379        $warnings    = self::get_transfer_warnings( $eligibility );
380
381        Common\wpcom_enqueue_tracking_scripts( $handle );
382
383        wp_localize_script(
384            $handle,
385            self::INITIAL_STATE_VAR,
386            array(
387                'state'       => $state,
388                'domain'      => $domain,
389                // A null result means the library was unavailable, not that the site
390                // failed a check; let the transfer flow reject it instead.
391                'isEligible'  => null === $eligibility || ! empty( $eligibility['is_eligible'] ),
392                'errors'      => self::get_transfer_errors( $eligibility ),
393                'warnings'    => $warnings,
394                'upgradeUrl'  => static::get_upgrade_url( $domain ),
395                'activateUrl' => static::get_activate_url( $warnings ),
396            )
397        );
398    }
399
400    /**
401     * Load wpcom's Atomic library for the transfer checks, which callers still guard with function_exists().
402     *
403     * @return bool False on WoA, where there is nothing left to transfer.
404     */
405    private static function load_atomic_lib() {
406        if ( self::is_atomic() ) {
407            return false;
408        }
409
410        if ( function_exists( 'require_lib' ) ) {
411            require_lib( 'atomic' );
412        }
413
414        return true;
415    }
416
417    /**
418     * Whether a transfer to WoA is already underway.
419     *
420     * @param int $blog_id Blog ID.
421     * @return bool
422     */
423    public static function is_transfer_in_progress( $blog_id ) {
424        if ( ! self::load_atomic_lib() ) {
425            return false;
426        }
427
428        if ( function_exists( '\A8C\Atomic\has_site_pending_automated_transfer' ) ) {
429            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
430            if ( \A8C\Atomic\has_site_pending_automated_transfer( $blog_id ) ) {
431                return true;
432            }
433        }
434
435        if ( function_exists( '\A8C\Atomic\is_wpcom_atomic' ) ) {
436            // Second argument includes pending/active/provisioned transfers.
437            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
438            return (bool) \A8C\Atomic\is_wpcom_atomic( $blog_id, true );
439        }
440
441        return false;
442    }
443
444    /**
445     * Transfer eligibility for the site, or null when it cannot be determined.
446     *
447     * An in-progress transfer is reported as a `transfer_already_exists` error, so
448     * that state is checked first and never reaches here as a blocker.
449     *
450     * @param int $blog_id Blog ID.
451     * @param int $user_id User ID.
452     * @return array|null { is_eligible: bool, errors: array, warnings: array }
453     */
454    public static function get_eligibility( $blog_id, $user_id ) {
455        if ( ! self::load_atomic_lib() ) {
456            return null;
457        }
458
459        if ( ! function_exists( '\A8C\Atomic\Eligibility\get_status_for_site' ) ) {
460            return null;
461        }
462
463        // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only; pending addition to stub-defs.php.
464        return \A8C\Atomic\Eligibility\get_status_for_site( $blog_id, $user_id );
465    }
466
467    /**
468     * Resolve which prompt the page should show.
469     *
470     * @param int $blog_id Blog ID.
471     * @return string One of the STATE_* constants.
472     */
473    public static function get_state( $blog_id ) {
474        // On WoA the plan is the only question: the site is already on the
475        // infrastructure that runs the feature, so there is nothing to transfer, and
476        // should_register() has already stepped the page aside if it has the plan.
477        if ( ! static::has_feature() || self::is_atomic() ) {
478            $state = self::STATE_UPGRADE;
479        } elseif ( self::is_transfer_in_progress( $blog_id ) ) {
480            $state = self::STATE_IN_PROGRESS;
481        } else {
482            // Eligibility failures are explained by the activation prompt's own
483            // confirmation step, so they do not get a state of their own.
484            $state = self::STATE_ACTIVATE;
485        }
486
487        return $state;
488    }
489
490    /**
491     * Reasons a site cannot be transferred, in the shape the API returns them.
492     *
493     * The code is kept because the page maps it to its own copy; the API's message comes
494     * along as the fallback the page renders for codes it does not recognize.
495     *
496     * @param array|null $eligibility Result of get_eligibility().
497     * @return array[] Errors as { code, message }.
498     */
499    public static function get_transfer_errors( $eligibility ) {
500        $errors = array();
501
502        if ( ! empty( $eligibility['errors'] ) && is_array( $eligibility['errors'] ) ) {
503            foreach ( $eligibility['errors'] as $error ) {
504                if ( empty( $error['code'] ) ) {
505                    continue;
506                }
507
508                $errors[] = array(
509                    'code'    => (string) $error['code'],
510                    'message' => isset( $error['message'] ) ? (string) $error['message'] : '',
511                );
512            }
513        }
514
515        return $errors;
516    }
517
518    /**
519     * Non-blocking warnings to confirm before a transfer starts.
520     *
521     * The API groups warnings by type; they are flattened here because the page
522     * renders one list and a PHP map would localize as a JSON array once empty.
523     *
524     * @param array|null $eligibility Result of get_eligibility().
525     * @return array[] Warnings in the API's own shape, minus the grouping.
526     */
527    public static function get_transfer_warnings( $eligibility ) {
528        $warnings = array();
529
530        if ( ! empty( $eligibility['warnings'] ) && is_array( $eligibility['warnings'] ) ) {
531            foreach ( $eligibility['warnings'] as $group ) {
532                if ( ! is_array( $group ) ) {
533                    continue;
534                }
535
536                foreach ( $group as $warning ) {
537                    if ( empty( $warning['id'] ) ) {
538                        continue;
539                    }
540
541                    $warnings[] = array(
542                        'id'           => (string) $warning['id'],
543                        'description'  => isset( $warning['description'] ) ? (string) $warning['description'] : '',
544                        'domain_names' => ( ! empty( $warning['domain_names']['current'] ) && ! empty( $warning['domain_names']['new'] ) )
545                            ? array(
546                                'current' => (string) $warning['domain_names']['current'],
547                                'new'     => (string) $warning['domain_names']['new'],
548                            )
549                            : null,
550                        // Rendered as a link, so only a web URL gets through.
551                        'support_url'  => isset( $warning['support_url'] ) ? esc_url_raw( (string) $warning['support_url'], array( 'http', 'https' ) ) : '',
552                    );
553                }
554            }
555        }
556
557        return $warnings;
558    }
559
560    /**
561     * Checkout, with the Business plan already in the cart.
562     *
563     * `redirect_to` returns a buyer to activate or use what they just bought, and
564     * `checkoutBackUrl` returns someone who backs out; without it checkout falls back to /plans.
565     *
566     * @param string $domain Site domain.
567     * @return string
568     */
569    public static function get_upgrade_url( $domain ) {
570        $page_url = rawurlencode( static::get_page_url() );
571        // On Simple the buyer comes back here to activate; on WoA the purchase makes the feature live.
572        $return_url = self::is_atomic() ? rawurlencode( static::get_live_feature_url() ) : $page_url;
573
574        return add_query_arg(
575            array(
576                'redirect_to'     => $return_url,
577                'checkoutBackUrl' => $page_url,
578            ),
579            'https://wordpress.com/checkout/' . rawurlencode( (string) $domain ) . '/business'
580        );
581    }
582
583    /**
584     * This page's own address, for the round trips off to WordPress.com and back.
585     *
586     * @return string
587     */
588    public static function get_page_url() {
589        return admin_url( 'admin.php?page=' . static::MENU_SLUG );
590    }
591
592    /**
593     * Where a site lands once its plan makes the feature live and this page steps aside.
594     *
595     * By default the slug's next owner serves the feature at this page's own address.
596     *
597     * @param string|null $new_host The site's address after a transfer, when it changes.
598     * @return string
599     */
600    public static function get_live_feature_url( $new_host = null ) {
601        $page_url = static::get_page_url();
602
603        if ( null === $new_host ) {
604            return $page_url;
605        }
606
607        $parts = wp_parse_url( $page_url );
608
609        return 'https://' . $new_host . ( $parts['path'] ?? '' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
610    }
611
612    /**
613     * The site's new address from the address-change warning, if the transfer changes it.
614     *
615     * Used rather than trusting the old address to redirect, which may not have propagated
616     * when the flow sends the reader back.
617     *
618     * @param array[] $warnings Result of get_transfer_warnings().
619     * @return string|null
620     */
621    public static function get_post_transfer_host( array $warnings ) {
622        foreach ( $warnings as $warning ) {
623            $new_host = $warning['domain_names']['new'] ?? '';
624
625            // A bare hostname only, so nothing in the payload can steer the scheme or path.
626            if ( is_string( $new_host ) && filter_var( $new_host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME ) ) {
627                return strtolower( $new_host );
628            }
629        }
630
631        return null;
632    }
633
634    /**
635     * URL of the Calypso flow that transfers the site and switches the feature on.
636     *
637     * The flow waits for the transfer to land before following `redirect_to`.
638     *
639     * @param array[] $warnings Result of get_transfer_warnings(), for the post-transfer address.
640     * @return string
641     */
642    public static function get_activate_url( array $warnings = array() ) {
643        return add_query_arg(
644            array(
645                // Only Simple sites reach activation, and their local blog ID is the WordPress.com one; WoA's is not.
646                'siteId'                    => get_current_blog_id(),
647                'initiate_transfer_context' => self::TRANSFER_CONTEXT,
648                'redirect_to'               => rawurlencode( static::get_live_feature_url( self::get_post_transfer_host( $warnings ) ) ),
649            ),
650            self::TRANSFER_FLOW_URL
651        );
652    }
653}