Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.07% covered (warning)
87.07%
687 / 789
74.32% covered (warning)
74.32%
55 / 74
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initializer
87.07% covered (warning)
87.07%
687 / 789
74.32% covered (warning)
74.32%
55 / 74
458.26
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
5
 use_local_module_options
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 restore_module_options
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 is_licensing_ui_enabled
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 add_my_jetpack_menu_item
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 current_user_can_access_page
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 admin_init
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 is_onboarding_settled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 maybe_redirect_to_onboarding
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 get_onboarding_redirect_args_for_step
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 is_onboarding_available
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_partner_coupon_screen
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 get_onboarding_redirect_args
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
12
 silence_onboarding_notices
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 hide_onboarding_jitms
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 add_onboarding_admin_body_class
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 can_use_analytics
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 is_onboarding_wizard_enabled
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 has_never_registered_jetpack
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_onboarding_wizard_state
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 is_my_jetpack_admin_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_onboarding_request
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 is_onboarding_takeover
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 alias_screen_id_for_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 restore_screen_id_after_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 should_load_wp_build
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 should_render_wp_build
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 maybe_load_wp_build
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 load_wp_build
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 require_wp_build_with_screen_alias
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
2.06
 enqueue_scripts
96.97% covered (success)
96.97%
128 / 132
0.00% covered (danger)
0.00%
0 / 1
12
 get_header_state
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 add_script_data
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 is_modules_rest_api_enabled
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 get_modules_management_url
51.43% covered (warning)
51.43%
36 / 70
0.00% covered (danger)
0.00%
0 / 1
244.13
 print_modules_fallback
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
10
 get_products_section
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 add_admin_script_data
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 get_registered_admin_page_slugs
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 is_admin_page_available
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_assets_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_installed_jetpack_plugins
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 get_active_modules
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 is_jetpack_user_new
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 register_feature_flags
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
1
 is_header_controls_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 maybe_enqueue_header_controls_script
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 get_my_jetpack_flags
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 is_overview_v1_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 host_shows_my_jetpack
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_settings_consolidation_on_for_site
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 is_settings_consolidation_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 register_settings_panel_urls
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 resolve_settings_url
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 is_modules_to_features_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_seo_opt_in_state
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 admin_page
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 register_rest_endpoints
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
3
 permissions_callback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 should_initialize
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 is_offline_features_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setup_historically_active_jetpack_modules_sync
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 get_site
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 get_site_info
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
5.16
 is_commercial_site
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 is_registered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dismiss_welcome_banner
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 has_file_system_write_access
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
8.12
 get_idc_container_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybe_show_red_bubble
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
12
 has_connection_error
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 get_alerts_counted_elsewhere
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 enqueue_red_bubble_script
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 get_recommended_modules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * WP Admin page with information and configuration shared among all Jetpack stand-alone plugins
4 *
5 * @package automattic/my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Assets;
12use Automattic\Jetpack\Boost_Speed_Score\Speed_Score;
13use Automattic\Jetpack\Boost_Speed_Score\Speed_Score_History;
14use Automattic\Jetpack\Connection\Client;
15use Automattic\Jetpack\Connection\Error_Handler as Connection_Error_Handler;
16use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
17use Automattic\Jetpack\Connection\Manager as Connection_Manager;
18use Automattic\Jetpack\Connection\Rest_Authentication as Connection_Rest_Authentication;
19use Automattic\Jetpack\Connection\REST_Jetpack_AI_JWT;
20use Automattic\Jetpack\Constants as Jetpack_Constants;
21use Automattic\Jetpack\ExPlat;
22use Automattic\Jetpack\Feature_Flags\Feature_Flags;
23use Automattic\Jetpack\JITMS\JITM;
24use Automattic\Jetpack\Licensing;
25use Automattic\Jetpack\Menu_Badges\Menu_Badges;
26use Automattic\Jetpack\Menu_Badges\Notification_Counts;
27use Automattic\Jetpack\Modules;
28use Automattic\Jetpack\Partner_Coupon;
29use Automattic\Jetpack\Plugins_Installer;
30use Automattic\Jetpack\Status;
31use Automattic\Jetpack\Status\Host as Status_Host;
32use Automattic\Jetpack\Sync\Functions as Sync_Functions;
33use Automattic\Jetpack\Terms_Of_Service;
34use Automattic\Jetpack\Tracking;
35use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
36use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
37use Jetpack;
38use WP_Error;
39
40/**
41 * The main Initializer class that registers the admin menu and eneuque the assets.
42 */
43class Initializer {
44
45    /**
46     * My Jetpack package version
47     *
48     * @var string
49     */
50    const PACKAGE_VERSION = '6.17.0';
51
52    /**
53     * Feature flag that swaps the single-screen onboarding takeover for the setup wizard.
54     */
55    const ONBOARDING_WIZARD_FEATURE_FLAG = 'my-jetpack-onboarding-wizard';
56
57    /**
58     * User option recording that this user chose to skip setup.
59     *
60     * Per user rather than per site, because skipping is one person saying "not
61     * now": a second admin arriving at a site nobody has set up should still be
62     * offered the flow. Finishing it is the site-wide fact, and that is
63     * ONBOARDING_COMPLETED_OPTION.
64     *
65     * A user option rather than plain user meta: `wp_usermeta` is network-wide, so
66     * without the blog prefix a skip on one site would silence setup on all of them.
67     */
68    const ONBOARDING_DISMISSED_USER_OPTION = 'jetpack_my_jetpack_onboarding_dismissed';
69
70    /**
71     * Option recording that someone has finished setup on this site.
72     *
73     * Site-wide, because the flow connects the site and switches modules on, so a
74     * second admin does not need it again. Deliberately not autoloaded.
75     */
76    const ONBOARDING_COMPLETED_OPTION = 'jetpack_my_jetpack_onboarding_completed';
77
78    /**
79     * Handle for the classic script that carries the React initial state.
80     *
81     * Plugins that render My Jetpack components on their own pages (Boost) depend on this name.
82     */
83    const DATA_SCRIPT_HANDLE = 'my_jetpack_main_app';
84
85    /**
86     * Handle for the webpack bundle that renders the onboarding takeover.
87     */
88    const ONBOARDING_SCRIPT_HANDLE = 'my_jetpack_onboarding';
89
90    /**
91     * Handle for the webpack bundle that gives dashboards outside My Jetpack the shared header's controls.
92     */
93    const HEADER_CONTROLS_SCRIPT_HANDLE = 'my-jetpack-header-controls';
94
95    /**
96     * The wp-build page ID, which the generated enqueue check expects as the screen ID.
97     */
98    const WP_BUILD_PAGE_ID = 'my-jetpack-dashboard';
99
100    /**
101     * HTML container ID for the IDC screen on My Jetpack page.
102     */
103    private const IDC_CONTAINER_ID = 'my-jetpack-identity-crisis-container';
104
105    public const JETPACK_PLUGIN_SLUGS = array(
106        'jetpack-backup',
107        'jetpack-boost',
108        'zerobscrm',
109        'jetpack',
110        'jetpack-protect',
111        'jetpack-social',
112        'jetpack-videopress',
113        'jetpack-search',
114        'jetpack-stats',
115    );
116
117    private const MY_JETPACK_SITE_INFO_TRANSIENT_KEY = 'my-jetpack-site-info';
118
119    /**
120     * Feature flag for connecting the WordPress.com account from a Features card that needs one.
121     *
122     * @var string
123     */
124    public const ACCOUNT_CONNECT_FLAG = 'my-jetpack-account-connect';
125
126    /**
127     * Feature flag for the shared header's global controls.
128     *
129     * @var string
130     */
131    public const HEADER_CONTROLS_FLAG = 'my-jetpack-header-controls';
132
133    /**
134     * Feature flag for Overview v1.
135     *
136     * @var string
137     */
138    public const OVERVIEW_V1_FLAG = 'my-jetpack-overview-v1';
139
140    /**
141     * Feature flag for sending classic Modules links to Features.
142     *
143     * @var string
144     */
145    public const MODULES_TO_FEATURES_FLAG = 'my-jetpack-modules-to-features';
146
147    /**
148     * Feature flag for consolidating settings in My Jetpack instead of the Settings screen.
149     *
150     * @var string
151     */
152    public const SETTINGS_CONSOLIDATION_FLAG = 'my-jetpack-settings-consolidation';
153
154    /**
155     * Modules whose Jetpack settings link opens in the Features panel. A subset of `moduleSettings` in `settings-panel/registry.ts`; see there for which modules stay out.
156     */
157    public const SETTINGS_PANEL_MODULES = array( 'carousel', 'infinite-scroll', 'post-by-email', 'related-posts' );
158
159    /**
160     * Holds info/data about the site (from the /sites/%d endpoint)
161     *
162     * @var object
163     */
164    public static $site_info;
165
166    /**
167     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
168     *
169     * @var string|null
170     */
171    private static $wp_build_original_screen_id = null;
172
173    /**
174     * Initialize My Jetpack
175     *
176     * @return void
177     */
178    public static function init() {
179        self::register_feature_flags();
180
181        // Before the gate: the Jetpack plugin renders this package's connection screen and footer
182        // links even where My Jetpack is off, and `myJetpackInitialState` only exists on its own page.
183        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_admin_script_data' ) );
184        add_action( 'admin_head', array( __CLASS__, 'print_modules_fallback' ) );
185
186        if ( ! self::should_initialize() || did_action( 'my_jetpack_init' ) ) {
187            return;
188        }
189
190        if ( self::is_offline_features_enabled() ) {
191            add_filter( 'jetpack_my_jetpack_should_enable_add_license_screen', '__return_false' );
192            Connection_Rest_Authentication::init();
193            add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'use_local_module_options' ), 10, 3 );
194            add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'restore_module_options' ), 10, 3 );
195            add_action( 'rest_api_init', array( __CLASS__, 'register_rest_endpoints' ) );
196            add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
197            add_action( 'admin_menu', array( __CLASS__, 'add_my_jetpack_menu_item' ) );
198            do_action( 'my_jetpack_init' );
199            return;
200        }
201
202        // Answer "is this product on?" for admin menu registration.
203        Menu_Visibility::init();
204
205        self::register_settings_panel_urls();
206
207        // Extend jetpack plugins action links.
208        Products::extend_plugins_action_links();
209
210        // Set up the REST authentication hooks.
211        Connection_Rest_Authentication::init();
212
213        if ( self::is_licensing_ui_enabled() ) {
214            Licensing::instance()->initialize();
215        }
216
217        // Initialize Boost Speed Score
218        new Speed_Score( array(), 'jetpack-my-jetpack' );
219
220        // Add custom WP REST API endoints.
221        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_endpoints' ) );
222
223        // Both of wp-build's deadlines fall later in this request: its enqueue check must be
224        // hooked before `admin_enqueue_scripts`, and its render function defined before
225        // the page callback.
226        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
227
228        add_action( 'admin_menu', array( __CLASS__, 'add_my_jetpack_menu_item' ) );
229
230        add_action( 'admin_init', array( __CLASS__, 'setup_historically_active_jetpack_modules_sync' ) );
231
232        // Not on admin_init: modules are usually toggled over REST, where admin_init never fires.
233        add_action( 'jetpack_activate_module', array( Historically_Active_Modules::class, 'expire_refresh' ) );
234        add_action( 'jetpack_deactivate_module', array( Historically_Active_Modules::class, 'expire_refresh' ) );
235
236        // Nothing else gets to talk over the takeover. See both methods for why it
237        // takes two hooks rather than one.
238        add_action( 'in_admin_header', array( __CLASS__, 'silence_onboarding_notices' ), PHP_INT_MAX );
239        add_filter( 'jetpack_display_jitms_on_screen', array( __CLASS__, 'hide_onboarding_jitms' ), 10, 2 );
240        // Registered on admin_menu (not admin_init) and well before priority 100000, so the
241        // counts it registers exist before the menu-badges renderer runs on admin_menu 100000.
242        add_action( 'admin_menu', array( __CLASS__, 'maybe_show_red_bubble' ), 30 );
243
244        // Set up the ExPlat package endpoints
245        ExPlat::init();
246
247        // Sets up JITMS.
248        JITM::configure();
249
250        // Add "Jetpack Manage" menu item.
251        Jetpack_Manage::init();
252
253        /**
254         * Fires after the My Jetpack package is initialized
255         *
256         * @since 0.1.0
257         */
258        do_action( 'my_jetpack_init' );
259    }
260
261    /**
262     * Keep uncached module-list options local on the offline route.
263     *
264     * @since 6.10.0
265     * @param mixed            $response Response before the callback.
266     * @param array            $handler  Route handler.
267     * @param \WP_REST_Request $request  REST request.
268     * @return mixed
269     */
270    public static function use_local_module_options( $response, $handler, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- WordPress REST filter signature.
271        if ( '/jetpack/v4/module/all' === $request->get_route() ) {
272            // Keep module activation editable when the standalone plugin is not running;
273            // a stale autoloaded VideoPress class can still attach its forcing filter.
274            if ( ! Jetpack_Constants::is_defined( 'JETPACK_VIDEOPRESS_ROOT_FILE' )
275                && ! Products\Videopress::is_standalone_plugin_active()
276                && remove_filter( 'jetpack_active_modules', array( \Automattic\Jetpack\VideoPress\Module_Control::class, 'add_videopress_to_array' ) )
277                && class_exists( 'Jetpack_Modules_Overrides', false )
278            ) {
279                \Jetpack_Modules_Overrides::instance()->clear_cache();
280            }
281            add_filter( 'default_option_monitor_receive_notifications', '__return_false' );
282            add_filter( 'default_option_post_by_email_address' . get_current_user_id(), '__return_false' );
283        }
284        return $response;
285    }
286
287    /**
288     * Restore normal option reads after the offline module-list callback.
289     *
290     * @since 6.10.0
291     * @param mixed            $response Response after the callback.
292     * @param array            $handler  Route handler.
293     * @param \WP_REST_Request $request  REST request.
294     * @return mixed
295     */
296    public static function restore_module_options( $response, $handler, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- WordPress REST filter signature.
297        if ( '/jetpack/v4/module/all' === $request->get_route() ) {
298            remove_filter( 'default_option_monitor_receive_notifications', '__return_false' );
299            remove_filter( 'default_option_post_by_email_address' . get_current_user_id(), '__return_false' );
300        }
301        return $response;
302    }
303
304    /**
305     * Acts as a feature flag, returning a boolean for whether we should show the licensing UI.
306     *
307     * @since 1.2.0
308     *
309     * @return boolean
310     */
311    public static function is_licensing_ui_enabled() {
312        // Default changed to true in 1.5.0.
313        $is_enabled = true;
314
315        /*
316         * Bail if My Jetpack is not enabled,
317         * and thus the licensing UI shouldn't be enabled either.
318         */
319        if ( ! self::should_initialize() ) {
320            $is_enabled = false;
321        }
322
323        /**
324         * Acts as a feature flag, returning a boolean for whether we should show the licensing UI.
325         *
326         * @param bool $is_enabled Defaults to true.
327         *
328         * @since 1.2.0
329         * @since 1.5.0 Update default value to true.
330         */
331        return apply_filters(
332            'jetpack_my_jetpack_should_enable_add_license_screen',
333            $is_enabled
334        );
335    }
336
337    /**
338     * Add My Jetpack menu item to the admin menu.
339     *
340     * @return void
341     */
342    public static function add_my_jetpack_menu_item() {
343        if ( self::is_offline_features_enabled() && ! self::current_user_can_access_page() ) {
344            return;
345        }
346
347        $position    = defined( Admin_Menu::class . '::POSITION_FIRST' ) ? Admin_Menu::POSITION_FIRST : -10;
348        $menu_slug   = 'my-jetpack';
349        $page_suffix = Admin_Menu::add_menu(
350            __( 'My Jetpack', 'jetpack-my-jetpack' ),
351            __( 'My Jetpack', 'jetpack-my-jetpack' ),
352            'edit_posts',
353            $menu_slug,
354            array( __CLASS__, 'admin_page' ),
355            $position
356        );
357        add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
358        // Users who can edit posts but have no Jetpack menu get an admin_page_ hook instead.
359        add_action( 'load-admin_page_' . $menu_slug, array( __CLASS__, 'admin_init' ) );
360    }
361
362    /**
363     * Whether the current user may access the page selected by My Jetpack.
364     *
365     * @since 6.10.0
366     * @return bool
367     */
368    public static function current_user_can_access_page() {
369        return current_user_can( 'edit_posts' )
370            && ( ! self::is_offline_features_enabled() || REST_Main_Features::permissions_callback() );
371    }
372
373    /**
374     * Callback for the load my jetpack page hook.
375     *
376     * @return void
377     */
378    public static function admin_init() {
379        if ( self::is_offline_features_enabled() ) {
380            if ( ! self::current_user_can_access_page() ) {
381                wp_die( esc_html__( 'Sorry, you are not allowed to access this page.', 'jetpack-my-jetpack' ), 403 );
382            }
383            add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
384            return;
385        }
386
387        self::maybe_redirect_to_onboarding();
388
389        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No nonce needed for redirect flow control
390        $step = isset( $_GET['step'] ) ? sanitize_text_field( wp_unslash( $_GET['step'] ) ) : '';
391
392        // If the user reaches the onboarding page, add a class to the body
393        if ( $step === 'onboarding' ) {
394            add_filter( 'admin_body_class', array( __CLASS__, 'add_onboarding_admin_body_class' ) );
395        }
396
397        self::$site_info = self::get_site_info();
398        add_filter( 'identity_crisis_container_id', array( static::class, 'get_idc_container_id' ) );
399        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
400    }
401
402    /**
403     * Whether this user has already settled setup, one way or the other.
404     *
405     * Two separate facts. Finishing is site-wide: the wizard connects the site and
406     * switches modules on, so once it is done nobody needs the takeover again.
407     * Skipping is this person saying "not now", and it must not answer for the next
408     * admin who arrives at a site nobody has set up.
409     *
410     * @internal Not part of the package's public API.
411     *
412     * @return bool
413     */
414    public static function is_onboarding_settled() {
415        if ( get_option( self::ONBOARDING_COMPLETED_OPTION, false ) ) {
416            return true;
417        }
418
419        return (bool) get_user_option( self::ONBOARDING_DISMISSED_USER_OPTION );
420    }
421
422    /**
423     * Send the user into setup when they land on My Jetpack without one.
424     *
425     * Runs from My Jetpack's own page load, which nothing but a request for that page
426     * reaches and which fires before any output, so it needs no guards of its own.
427     *
428     * @return void
429     */
430    public static function maybe_redirect_to_onboarding() {
431        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No nonce needed for redirect flow control
432        $step          = isset( $_GET['step'] ) ? sanitize_text_field( wp_unslash( $_GET['step'] ) ) : '';
433        $redirect_args = self::get_onboarding_redirect_args_for_step( $step );
434
435        if ( null === $redirect_args ) {
436            return;
437        }
438
439        $location = wp_sanitize_redirect( add_query_arg( $redirect_args, admin_url( 'admin.php' ) ) );
440
441        // Remove wp_get_referer filter applied in `fix_redirect` method of `Jetpack_Admin` class
442        remove_filter( 'wp_redirect', 'wp_get_referer' );
443        wp_safe_redirect( $location );
444
445        exit( 0 );
446    }
447
448    /**
449     * Get the setup redirect for the current user and site at the requested step.
450     *
451     * @param string $step The requested setup step, or empty for the dashboard.
452     * @return array|null Query args for the redirect, or null to stay on the page.
453     */
454    private static function get_onboarding_redirect_args_for_step( $step ) {
455        $wizard_enabled = self::is_onboarding_wizard_enabled();
456        $connection     = new Connection_Manager();
457
458        // Only the wizard connects a user, so only it may count a site-only connection as
459        // unfinished setup. The single screen can only register the site, so asking it for
460        // an owner leaves that site on a screen with no way back out.
461        $connection_done = $wizard_enabled ? $connection->has_connected_owner() : $connection->is_connected();
462
463        // Onboarding's only action is the connect request, which answers a user
464        // without `jetpack_connect` with a 403.
465        return self::get_onboarding_redirect_args(
466            $step,
467            $connection_done,
468            self::is_onboarding_available() && current_user_can( 'jetpack_connect' ),
469            $wizard_enabled,
470            // Only the wizard records a settled run, and only a registered site may act on one:
471            // My Jetpack requires the site connection that accepting the terms creates.
472            $wizard_enabled && $connection->is_connected() && self::is_onboarding_settled()
473        );
474    }
475
476    /**
477     * Whether the My Jetpack onboarding flow is available on this site.
478     *
479     * WordPress.com Simple sites are connected by definition and don't manage their
480     * connection through My Jetpack, so the onboarding flow (which asks the user to
481     * connect) never applies there. A pending partner coupon brings its own connect screen.
482     *
483     * @internal Not part of the package's public API.
484     *
485     * @return bool
486     */
487    public static function is_onboarding_available() {
488        if ( self::is_offline_features_enabled() ) {
489            return false;
490        }
491        return ! ( new Status_Host() )->is_wpcom_simple() && null === self::get_partner_coupon_screen();
492    }
493
494    /**
495     * The partner coupon screen's data, when it should replace the dashboard.
496     *
497     * Coupons exist only with the Jetpack plugin, which supplies their products and images.
498     *
499     * @since 6.5.0
500     *
501     * @return array{coupon: array, assetBaseUrl: string}|null
502     */
503    public static function get_partner_coupon_screen() {
504        if (
505            self::is_offline_features_enabled()
506            || ! self::should_initialize()
507            || ! current_user_can( 'manage_options' )
508            || ! Jetpack_Constants::is_defined( 'JETPACK__PLUGIN_FILE' )
509        ) {
510            return null;
511        }
512
513        $coupon = Partner_Coupon::get_coupon();
514        if ( ! $coupon ) {
515            return null;
516        }
517
518        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks which screen renders.
519        if ( ! isset( $_GET['showCouponRedemption'] ) && ( new Connection_Manager() )->has_connected_owner() ) {
520            return null;
521        }
522
523        return array(
524            'coupon'       => $coupon,
525            'assetBaseUrl' => plugins_url( '', Jetpack_Constants::get_constant( 'JETPACK__PLUGIN_FILE' ) ),
526        );
527    }
528
529    /**
530     * Decide whether the current My Jetpack request should redirect, and where to.
531     *
532     * @internal Not part of the package's public API.
533     *
534     * @param string $step                 The current `step` query param.
535     * @param bool   $connection_done      Whether the connection the takeover asks for is in place:
536     *                                     a connected owner with the wizard, a connected site without it.
537     * @param bool   $onboarding_available Whether the onboarding flow is available on this site.
538     * @param bool   $wizard_enabled       Whether the takeover renders the setup wizard.
539     * @param bool   $is_settled           Whether this user has already finished or skipped setup.
540     * @return array|null Query args for the redirect, or null to stay on the current page.
541     */
542    public static function get_onboarding_redirect_args( $step, $connection_done, $onboarding_available, $wizard_enabled = false, $is_settled = false ) {
543        /*
544         * Only the automatic redirect is suppressed once setup is settled. Asking for
545         * the onboarding page by its own URL still works, so someone who skipped can
546         * go back to it, and so can a second admin the site-wide flag does not cover.
547         */
548        if ( $onboarding_available && ! $connection_done && ! $is_settled && $step !== 'onboarding' ) {
549            // The connection setup asks for is not in place, so setup is unfinished.
550            return array(
551                'page' => 'my-jetpack',
552                'step' => 'onboarding',
553            );
554        }
555
556        // The wizard connects mid-flow: the user authorizes on WordPress.com and is sent
557        // back here to finish setup, so arriving connected is not the same as finishing.
558        if ( $step === 'onboarding' && $connection_done && $onboarding_available && $wizard_enabled ) {
559            return null;
560        }
561
562        if ( $step === 'onboarding' && ( ! $onboarding_available || $connection_done ) ) {
563            // Nothing left to set up here, or onboarding does not apply on this site.
564            return array( 'page' => 'my-jetpack' );
565        }
566
567        return null;
568    }
569
570    /**
571     * Empty the notice channels while the takeover is on screen.
572     *
573     * The takeover hides `.notice`, `.error` and `.updated` in CSS, which only
574     * catches notices that use WordPress's own classes. Plenty do not, and a plugin
575     * announcing itself in its own markup lands on top of a full-screen flow that
576     * has no way to scroll past it.
577     *
578     * On `in_admin_header`, the last hook before `admin-header.php` prints notices,
579     * so a notice registered late is caught too.
580     *
581     * @return void
582     */
583    public static function silence_onboarding_notices() {
584        if ( ! self::is_onboarding_request() ) {
585            return;
586        }
587
588        remove_all_actions( 'admin_notices' );
589        remove_all_actions( 'all_admin_notices' );
590        remove_all_actions( 'network_admin_notices' );
591        remove_all_actions( 'user_admin_notices' );
592    }
593
594    /**
595     * Keep Just In Time Messages off the takeover.
596     *
597     * Emptying the notice channels above would also drop the JITM, since that is how
598     * it renders, but only after it has enqueued its script and stylesheet. This is
599     * the package's own opt-out and it runs before any of that.
600     *
601     * The screen id cannot answer this on its own: the takeover and the My Jetpack
602     * dashboard are the same screen, and only the query arg separates them.
603     *
604     * @param bool   $show      Whether to show JITMs on this screen.
605     * @param string $screen_id The current screen's id.
606     * @return bool
607     */
608    public static function hide_onboarding_jitms( $show, $screen_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required by the filter signature.
609        return self::is_onboarding_request() ? false : $show;
610    }
611
612    /**
613     * Add a body class to the My Jetpack onboarding page.
614     * This class hides the WP Admin toolbar and the sidebar menu.
615     *
616     * @param string $classes The body classes.
617     * @return string The modified body classes.
618     */
619    public static function add_onboarding_admin_body_class( $classes ) {
620        $classes .= ' jetpack-admin-full-screen';
621        return $classes;
622    }
623
624    /**
625     * Returns whether we are in condition to track to use
626     * Analytics functionality like Tracks, MC, or GA.
627     */
628    public static function can_use_analytics() {
629        $status     = new Status();
630        $connection = new Connection_Manager();
631        $tracking   = new Tracking( 'jetpack', $connection );
632
633        return $tracking->should_enable_tracking( new Terms_Of_Service(), $status );
634    }
635
636    /**
637     * Whether the onboarding takeover renders the setup wizard.
638     *
639     * Self-hosted only. On WordPress.com the platform owns the connection and nobody chose
640     * to install Jetpack, so a screen inviting them to start with it and connect an account
641     * does not apply. `is_wpcom_platform()` covers Simple and WordPress.com on Atomic, and
642     * requires wpcomsh, so other Atomic-hosted products still count as self-hosted here.
643     *
644     * @since 6.11.0
645     *
646     * @return bool
647     */
648    public static function is_onboarding_wizard_enabled() {
649        if ( ( new Status_Host() )->is_wpcom_platform() ) {
650            return false;
651        }
652
653        if ( ! Feature_Flags::is_enabled( self::ONBOARDING_WIZARD_FEATURE_FLAG ) ) {
654            return false;
655        }
656
657        // The wizard registers the site at its own first step, which writes the marker
658        // below, so asking again on the way back from WordPress.com would drop someone
659        // out of a run already under way.
660        if ( ( new Connection_Manager() )->is_connected() ) {
661            return true;
662        }
663
664        return self::has_never_registered_jetpack();
665    }
666
667    /**
668     * Whether the Jetpack plugin has never registered this site.
669     *
670     * `unique_registrations` is written on `jetpack_site_registered` by the Jetpack plugin
671     * alone, so a site that has only ever run a standalone plugin still reads as new here.
672     *
673     * @internal Not part of the package's public API.
674     *
675     * @since 6.11.0
676     *
677     * @return bool
678     */
679    public static function has_never_registered_jetpack() {
680        return ! \Jetpack_Options::get_option( 'unique_registrations' );
681    }
682
683    /**
684     * The wizard's configuration, or null while the takeover stays on its single screen.
685     *
686     * @since 6.11.0
687     *
688     * @return array{exitUrl: string, dashboardUrl: string}|null
689     */
690    public static function get_onboarding_wizard_state() {
691        if ( ! self::is_onboarding_wizard_enabled() ) {
692            return null;
693        }
694
695        return array(
696            // Where skipping the wizard lands: the admin menu is hidden, so leaving is always one click.
697            'exitUrl'      => admin_url( 'admin.php?page=my-jetpack' ),
698            // Where leaving Jetpack altogether lands: wp-admin's own dashboard.
699            'dashboardUrl' => admin_url(),
700        );
701    }
702
703    /**
704     * Whether the current request targets the My Jetpack admin page.
705     *
706     * @since 6.3.0
707     *
708     * @return bool
709     */
710    public static function is_my_jetpack_admin_request() {
711        if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
712            return false;
713        }
714
715        return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === 'my-jetpack'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
716    }
717
718    /**
719     * Whether the current request is the full-viewport onboarding takeover.
720     *
721     * Onboarding hides all wp-admin chrome and never renders through wp-build. The page
722     * is part of the question: `in_admin_header` and the JITM filter run everywhere.
723     *
724     * @since 6.3.0
725     *
726     * @return bool
727     */
728    public static function is_onboarding_request() {
729        if ( ! self::is_my_jetpack_admin_request() ) {
730            return false;
731        }
732
733        if ( ! isset( $_GET['step'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
734            return false;
735        }
736
737        return sanitize_text_field( wp_unslash( $_GET['step'] ) ) === 'onboarding'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
738    }
739
740    /**
741     * Whether this request is the onboarding takeover rather than the dashboard.
742     *
743     * Loading wp-build, enqueueing scripts and rendering the page must all agree,
744     * so they share this one expression.
745     *
746     * @since 6.3.0
747     *
748     * @return bool
749     */
750    public static function is_onboarding_takeover() {
751        return self::is_onboarding_request() && self::is_onboarding_available();
752    }
753
754    /**
755     * Alias the screen ID to satisfy wp-build's generated enqueue check.
756     *
757     * @since 6.3.0
758     *
759     * @return void
760     */
761    public static function alias_screen_id_for_wp_build() {
762        $screen = get_current_screen();
763
764        if ( ! $screen ) {
765            return;
766        }
767
768        self::$wp_build_original_screen_id = $screen->id;
769        $screen->id                        = self::WP_BUILD_PAGE_ID;
770    }
771
772    /**
773     * Undo alias_screen_id_for_wp_build(), since JITM builds its message path from the screen ID.
774     *
775     * @since 6.3.0
776     *
777     * @return void
778     */
779    public static function restore_screen_id_after_wp_build() {
780        $screen = get_current_screen();
781
782        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
783            return;
784        }
785
786        $screen->id                        = self::$wp_build_original_screen_id;
787        self::$wp_build_original_screen_id = null;
788    }
789
790    /**
791     * Whether this request should load wp-build at all.
792     *
793     * Also what keeps WP_Build_Polyfills from replacing core scripts on every other admin page.
794     *
795     * @since 6.3.0
796     *
797     * @return bool
798     */
799    public static function should_load_wp_build() {
800        return self::is_my_jetpack_admin_request() && ! self::is_onboarding_takeover();
801    }
802
803    /**
804     * Whether this request renders the dashboard through wp-build.
805     *
806     * The generated render function is missing where the package was never built.
807     *
808     * @since 6.3.0
809     *
810     * @return bool
811     */
812    public static function should_render_wp_build() {
813        return ! self::is_onboarding_takeover()
814            && function_exists( 'jetpack_my_jetpack_my_jetpack_dashboard_wp_admin_render_page' );
815    }
816
817    /**
818     * Load wp-build for the My Jetpack dashboard.
819     *
820     * @since 6.3.0
821     *
822     * @return void
823     */
824    public static function maybe_load_wp_build() {
825        if ( ! self::should_load_wp_build() ) {
826            return;
827        }
828
829        $build_index = dirname( __DIR__ ) . '/build/build.php';
830
831        if ( file_exists( $build_index ) ) {
832            self::load_wp_build( $build_index );
833        }
834    }
835
836    /**
837     * Require the generated wp-build index and wire it into this request.
838     *
839     * @since 6.3.0
840     *
841     * @param string $build_index Path to the generated `build.php`.
842     * @return void
843     */
844    public static function load_wp_build( $build_index ) {
845        self::require_wp_build_with_screen_alias( $build_index );
846
847        // wp-build hooks module registration to wp_default_scripts, which has
848        // already fired by admin_menu â€” call it directly or the init module
849        // never reaches the import map.
850        if ( function_exists( 'jetpack_my_jetpack_register_script_modules' ) ) {
851            jetpack_my_jetpack_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
852        }
853
854        WP_Build_Polyfills::register(
855            'my-jetpack',
856            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
857        );
858    }
859
860    /**
861     * Require the generated build file with the screen ID aliased across its enqueue check.
862     *
863     * @see WP_Build_Screen_Id::load_with_alias()
864     * @param string $build_index Path to the generated `build.php`.
865     * @return void
866     */
867    private static function require_wp_build_with_screen_alias( $build_index ) {
868        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
869        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
870            WP_Build_Screen_Id::load_with_alias(
871                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
872                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
873                function () use ( $build_index ) {
874                    require_once $build_index;
875                }
876            );
877            return;
878        }
879
880        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
881        require_once $build_index;
882        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
883    }
884
885    /**
886     * Enqueue admin page assets.
887     *
888     * @return void
889     */
890    public static function enqueue_scripts() {
891        if ( self::is_offline_features_enabled() ) {
892            $data_handle = self::DATA_SCRIPT_HANDLE;
893            wp_register_script( $data_handle, false, array(), self::PACKAGE_VERSION, true );
894            wp_enqueue_script( $data_handle );
895            add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) );
896            wp_localize_script(
897                $data_handle,
898                'myJetpackInitialState',
899                array(
900                    'isOfflineFeatures'     => true,
901                    'products'              => array( 'items' => array() ),
902                    'adminUrl'              => esc_url( admin_url() ),
903                    'assetsUrl'             => self::get_assets_url(),
904                    'isJetpackPluginActive' => class_exists( 'Jetpack' ),
905                    'hiddenFeatures'        => Feature_Visibility::get_hidden(),
906                    'deprecatedModules'     => Feature_Visibility::get_deprecated_modules(),
907                    'myJetpackFlags'        => self::get_my_jetpack_flags(),
908                )
909            );
910            wp_localize_script(
911                $data_handle,
912                'myJetpackRest',
913                array(
914                    'apiRoot'  => esc_url_raw( rest_url() ),
915                    'apiNonce' => wp_create_nonce( 'wp_rest' ),
916                )
917            );
918            if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
919                wp_enqueue_script( 'wp-jp-i18n-loader' );
920            }
921            self::maybe_enqueue_header_controls_script();
922            return;
923        }
924
925        /**
926         * Fires after the My Jetpack page is initialized.
927         * Allows for enqueuing additional scripts only on the My Jetpack page.
928         *
929         * @since 4.35.7
930         */
931        do_action( 'myjetpack_enqueue_scripts' );
932        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) );
933
934        // Script-less: it only prints the state below, before the dashboard's boot runs on DOMContentLoaded.
935        $data_handle = self::DATA_SCRIPT_HANDLE;
936        wp_register_script( $data_handle, false, array(), self::PACKAGE_VERSION, true );
937        wp_enqueue_script( $data_handle );
938        self::maybe_enqueue_header_controls_script();
939
940        if ( self::is_my_jetpack_admin_request() && self::is_onboarding_takeover() ) {
941            Assets::register_script(
942                self::ONBOARDING_SCRIPT_HANDLE,
943                '../build/onboarding.js',
944                __FILE__,
945                array(
946                    'dependencies' => array( $data_handle ),
947                    'enqueue'      => true,
948                    'in_footer'    => true,
949                    'textdomain'   => 'jetpack-my-jetpack',
950                )
951            );
952        } elseif ( self::should_render_wp_build() && wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
953            // Registered on every admin page but only enqueued when depended on; the esbuild bundles don't pull it in.
954            wp_enqueue_script( 'wp-jp-i18n-loader' );
955        }
956
957        $modules             = new Modules();
958        $connection          = new Connection_Manager();
959        $speed_score_history = new Speed_Score_History( home_url() );
960        $latest_score        = $speed_score_history->latest();
961        $previous_score      = array();
962        if ( $speed_score_history->count() > 1 ) {
963            $previous_score = $speed_score_history->latest( 1 );
964        }
965        $latest_score['previousScores'] = $previous_score['scores'] ?? array();
966
967        $sandboxed_domain = '';
968        $is_dev_version   = false;
969        if ( class_exists( 'Jetpack' ) ) {
970            $is_dev_version   = Jetpack::is_development_version();
971            $sandboxed_domain = defined( 'JETPACK__SANDBOX_DOMAIN' ) ? JETPACK__SANDBOX_DOMAIN : '';
972        }
973
974        wp_localize_script(
975            $data_handle,
976            'myJetpackInitialState',
977            array(
978                'products'               => array(
979                    'items' => Products::get_products(),
980                ),
981                'mainFeatures'           => Main_Features::get_state(),
982                'featuresBanner'         => array( 'isDismissed' => REST_Main_Features::is_banner_dismissed() ),
983                'onboardingWizard'       => self::get_onboarding_wizard_state(),
984                'header'                 => self::get_header_state(),
985                'plugins'                => Plugins_Installer::get_plugins(),
986                'themes'                 => Sync_Functions::get_themes(),
987                'myJetpackUrl'           => admin_url( 'admin.php?page=my-jetpack' ),
988                'myJetpackCheckoutUri'   => admin_url( 'admin.php?page=my-jetpack' ),
989                'topJetpackMenuItemUrl'  => Admin_Menu::get_top_level_menu_item_url(),
990                'siteSuffix'             => ( new Status() )->get_site_suffix(),
991                'siteUrl'                => esc_url( get_site_url() ),
992                'homeUrl'                => esc_url_raw( home_url() ),
993                'blogID'                 => Connection_Manager::get_site_id( true ),
994                'myJetpackVersion'       => self::PACKAGE_VERSION,
995                'myJetpackFlags'         => self::get_my_jetpack_flags(),
996                'fileSystemWriteAccess'  => self::has_file_system_write_access(),
997                'loadAddLicenseScreen'   => self::is_licensing_ui_enabled(),
998                'adminUrl'               => esc_url( admin_url() ),
999                'assetsUrl'              => self::get_assets_url(),
1000                'IDCContainerID'         => static::get_idc_container_id(),
1001                'userIsAdmin'            => current_user_can( 'manage_options' ),
1002                // wp_localize_script() decodes the HTML entities WordPress stores names with.
1003                'userFirstName'          => (string) wp_get_current_user()->first_name,
1004                'lifecycleStats'         => array(
1005                    'jetpackPlugins'                      => self::get_installed_jetpack_plugins(),
1006                    'historicallyActiveModules'           => \Jetpack_Options::get_option( 'historically_active_modules', array() ),
1007                    'historicallyActiveModulesRefreshDue' => Historically_Active_Modules::is_refresh_due(),
1008                    'brokenModules'                       => Red_Bubble_Notifications::check_for_broken_modules(),
1009                    'isSiteConnected'                     => $connection->is_connected(),
1010                    'isUserConnected'                     => $connection->is_user_connected(),
1011                    'modules'                             => self::get_active_modules(),
1012                ),
1013                'recommendedModules'     => array(
1014                    'modules'    => self::get_recommended_modules(),
1015                    'isFirstRun' => \Jetpack_Options::get_option( 'recommendations_first_run', true ),
1016                    'dismissed'  => \Jetpack_Options::get_option( 'dismissed_recommendations', false ),
1017                ),
1018                'isStatsModuleActive'    => $modules->is_active( 'stats' ),
1019                'canUserViewStats'       => current_user_can( 'manage_options' ) || current_user_can( 'view_stats' ),
1020                'hiddenFeatures'         => Feature_Visibility::get_hidden(),
1021                'deprecatedModules'      => Feature_Visibility::get_deprecated_modules(),
1022                'sandboxedDomain'        => $sandboxed_domain,
1023                'isDevVersion'           => $is_dev_version,
1024                'isAtomic'               => ( new Status_Host() )->is_woa_site(),
1025                'isJetpackPluginActive'  => class_exists( 'Jetpack' ),
1026                'latestBoostSpeedScores' => $latest_score,
1027                'seoOptIn'               => self::get_seo_opt_in_state(),
1028                'protectPageModuleUrls'  => (object) Products\Protect::get_protect_page_module_urls(),
1029                'partnerCoupon'          => self::get_partner_coupon_screen(),
1030            )
1031        );
1032
1033        wp_localize_script(
1034            $data_handle,
1035            'myJetpackRest',
1036            array(
1037                'apiRoot'  => esc_url_raw( rest_url() ),
1038                'apiNonce' => wp_create_nonce( 'wp_rest' ),
1039            )
1040        );
1041
1042        // Connection Initial State.
1043        Connection_Initial_State::render_script( $data_handle );
1044
1045        // Required for Analytics.
1046        if ( self::can_use_analytics() ) {
1047            Tracking::register_tracks_functions_scripts( true );
1048        }
1049    }
1050
1051    /**
1052     * What the shared header needs beyond the rest of the state, so it renders without a request.
1053     *
1054     * @since 6.10.0
1055     *
1056     * @return array{connectorsUrl: string|null} The state.
1057     */
1058    public static function get_header_state() {
1059        return array(
1060            // The same probe the connection package uses: the core screen ships with WordPress 7.0.
1061            // `connector` names the connection package's card, which opens its details for it.
1062            'connectorsUrl' => file_exists( ABSPATH . 'wp-admin/options-connectors.php' ) ? admin_url( 'options-connectors.php?connector=wordpress_com' ) : null,
1063        );
1064    }
1065
1066    /**
1067     * Add My Jetpack data to the unified script data object.
1068     *
1069     * @param array $data The script data.
1070     * @return array
1071     */
1072    public static function add_script_data( $data ) {
1073        $block_availability = ! self::is_offline_features_enabled() && class_exists( '\Jetpack_Gutenberg' )
1074            ? \Jetpack_Gutenberg::get_cached_availability()
1075            : array();
1076
1077        $data['myJetpack']['siteEditor'] = array(
1078            'isBlockTheme'            => function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(),
1079            'isSharingBlockAvailable' => isset( $block_availability['sharing-buttons'] )
1080                && $block_availability['sharing-buttons']['available'],
1081            'isLikeBlockAvailable'    => isset( $block_availability['like'] )
1082                && $block_availability['like']['available'],
1083        );
1084
1085        return $data;
1086    }
1087
1088    /**
1089     * Whether module management can use the REST API.
1090     *
1091     * @since 6.11.0
1092     * @return bool
1093     */
1094    public static function is_modules_rest_api_enabled() {
1095        /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
1096        $enabled = apply_filters( 'rest_enabled', true );
1097        $user    = wp_get_current_user();
1098        $auth    = false;
1099        // Passing true skips WordPress's nonce check, which would sign the user out; restore the user if another filter clears it.
1100        try {
1101            /** This filter is documented in wp-includes/rest-api/class-wp-rest-server.php */
1102            $auth = apply_filters( 'rest_authentication_errors', true );
1103        } finally {
1104            wp_set_current_user( $user->ID );
1105        }
1106        return $enabled && ! is_wp_error( $enabled ) && false !== $auth && ! is_wp_error( $auth );
1107    }
1108
1109    /**
1110     * Get a Features management link with a classic Modules fallback.
1111     *
1112     * @since 6.11.0
1113     * @param array     $args     Query arguments from a classic Modules screen link; only `s` and `module_tag` can carry over to Features.
1114     * @param bool|null $eligible Set to true when the URL points at the Features tab, false when it is the classic Modules screen.
1115     * @return string The management URL.
1116     */
1117    public static function get_modules_management_url( $args = array(), &$eligible = null ) {
1118        $eligible  = false;
1119        $available = self::host_shows_my_jetpack() && self::is_modules_to_features_enabled();
1120        $classic   = add_query_arg( array_merge( urlencode_deep( $args ), array( 'page' => 'jetpack_modules' ) ), admin_url( 'admin.php' ) );
1121        if (
1122            ! $available
1123            || array_diff( array_keys( $args ), array( 's', 'module_tag' ) )
1124            || array_filter( $args, 'is_string' ) !== $args
1125            // Keep links on Modules unless Features can show controls for this installation.
1126            || Main_Features::PLUGIN_ACTIVE !== Main_Features::get_plugin_status( Product::JETPACK_PLUGIN_SLUG )
1127            || ! current_user_can( 'jetpack_manage_modules' )
1128            || ! current_user_can( 'manage_options' )
1129            || ! self::current_user_can_access_page()
1130            || ( new Status() )->is_offline_mode()
1131            || ( new Status_Host() )->is_wpcom_simple()
1132            || is_network_admin()
1133            || ! self::should_initialize()
1134            || ! ( new Connection_Manager() )->is_connected()
1135            || null !== self::get_partner_coupon_screen()
1136            // Predict My Jetpack's setup redirect for a Features URL with no step; keep both decisions in sync.
1137            || null !== self::get_onboarding_redirect_args_for_step( '' )
1138            || ! self::is_modules_rest_api_enabled()
1139        ) {
1140            return $classic;
1141        }
1142
1143        if ( isset( $args['module_tag'] ) && '' !== $args['module_tag'] ) {
1144            if ( ! function_exists( 'jetpack_get_module_i18n_tag' ) ) {
1145                return $classic;
1146            }
1147            $known      = array();
1148            $modules    = new Modules();
1149            $hidden     = Main_Features::get_hidden_modules();
1150            $deprecated = Feature_Visibility::get_deprecated_modules();
1151            foreach ( $modules->get_available() as $slug ) {
1152                if ( in_array( $slug, $hidden, true ) || ( in_array( $slug, $deprecated, true ) && ! $modules->is_active( $slug ) ) ) {
1153                    continue;
1154                }
1155                $module = $modules->get( $slug );
1156                $known  = array_merge( $known, $module['module_tags'] ?? array() );
1157            }
1158            $known = array_map( array( Jetpack::class, 'translate_module_tag' ), $known );
1159            if ( ! in_array( $args['module_tag'], $known, true ) ) {
1160                return $classic;
1161            }
1162        }
1163
1164        if ( isset( $args['s'] ) && '' !== $args['s'] ) {
1165            require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
1166            $hidden        = Main_Features::get_hidden_modules();
1167            $deprecated    = Feature_Visibility::get_deprecated_modules();
1168            $hidden_match  = false;
1169            $visible_match = false;
1170            foreach ( Jetpack::get_translated_modules( \Jetpack_Admin::init()->get_modules() ) as $slug => $module ) {
1171                if ( ! empty( $args['module_tag'] ) && ! in_array( $args['module_tag'], $module['module_tags'], true ) ) {
1172                    continue;
1173                }
1174                $text  = $module['name'] . ' ' . $module['description'] . ' ' . $module['long_description'] . ' ' . $module['search_terms'] . ' ' . implode( ',', $module['module_tags'] );
1175                $match = function_exists( 'mb_stripos' ) ? mb_stripos( $text, $args['s'] ) : stripos( $text, $args['s'] );
1176                if ( false === $match ) {
1177                    continue;
1178                }
1179                if ( in_array( $slug, $hidden, true ) || ( in_array( $slug, $deprecated, true ) && ! $module['activated'] ) ) {
1180                    $hidden_match = true;
1181                } else {
1182                    $visible_match = true;
1183                    break;
1184                }
1185            }
1186            if ( $hidden_match && ! $visible_match ) {
1187                return $classic;
1188            }
1189        }
1190
1191        $eligible = true;
1192        $hash     = array();
1193        if ( isset( $args['s'] ) && '' !== $args['s'] ) {
1194            $hash['search'] = $args['s'];
1195        }
1196        if ( isset( $args['module_tag'] ) && '' !== $args['module_tag'] ) {
1197            $hash['module_tag'] = $args['module_tag'];
1198        }
1199        return add_query_arg(
1200            array_merge(
1201                urlencode_deep( $args ),
1202                array(
1203                    'page'             => 'my-jetpack',
1204                    'modules_fallback' => '1',
1205                )
1206            ),
1207            admin_url( 'admin.php' )
1208        )
1209            . '#/features' . ( $hash ? '?' . http_build_query( $hash, '', '&', PHP_QUERY_RFC3986 ) : '' );
1210    }
1211
1212    /**
1213     * With `my-jetpack-modules-to-features` on, send My Jetpack requests with `modules_fallback=1` back to classic Modules:
1214     * always without JavaScript, and for everyone when REST is unavailable.
1215     *
1216     * @since 6.11.0
1217     * @return void
1218     */
1219    public static function print_modules_fallback() {
1220        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only navigation fallback.
1221        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Only the exact allowlisted marker is accepted.
1222        if ( ! self::is_modules_to_features_enabled() || 'my-jetpack' !== ( $_GET['page'] ?? null ) || '1' !== ( $_GET['modules_fallback'] ?? null ) || Main_Features::PLUGIN_ACTIVE !== Main_Features::get_plugin_status( Product::JETPACK_PLUGIN_SLUG ) || ! current_user_can( 'jetpack_manage_modules' ) ) {
1223            return;
1224        }
1225        $args = array( 'page' => 'jetpack_modules' );
1226        foreach ( array( 's', 'module_tag' ) as $key ) {
1227            if ( isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ) {
1228                $args[ $key ] = sanitize_text_field( wp_unslash( $_GET[ $key ] ) );
1229            }
1230        }
1231        // phpcs:enable WordPress.Security.NonceVerification.Recommended
1232        $meta = '<meta http-equiv="refresh" content="0; url=' . esc_url( add_query_arg( urlencode_deep( $args ), admin_url( 'admin.php' ) ) ) . '">';
1233        echo '<noscript>' . $meta . '</noscript>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- URL escaped above.
1234        if ( ! self::is_modules_rest_api_enabled() ) {
1235            echo $meta; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- URL escaped above.
1236        }
1237    }
1238
1239    /**
1240     * Get the slug and label of the Features tab, for footer links to it.
1241     *
1242     * Kept for footers built before the Products tab was removed: Jetpack 16.3 calls this, and
1243     * older JS footers read it from script data. Without it they link to "Products".
1244     *
1245     * @since 6.4.0
1246     *
1247     * @return array{slug: string, label: string}
1248     */
1249    public static function get_products_section() {
1250        return array(
1251            'slug'  => 'features',
1252            'label' => _x( 'Features', 'Navigation item', 'jetpack-my-jetpack' ),
1253        );
1254    }
1255
1256    /**
1257     * Add My Jetpack's availability, image base URL and link targets to admin script data.
1258     *
1259     * Printed on every admin page by Script_Data, so the connection screen can resolve its
1260     * illustrations and Jetpack footers know whether to link to My Jetpack.
1261     *
1262     * @since 6.3.0
1263     *
1264     * @param array $data Script data.
1265     * @return array
1266     */
1267    public static function add_admin_script_data( $data ) {
1268        $data['myJetpack']['isAvailable']           = self::is_admin_page_available();
1269        $data['myJetpack']['assetsUrl']             = self::get_assets_url();
1270        $data['myJetpack']['productsSection']       = self::get_products_section();
1271        $data['myJetpack']['settingsConsolidation'] = self::is_settings_consolidation_enabled();
1272        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only selects which navigation data to seed.
1273        if ( in_array( $_GET['page'] ?? '', array( 'my-jetpack', 'jetpack-settings', 'jetpack' ), true ) ) {
1274            $data['myJetpack']['modulesManagementUrl'] = self::get_modules_management_url();
1275        }
1276
1277        // Only administrators can open the pages this points at, so spare everyone else the lookup.
1278        if ( current_user_can( 'manage_options' ) ) {
1279            $data['myJetpack']['aiManageUrl'] = esc_url_raw( Products\Jetpack_Ai::get_manage_url() );
1280        }
1281
1282        if (
1283            self::is_my_jetpack_admin_request()
1284            && self::is_offline_features_enabled()
1285            && self::should_initialize()
1286            && REST_Main_Features::permissions_callback()
1287        ) {
1288            $data['myJetpack']['offlineFeatures'] = array(
1289                'mainFeatures' => Main_Features::get_state( true ),
1290                'plugins'      => Plugins_Installer::get_plugins(),
1291            );
1292            $data['myJetpack']['offlineFeatures']['mainFeatures']['available_admin_pages'] = self::get_registered_admin_page_slugs();
1293        }
1294
1295        return $data;
1296    }
1297
1298    /**
1299     * Get the slugs of registered admin pages.
1300     *
1301     * Skip hooks containing `?`: they are `edit.php?post_type=...` style pages, not page slugs.
1302     *
1303     * @return string[]
1304     */
1305    private static function get_registered_admin_page_slugs() {
1306        global $_registered_pages;
1307
1308        $hooks = array_filter(
1309            array_keys( $_registered_pages ?? array() ),
1310            static fn( $hook ) => false !== strpos( $hook, '_page_' ) && false === strpos( $hook, '?' )
1311        );
1312
1313        return array_values(
1314            array_unique(
1315                array_map( static fn( $hook ) => substr( $hook, strpos( $hook, '_page_' ) + 6 ), $hooks )
1316            )
1317        );
1318    }
1319
1320    /**
1321     * Whether My Jetpack's admin page is available to the current user.
1322     *
1323     * Meaningful only after admin_menu has registered the page.
1324     *
1325     * @since 6.5.0
1326     *
1327     * @return bool
1328     */
1329    public static function is_admin_page_available() {
1330        global $_registered_pages;
1331
1332        return did_action( 'my_jetpack_init' ) > 0
1333            && isset( $_registered_pages[ get_plugin_page_hookname( 'my-jetpack', 'jetpack' ) ] )
1334            && current_user_can( 'edit_posts' );
1335    }
1336
1337    /**
1338     * Get the base URL of the package's built images, with a trailing slash.
1339     *
1340     * @since 6.3.0
1341     *
1342     * @return string
1343     */
1344    public static function get_assets_url() {
1345        return trailingslashit( Assets::normalize_path( plugins_url( '../build/images/', __FILE__ ) ) );
1346    }
1347
1348    /**
1349     * Get installed Jetpack plugins
1350     *
1351     * @return array
1352     */
1353    public static function get_installed_jetpack_plugins() {
1354        $plugin_slugs = array_keys( Plugins_Installer::get_plugins() );
1355        $plugin_slugs = array_map(
1356            static function ( $slug ) {
1357                $parts = explode( '/', $slug );
1358                // Return the last segment of the filepath without the PHP extension
1359                return str_replace( '.php', '', $parts[ count( $parts ) - 1 ] );
1360            },
1361            $plugin_slugs
1362        );
1363
1364        return array_values( array_intersect( self::JETPACK_PLUGIN_SLUGS, $plugin_slugs ) );
1365    }
1366
1367    /**
1368     * Get active modules (except ones enabled by default)
1369     *
1370     * @return array
1371     */
1372    public static function get_active_modules() {
1373        $modules        = new Modules();
1374        $active_modules = $modules->get_active();
1375
1376        // if the Jetpack plugin is active, filter out the modules that are active by default
1377        if ( class_exists( 'Jetpack' ) && ! empty( $active_modules ) ) {
1378            $active_modules = array_diff( $active_modules, Jetpack::get_default_modules() );
1379        }
1380        return array_values( $active_modules );
1381    }
1382
1383    /**
1384     * Determine if the current user is "new" to Jetpack
1385     * This is used to vary some messaging in My Jetpack
1386     *
1387     * On the front-end, purchases are also taken into account
1388     *
1389     * @return bool
1390     */
1391    public static function is_jetpack_user_new() {
1392        // is the user connected?
1393        $connection = new Connection_Manager();
1394        if ( $connection->is_user_connected() ) {
1395            return false;
1396        }
1397
1398        // TODO: add a data point for the last known connection/ disconnection time
1399
1400        // are any modules active?
1401        $active_modules = self::get_active_modules();
1402        if ( ! empty( $active_modules ) ) {
1403            return false;
1404        }
1405
1406        // check for other Jetpack plugins that are installed on the site (active or not)
1407        // If there's more than one Jetpack plugin active, this user is not "new"
1408        $plugin_slugs              = array_keys( Plugins_Installer::get_plugins() );
1409        $plugin_slugs              = array_map(
1410            static function ( $slug ) {
1411                $parts = explode( '/', $slug );
1412                // Return the last segment of the filepath without the PHP extension
1413                return str_replace( '.php', '', $parts[ count( $parts ) - 1 ] );
1414            },
1415            $plugin_slugs
1416        );
1417        $installed_jetpack_plugins = array_intersect( self::JETPACK_PLUGIN_SLUGS, $plugin_slugs );
1418        if ( is_countable( $installed_jetpack_plugins ) && count( $installed_jetpack_plugins ) >= 2 ) {
1419            return false;
1420        }
1421
1422        // Does the site have any purchases?
1423        $purchases = Wpcom_Products::get_site_current_purchases();
1424        if ( ! empty( $purchases ) && ! is_wp_error( $purchases ) ) {
1425            return false;
1426        }
1427
1428        return true;
1429    }
1430
1431    /**
1432     * Register My Jetpack's feature flags.
1433     *
1434     * @since 6.11.0
1435     *
1436     * @return void
1437     */
1438    public static function register_feature_flags() {
1439        Feature_Flags::register(
1440            self::ACCOUNT_CONNECT_FLAG,
1441            array(
1442                'default'     => true,
1443                'description' => 'Connect the account from the Install or Connect button of Features cards that need one, turn the chosen module on once Install Jetpack or Activate Jetpack finishes setup, hide Active and Inactive where the control already shows the state, exclude features from Available when their only action is an install the user cannot make, and show host restrictions below card descriptions.',
1444                'owner'       => 'my-jetpack',
1445            )
1446        );
1447        Feature_Flags::register(
1448            self::HEADER_CONTROLS_FLAG,
1449            array(
1450                'default'     => true,
1451                'description' => 'Show the global controls in the shared header of My Jetpack and Boost.',
1452                'owner'       => 'my-jetpack',
1453            )
1454        );
1455        Feature_Flags::register(
1456            self::OVERVIEW_V1_FLAG,
1457            array(
1458                'default'     => false,
1459                'description' => 'Show Overview v1 in My Jetpack.',
1460                'owner'       => 'my-jetpack',
1461            )
1462        );
1463        Feature_Flags::register(
1464            self::MODULES_TO_FEATURES_FLAG,
1465            array(
1466                'default'     => true,
1467                'description' => 'Open supported classic Modules links in Features.',
1468                'owner'       => 'my-jetpack',
1469            )
1470        );
1471        Feature_Flags::register(
1472            self::SETTINGS_CONSOLIDATION_FLAG,
1473            array(
1474                'default'     => false,
1475                'description' => 'Move settings out of the Settings screen to their new homes: open small features\' settings in a panel from Configure in Features, remove or replace their Settings cards with pointers, and show settings in their new places.',
1476                'owner'       => 'my-jetpack',
1477            )
1478        );
1479    }
1480
1481    /**
1482     * Whether the shared header shows its controls: the flag is on, My Jetpack runs, and not in offline Features.
1483     *
1484     * Boost's dashboard gets My Jetpack's page state even where My Jetpack is off, hence the second check.
1485     * Offline Features prints no connection state for the controls to read, and has no connection to show.
1486     *
1487     * @return bool
1488     */
1489    private static function is_header_controls_enabled() {
1490        return Feature_Flags::is_enabled( self::HEADER_CONTROLS_FLAG )
1491            && self::should_initialize()
1492            && ! self::is_offline_features_enabled();
1493    }
1494
1495    /**
1496     * Give dashboards outside My Jetpack, such as Boost's, the header's controls as a shared script.
1497     *
1498     * Bundling them instead would ship a second copy of the connection package next to the page's own.
1499     *
1500     * @return void
1501     */
1502    private static function maybe_enqueue_header_controls_script() {
1503        if ( self::is_my_jetpack_admin_request() || ! self::is_header_controls_enabled() ) {
1504            return;
1505        }
1506
1507        Assets::register_script(
1508            self::HEADER_CONTROLS_SCRIPT_HANDLE,
1509            '../build/header-controls.js',
1510            __FILE__,
1511            array(
1512                'dependencies' => array( self::DATA_SCRIPT_HANDLE ),
1513                'enqueue'      => true,
1514                'in_footer'    => true,
1515                'textdomain'   => 'jetpack-my-jetpack',
1516            )
1517        );
1518    }
1519
1520    /**
1521     *  Build flags for My Jetpack UI
1522     *
1523     *  @return array
1524     */
1525    public static function get_my_jetpack_flags() {
1526        $flags = array(
1527            'videoPressStats'          => Jetpack_Constants::is_true( 'JETPACK_MY_JETPACK_VIDEOPRESS_STATS_ENABLED' ),
1528            'showFullJetpackStatsCard' => class_exists( 'Jetpack' ) || Products\Stats::is_standalone_plugin_active(),
1529            // Only says which destination `manage_url` is: the legacy Stats page
1530            // caches its report and wants a `force_refresh` hint the dashboard does not.
1531            'premiumAnalyticsEnabled'  => Products\Stats::is_premium_analytics_enabled(),
1532            'showAiModuleToggle'       => Products\Jetpack_Ai::is_feature_ui_enabled(),
1533            'headerControls'           => self::is_header_controls_enabled(),
1534            'accountConnect'           => Feature_Flags::is_enabled( self::ACCOUNT_CONNECT_FLAG ),
1535            'overviewV1'               => self::is_overview_v1_enabled(),
1536        );
1537
1538        if ( $flags['accountConnect'] ) {
1539            $flags['showAiModuleToggleOnceConnected'] = Products\Jetpack_Ai::is_feature_ui_waiting_for_owner();
1540        }
1541
1542        return $flags;
1543    }
1544
1545    /**
1546     * Whether My Jetpack shows Overview v1.
1547     *
1548     * @since 6.10.0
1549     * @return bool
1550     */
1551    public static function is_overview_v1_enabled() {
1552        return Feature_Flags::is_enabled( self::OVERVIEW_V1_FLAG );
1553    }
1554
1555    /**
1556     * Whether the host shows My Jetpack.
1557     *
1558     * @return bool
1559     */
1560    private static function host_shows_my_jetpack() {
1561        /**
1562         * Filters whether the host shows My Jetpack, which lets Modules links open Features and settings move to My Jetpack.
1563         *
1564         * @since 6.11.0
1565         * @since $$next-version$$ False also keeps settings on the Settings screen.
1566         * @param bool $available True to allow the Features tab; false keeps the classic Modules and Settings screens, for a host where My Jetpack is not shown.
1567         */
1568        return (bool) apply_filters( 'jetpack_my_jetpack_modules_management_available', true );
1569    }
1570
1571    /**
1572     * Whether settings move to My Jetpack on this site: the flag is on, My Jetpack initializes, and the host shows it.
1573     *
1574     * It needs no admin menu, so URL builders that also run in REST use it, while page loads use `is_settings_consolidation_enabled()`.
1575     *
1576     * @since $$next-version$$
1577     *
1578     * @return bool
1579     */
1580    public static function is_settings_consolidation_on_for_site() {
1581        return Feature_Flags::is_enabled( self::SETTINGS_CONSOLIDATION_FLAG )
1582            && self::should_initialize()
1583            && self::host_shows_my_jetpack();
1584    }
1585
1586    /**
1587     * Whether settings move to My Jetpack: consolidation is on for the site and My Jetpack's page is available.
1588     *
1589     * Without the page, moved settings would have nowhere to go, so they stay on the Settings screen.
1590     *
1591     * @since 6.15.0
1592     *
1593     * @return bool
1594     */
1595    public static function is_settings_consolidation_enabled() {
1596        return self::is_settings_consolidation_on_for_site() && self::is_admin_page_available();
1597    }
1598
1599    /**
1600     * Point the module's configure URL at its settings panel while settings consolidation is on for the site.
1601     *
1602     * @since 6.16.0
1603     *
1604     * @return void
1605     */
1606    public static function register_settings_panel_urls() {
1607        foreach ( self::SETTINGS_PANEL_MODULES as $module ) {
1608            add_filter(
1609                'jetpack_module_configuration_url_' . $module,
1610                static function ( $url ) use ( $module ) {
1611                    // Only Jetpack's settings-search default: a module's own page, such as SEO's, is its real home.
1612                    return false === strpos( (string) $url, 'page=jetpack-settings#/settings?term=' )
1613                        ? $url
1614                        : self::resolve_settings_url( $module, $url );
1615                }
1616            );
1617        }
1618    }
1619
1620    /**
1621     * Where a module's settings live: its panel in Features while settings consolidation is on for the site, `$fallback` otherwise.
1622     *
1623     * @since 6.16.0
1624     *
1625     * @param string $module   The module slug.
1626     * @param string $fallback The URL to use when the settings stay where they were.
1627     * @return string
1628     */
1629    public static function resolve_settings_url( $module, $fallback ) {
1630        if ( ! in_array( $module, self::SETTINGS_PANEL_MODULES, true ) || ! self::is_settings_consolidation_on_for_site() ) {
1631            return $fallback;
1632        }
1633
1634        return add_query_arg(
1635            array(
1636                'page' => 'my-jetpack',
1637                'p'    => rawurlencode( '/features?configure=' . $module ),
1638            ),
1639            admin_url( 'admin.php' )
1640        );
1641    }
1642
1643    /**
1644     * Whether Modules links may use Features.
1645     *
1646     * @return bool
1647     */
1648    private static function is_modules_to_features_enabled() {
1649        return Feature_Flags::is_enabled( self::MODULES_TO_FEATURES_FLAG );
1650    }
1651
1652    /**
1653     * Build the state the My Jetpack "try the new SEO experience" opt-in card hydrates from.
1654     *
1655     * The card invites an existing self-hosted install to switch over to the new Jetpack SEO
1656     * dashboard (JETPACK-1700). Gating lives server-side, where the signals actually are. The
1657     * card shows only when all of:
1658     *
1659     * - the new SEO product is available â€” the `rsm_jetpack_seo` feature filter is on (the SEO
1660     *   package autoloads regardless, so `class_exists()` alone isn't enough; the filter is the
1661     *   real availability switch and the same one the SEO package gates its own surface behind);
1662     * - the site is self-hosted â€” WordPress.com (Simple + Atomic) decides its own SEO surface, so
1663     *   the opt-in card is for self-hosted installs only;
1664     * - the install hasn't opted in yet â€” the SEO surface isn't visible. On wpcom
1665     *   the SEO package's `is_seo_surface_visible()` short-circuits to `true`, so the
1666     *   "not visible yet" check also doubles as the self-hosted guard, but we check the platform
1667     *   explicitly for clarity.
1668     *
1669     * Referenced through `class_exists()` rather than a hard composer dependency: both packages
1670     * ship inside the Jetpack plugin and the SEO surface is feature-flagged, so a guarded read of
1671     * its public API keeps this from adding plumbing to consumers that don't load SEO.
1672     *
1673     * The on-success destination is computed by the opt-in endpoint itself; we only seed the card
1674     * with the same admin URL so the button has a sensible fallback before the request resolves.
1675     *
1676     * @return array{showCard: bool, redirect: string}
1677     */
1678    public static function get_seo_opt_in_state() {
1679        // Guard with method_exists rather than class_exists: an older bundled SEO package can ship
1680        // the Initializer class without this method, and class_exists alone would still fatal.
1681        $seo_initializer = 'Automattic\Jetpack\SEO\Initializer';
1682        // @phan-suppress-next-line PhanUndeclaredClassReference -- optional SEO package, guarded by method_exists.
1683        $show_card = method_exists( $seo_initializer, 'is_optin_available' ) && $seo_initializer::is_optin_available();
1684
1685        return array(
1686            'showCard' => $show_card,
1687            'redirect' => admin_url( 'admin.php?page=jetpack-seo' ),
1688        );
1689    }
1690
1691    /**
1692     * Echoes the admin page content.
1693     *
1694     * @return void
1695     */
1696    public static function admin_page() {
1697        // Asked again here rather than inferred from the redirect: with the wizard on a
1698        // connected user is deliberately left on this screen to finish setup, and anyone
1699        // who cannot connect never reaches it.
1700        if ( self::is_onboarding_takeover() ) {
1701            echo '<div id="my-jetpack-container"></div>';
1702            return;
1703        }
1704
1705        if ( self::should_render_wp_build() ) {
1706            jetpack_my_jetpack_my_jetpack_dashboard_wp_admin_render_page(); // @phan-suppress-current-line PhanUndeclaredFunction -- should_render_wp_build() checks function_exists(); defined in the generated build/pages/, which Phan excludes.
1707        }
1708    }
1709
1710    /**
1711     * Register the REST API routes.
1712     *
1713     * @return void
1714     */
1715    public static function register_rest_endpoints() {
1716        if ( self::is_offline_features_enabled() ) {
1717            ( new REST_Main_Features() )->register_rest_routes();
1718            return;
1719        }
1720
1721        new REST_Products();
1722        new REST_Purchases();
1723        ( new REST_Jetpack_AI_JWT() )->register_rest_route();
1724        new REST_Recommendations_Evaluation();
1725
1726        ( new REST_Onboarding() )->register_rest_routes();
1727        ( new REST_Main_Features() )->register_rest_routes();
1728
1729        Products::register_product_endpoints();
1730        Historically_Active_Modules::register_rest_endpoints();
1731        Jetpack_Manage::register_rest_endpoints();
1732        Red_Bubble_Notifications::register_rest_endpoints();
1733
1734        if ( self::is_overview_v1_enabled() ) {
1735            REST_Blog_Posts::register_rest_endpoints();
1736        }
1737
1738        register_rest_route(
1739            'my-jetpack/v1',
1740            'site',
1741            array(
1742                'methods'             => \WP_REST_Server::READABLE,
1743                'callback'            => __CLASS__ . '::get_site',
1744                'permission_callback' => __CLASS__ . '::permissions_callback',
1745            )
1746        );
1747
1748        register_rest_route(
1749            'my-jetpack/v1',
1750            'site/dismiss-welcome-banner',
1751            array(
1752                'methods'             => \WP_REST_Server::EDITABLE,
1753                'callback'            => __CLASS__ . '::dismiss_welcome_banner',
1754                'permission_callback' => __CLASS__ . '::permissions_callback',
1755            )
1756        );
1757    }
1758
1759    /**
1760     * Check user capability to access the endpoint.
1761     *
1762     * @access public
1763     * @static
1764     *
1765     * @return true|WP_Error
1766     */
1767    public static function permissions_callback() {
1768        return current_user_can( 'manage_options' );
1769    }
1770
1771    /**
1772     * Return true if we should initialize the My Jetpack admin page.
1773     */
1774    public static function should_initialize() {
1775        $should = true;
1776
1777        if ( ( new Status() )->is_offline_mode() ) {
1778            $should = self::is_offline_features_enabled();
1779        }
1780
1781        /**
1782         * Allows filtering whether My Jetpack should be initialized.
1783         * Outside REST requests, once the user is set, this cannot enable offline Features for a user without access.
1784         *
1785         * @since 0.5.0-alpha
1786         *
1787         * @param bool $shoud_initialize Should we initialize My Jetpack?
1788         */
1789        $should = apply_filters( 'jetpack_my_jetpack_should_initialize', $should );
1790
1791        // REST authenticates after route registration; its permission callbacks check access.
1792        if ( self::is_offline_features_enabled() && ! Jetpack_Constants::is_true( 'REST_REQUEST' ) && function_exists( 'wp_get_current_user' ) && did_action( 'set_current_user' ) && ! self::current_user_can_access_page() ) {
1793            return false;
1794        }
1795
1796        return $should;
1797    }
1798
1799    /**
1800     * Whether the site uses the local Features entry instead of the connected dashboard.
1801     *
1802     * @since 6.10.0
1803     * @return bool
1804     */
1805    public static function is_offline_features_enabled() {
1806        if ( ! ( new Status() )->is_offline_mode() ) {
1807            return false;
1808        }
1809
1810        /**
1811         * Enable local Features data and entry in offline mode.
1812         *
1813         * @since 6.10.0
1814         * @since 6.15.0 Enabled by default.
1815         * @param bool $enabled Whether to enable offline Features. Default true.
1816         */
1817        return (bool) apply_filters( 'jetpack_my_jetpack_offline_features', true );
1818    }
1819
1820    /**
1821     * Hook into several connection-based actions to update the historically active Jetpack modules
1822     * If the transient that indicates the list needs to be synced, update it and delete the transient
1823     *
1824     * @return void
1825     */
1826    public static function setup_historically_active_jetpack_modules_sync() {
1827        // yummmm. ham.
1828        $ham = new Historically_Active_Modules();
1829        if ( get_transient( $ham::UPDATE_HISTORICALLY_ACTIVE_JETPACK_MODULES_KEY ) && ! wp_doing_ajax() ) {
1830            $ham::update_historically_active_jetpack_modules();
1831            delete_transient( $ham::UPDATE_HISTORICALLY_ACTIVE_JETPACK_MODULES_KEY );
1832        }
1833
1834        $actions = array(
1835            'jetpack_site_registered',
1836            'jetpack_user_authorized',
1837            'activated_plugin',
1838        );
1839
1840        foreach ( $actions as $action ) {
1841            add_action( $action, array( $ham, 'queue_historically_active_jetpack_modules_update' ), 5 );
1842        }
1843
1844        // Modules are often updated async, so we need to update them right away as there will sometimes be no page reload.
1845        add_action( 'jetpack_activate_module', array( $ham, 'update_historically_active_jetpack_modules' ), 5 );
1846    }
1847
1848    /**
1849     * Site full-data endpoint.
1850     *
1851     * @return object Site data.
1852     */
1853    public static function get_site() {
1854        $site_id           = \Jetpack_Options::get_option( 'id' );
1855        $wpcom_endpoint    = sprintf( '/sites/%d?force=wpcom', $site_id );
1856        $wpcom_api_version = '1.1';
1857        $response          = Client::wpcom_json_api_request_as_blog( $wpcom_endpoint, $wpcom_api_version );
1858        $response_code     = wp_remote_retrieve_response_code( $response );
1859        $body              = json_decode( wp_remote_retrieve_body( $response ) );
1860
1861        if ( is_wp_error( $response ) || empty( $response['body'] ) ) {
1862            return new WP_Error( 'site_data_fetch_failed', 'Site data fetch failed', array( 'status' => $response_code ) );
1863        }
1864
1865        return rest_ensure_response( $body );
1866    }
1867
1868    /**
1869     * Populates the self::$site_info var with site data from the /sites/%d endpoint
1870     *
1871     * @return object|WP_Error
1872     */
1873    public static function get_site_info() {
1874        static $site_info = null;
1875
1876        if ( $site_info !== null ) {
1877            return $site_info;
1878        }
1879
1880        // Check for a cached value before doing lookup
1881        $stored_site_info = get_transient( self::MY_JETPACK_SITE_INFO_TRANSIENT_KEY );
1882        if ( $stored_site_info !== false ) {
1883            return $stored_site_info;
1884        }
1885
1886        $response = self::get_site();
1887        if ( is_wp_error( $response ) ) {
1888            return $response;
1889        }
1890        $site_info = $response->data;
1891        set_transient( self::MY_JETPACK_SITE_INFO_TRANSIENT_KEY, $site_info, DAY_IN_SECONDS );
1892
1893        return $site_info;
1894    }
1895
1896    /**
1897     * Returns whether a site has been determined "commercial" or not.
1898     *
1899     * @return bool|null
1900     */
1901    public static function is_commercial_site() {
1902        if ( is_wp_error( self::$site_info ) ) {
1903            return null;
1904        }
1905
1906        return empty( self::$site_info->options->is_commercial ) ? false : self::$site_info->options->is_commercial;
1907    }
1908
1909    /**
1910     * Check if site is registered (has been connected before).
1911     *
1912     * @return bool
1913     */
1914    public static function is_registered() {
1915        return (bool) \Jetpack_Options::get_option( 'id' );
1916    }
1917
1918    /**
1919     * Dismiss the welcome banner.
1920     *
1921     * @return \WP_REST_Response
1922     */
1923    public static function dismiss_welcome_banner() {
1924        \Jetpack_Options::update_option( 'dismissed_welcome_banner', true );
1925        return rest_ensure_response( array( 'success' => true ) );
1926    }
1927
1928    /**
1929     * Returns "yes" if the site has file write access to the plugins folder, "no" otherwise.
1930     *
1931     * @return string
1932     **/
1933    public static function has_file_system_write_access() {
1934
1935        $cache = get_transient( 'my_jetpack_write_access' );
1936
1937        if ( false !== $cache ) {
1938            return $cache;
1939        }
1940
1941        if ( ! function_exists( 'get_filesystem_method' ) ) {
1942            require_once ABSPATH . 'wp-admin/includes/file.php';
1943        }
1944
1945        require_once ABSPATH . 'wp-admin/includes/template.php';
1946
1947        $write_access = 'no';
1948
1949        $filesystem_method = get_filesystem_method( array(), WP_PLUGIN_DIR );
1950        if ( 'direct' === $filesystem_method ) {
1951            $write_access = 'yes';
1952        }
1953
1954        if ( 'no' === $write_access ) {
1955            ob_start();
1956            $filesystem_credentials_are_stored = request_filesystem_credentials( self_admin_url() );
1957            ob_end_clean();
1958
1959            if ( $filesystem_credentials_are_stored ) {
1960                $write_access = 'yes';
1961            }
1962        }
1963
1964        set_transient( 'my_jetpack_write_access', $write_access, 30 * MINUTE_IN_SECONDS );
1965
1966        return $write_access;
1967    }
1968
1969    /**
1970     * Get container IDC for the IDC screen.
1971     *
1972     * @return string
1973     */
1974    public static function get_idc_container_id() {
1975        return static::IDC_CONTAINER_ID;
1976    }
1977
1978    /**
1979     * Conditionally append the red bubble notification to the "Jetpack" menu item if there are alerts to show.
1980     *
1981     * On My Jetpack page: Uses blocking behavior to fetch fresh data.
1982     * On other admin pages: Uses cached data only to avoid blocking, with async JS fetch if cache is empty.
1983     *
1984     * @return void
1985     */
1986    public static function maybe_show_red_bubble() {
1987        global $pagenow;
1988
1989        // Don't show red bubble alerts for non-admin users
1990        // These alerts are generally only actionable for admins
1991        if ( ! current_user_can( 'manage_options' ) ) {
1992            return;
1993        }
1994
1995        // Don't show any red bubbles when Jetpack is disconnected
1996        // Users can't act on most alerts without a connection
1997        $connection = new Connection_Manager();
1998        if ( ! $connection->is_connected() ) {
1999            return;
2000        }
2001
2002        // Check if we're on the My Jetpack page.
2003        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2004        $page               = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
2005        $is_my_jetpack_page = $pagenow === 'admin.php' && $page === 'my-jetpack';
2006
2007        if ( $is_my_jetpack_page ) {
2008            // On My Jetpack page: use blocking behavior for fresh data.
2009            add_filter( 'my_jetpack_red_bubble_notification_slugs', array( Red_Bubble_Notifications::class, 'add_red_bubble_alerts' ) );
2010            $red_bubble_alerts = Red_Bubble_Notifications::get_red_bubble_alerts();
2011        } else {
2012            // On other pages: use cached data only to avoid blocking.
2013            $cached_alerts = Red_Bubble_Notifications::get_cached_alerts();
2014
2015            if ( false === $cached_alerts ) {
2016                // No cache: warm it asynchronously via JS. Register a hidden zero-count
2017                // placeholder so Menu_Renderer emits a `my-jetpack` badge element the
2018                // warmer can reveal (see async-notification-bubble.ts) without a reload.
2019                Menu_Badges::init(); // idempotent; wires the renderer.
2020                Notification_Counts::register(
2021                    'my-jetpack',
2022                    array(
2023                        'menu_slug' => 'my-jetpack',
2024                        'count'     => 0,
2025                        'type'      => 'count',
2026                    )
2027                );
2028                add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_red_bubble_script' ) );
2029                return;
2030            }
2031
2032            $red_bubble_alerts = $cached_alerts;
2033        }
2034
2035        // Filter out silent alerts.
2036        $red_bubble_alerts = array_filter(
2037            $red_bubble_alerts,
2038            function ( $alert ) {
2039                return empty( $alert['is_silent'] );
2040            }
2041        );
2042
2043        // Report each non-silent alert to the central menu-badges registry as an
2044        // attention entry (count 1). The registry + renderer own the badge.
2045        Menu_Badges::init(); // idempotent; wires the renderer.
2046
2047        // Connection errors are owned by the Error Handler (the single source of truth):
2048        // surface any it reports for this viewer as their own attention entry. Read live
2049        // rather than from the red-bubble transient, since that cache is not viewer-keyed.
2050        $has_connection_error = self::has_connection_error();
2051        if ( $has_connection_error ) {
2052            Notification_Counts::register(
2053                'my-jetpack-connection-error',
2054                array(
2055                    'menu_slug' => 'my-jetpack',
2056                    'type'      => 'attention',
2057                )
2058            );
2059        }
2060
2061        $counted_elsewhere = self::get_alerts_counted_elsewhere();
2062        foreach ( array_keys( $red_bubble_alerts ) as $slug ) {
2063            if ( in_array( $slug, $counted_elsewhere, true ) ) {
2064                continue;
2065            }
2066            // The missing-connection slug and a connection error describe the same broken
2067            // connection; count it once (the error, above, is the more specific signal).
2068            if ( $has_connection_error && 'missing-connection' === $slug ) {
2069                continue;
2070            }
2071            Notification_Counts::register(
2072                'my-jetpack-' . $slug,
2073                array(
2074                    'menu_slug' => 'my-jetpack',
2075                    'type'      => 'attention',
2076                )
2077            );
2078        }
2079    }
2080
2081    /**
2082     * Whether the Connection Error Handler reports a displayable connection error
2083     * for the current viewer.
2084     *
2085     * Read live (not via the red-bubble transient): get_displayable_errors() is a
2086     * cached option read that the Error Handler already scopes and caches per viewer.
2087     * Guarded for the mid-plugin-update window, where a stale connection package
2088     * predating the method can be loaded.
2089     *
2090     * @return bool
2091     */
2092    private static function has_connection_error() {
2093        if ( ! class_exists( Connection_Error_Handler::class ) || ! method_exists( Connection_Error_Handler::class, 'get_displayable_errors' ) ) {
2094            return false;
2095        }
2096        return ! empty( Connection_Error_Handler::get_instance()->get_displayable_errors() );
2097    }
2098
2099    /**
2100     * Alerts left out of the My Jetpack badge because another menu item already counts them.
2101     *
2102     * The Protect page reports its own count, from the standalone plugin (class-jetpack-protect.php::admin_page_init())
2103     * or the Protect package's dashboard; without either, nobody else counts the threats, so they must stay.
2104     *
2105     * @return string[] Alert slugs.
2106     */
2107    private static function get_alerts_counted_elsewhere() {
2108        // Without has_protect_page(), use the standalone plugin check this relied on before that method existed.
2109        $has_protect_page = method_exists( Products\Protect::class, 'has_protect_page' ) ? Products\Protect::has_protect_page() : Products\Protect::is_standalone_plugin_active();
2110        return $has_protect_page ? array( 'protect_has_threats' ) : array();
2111    }
2112
2113    /**
2114     * Enqueue the notification bubble script.
2115     * Fetches fresh alert data via REST API without blocking page load.
2116     *
2117     * @return void
2118     */
2119    public static function enqueue_red_bubble_script() {
2120        Assets::register_script(
2121            'my-jetpack-notification-bubble',
2122            '../build/async-notification-bubble.js',
2123            __FILE__,
2124            array(
2125                'enqueue'   => true,
2126                'in_footer' => true,
2127            )
2128        );
2129        wp_localize_script(
2130            'my-jetpack-notification-bubble',
2131            'myJetpackNotificationBubble',
2132            array( 'excludedAlerts' => self::get_alerts_counted_elsewhere() )
2133        );
2134    }
2135
2136    /**
2137     * Get list of module names sorted by their recommendation score
2138     *
2139     * @return array|null
2140     */
2141    public static function get_recommended_modules() {
2142        $recommendations_evaluation = \Jetpack_Options::get_option( 'recommendations_evaluation', null );
2143
2144        if ( empty( $recommendations_evaluation ) || ! is_array( $recommendations_evaluation ) ) {
2145            return null;
2146        }
2147
2148        arsort( $recommendations_evaluation ); // Sort by scores in descending order
2149
2150        return array_keys( $recommendations_evaluation ); // Get only module names
2151    }
2152}