Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.52% covered (warning)
79.52%
66 / 83
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Settings_React_Page
78.75% covered (warning)
78.75%
63 / 80
58.33% covered (warning)
58.33%
7 / 12
55.35
0.00% covered (danger)
0.00%
0 / 1
 should_load_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 maybe_load_wp_build
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 should_render_wp_build
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_wp_build_script_dependencies
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_page_hook
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 hide_menu_item
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 add_page_actions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 add_fallback_redirects
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 can_access_settings
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
18.12
 page_render
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
2.06
 additional_styles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 page_admin_scripts
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2/**
3 * The Jetpack Settings admin page.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Admin_UI\Admin_Menu;
9use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Status;
12
13require_once __DIR__ . '/class.jetpack-admin-page.php';
14require_once __DIR__ . '/class-jetpack-redux-state-helper.php';
15require_once __DIR__ . '/class-jetpack-wp-build-page.php';
16
17/**
18 * Renders the Settings app, whose connection screens also serve unconnected sites.
19 *
20 * @since 16.3
21 */
22class Jetpack_Settings_React_Page extends Jetpack_Admin_Page {
23    /**
24     * Register the page before the site connects, for the connection screen.
25     *
26     * @var bool
27     */
28    protected $dont_show_if_not_active = false;
29
30    /**
31     * Whether the REST API is off and the page falls back to the modules list.
32     *
33     * @var bool
34     */
35    protected $is_redirecting = false;
36
37    /**
38     * The wp-build route's page id, which must not be the `jetpack-settings` menu slug.
39     *
40     * @var string
41     */
42    const WP_BUILD_PAGE_ID = 'jetpack-settings-dashboard';
43
44    /**
45     * Whether this request loaded the wp-build route.
46     *
47     * @var bool
48     */
49    private $is_wp_build_loaded = false;
50
51    /**
52     * Whether this request should load wp-build.
53     *
54     * An IDC-blocked page shows only the IDC banner, which the wp-build template would hide.
55     *
56     * @since 16.3
57     *
58     * @return bool
59     */
60    public function should_load_wp_build() {
61        if ( ! is_admin() ) {
62            return false;
63        }
64
65        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only.
66        if ( ! isset( $_GET['page'] ) || 'jetpack-settings' !== sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) {
67            return false;
68        }
69
70        return ! $this->block_page_rendering_for_idc();
71    }
72
73    /**
74     * Load wp-build before the admin menu is built, on the Settings request only.
75     *
76     * @since 16.3
77     *
78     * @return void
79     */
80    public function maybe_load_wp_build() {
81        if ( $this->should_load_wp_build() ) {
82            $this->is_wp_build_loaded = Jetpack_WP_Build_Page::load( self::WP_BUILD_PAGE_ID );
83        }
84    }
85
86    /**
87     * Whether this request renders through wp-build.
88     *
89     * @since 16.3
90     *
91     * @return bool
92     */
93    public function should_render_wp_build() {
94        return $this->is_wp_build_loaded && function_exists( 'jetpack_plugin_jetpack_settings_dashboard_wp_admin_render_page' );
95    }
96
97    /**
98     * The route bundle's classic script dependencies (e.g. `lodash`), which wp-build registers
99     * as a script module without them, leaving globals like `window.lodash` undefined.
100     *
101     * @since 16.3
102     *
103     * @return string[]
104     */
105    protected function get_wp_build_script_dependencies() {
106        $asset_path = JETPACK__PLUGIN_DIR . 'build/routes/settings/content.min.asset.php';
107        if ( ! file_exists( $asset_path ) ) {
108            return array();
109        }
110
111        $asset = include $asset_path;
112        return $asset['dependencies'] ?? array();
113    }
114
115    /**
116     * Register the page; only its sidebar entry keeps the Settings access gate.
117     *
118     * Shares the bottom tier with Beta Tester so it lands below the alphabetical run;
119     * the upsell still renders underneath because Admin_Menu appends it after sorting.
120     *
121     * @return string The page hook.
122     */
123    public function get_page_hook() {
124        if ( ! $this->can_access_settings() ) {
125            add_filter( 'jetpack_admin_menu_visibility', array( __CLASS__, 'hide_menu_item' ) );
126        }
127
128        $position = defined( Admin_Menu::class . '::POSITION_LAST' ) ? Admin_Menu::POSITION_LAST : 998;
129
130        $hook = Admin_Menu::add_menu(
131            __( 'Settings', 'jetpack' ),
132            __( 'Settings', 'jetpack' ),
133            'jetpack_admin_page',
134            'jetpack-settings',
135            array( $this, 'render' ),
136            $position,
137            array( 'key' => 'jetpack-settings' )
138        );
139
140        // The IDC banner is a core-style notice, and during IDC it is all this page shows.
141        remove_action( "load-$hook", array( Admin_Menu::class, 'hide_core_admin_notices' ) );
142
143        return $hook;
144    }
145
146    /**
147     * Hide the sidebar entry from users who cannot use Settings.
148     *
149     * @param array $states Menu item key to visibility state.
150     * @return array
151     */
152    public static function hide_menu_item( $states ) {
153        $states['jetpack-settings'] = 'hidden';
154        return $states;
155    }
156
157    /**
158     * Add page actions.
159     *
160     * @param string $hook Hook of current page.
161     * @return void
162     */
163    public function add_page_actions( $hook ) {
164        add_action( "load-$hook", array( $this, 'add_fallback_redirects' ) );
165    }
166
167    /**
168     * Send browsers that cannot run the app to the modules list.
169     *
170     * @return void
171     */
172    public function add_fallback_redirects() {
173        if ( ! $this->is_rest_api_enabled() ) {
174            $this->is_redirecting = true;
175            add_action( 'admin_head', array( $this, 'add_fallback_head_meta' ) );
176        }
177
178        add_action( 'admin_head', array( $this, 'add_noscript_head_meta' ) );
179    }
180
181    /**
182     * Determine whether a user can access the Jetpack Settings page.
183     *
184     * Rules are:
185     * - user is allowed to see the Jetpack Admin
186     * - site is connected or in offline mode
187     * - non-admins only need access to the settings when there are modules they can manage.
188     *
189     * @return bool $can_access_settings Can the user access settings.
190     */
191    private function can_access_settings() {
192        $connection = new Connection_Manager( 'jetpack' );
193        $status     = new Status();
194
195        // User must have the necessary permissions to see the Jetpack settings pages.
196        if ( ! current_user_can( 'edit_posts' ) ) {
197            return false;
198        }
199
200        // In offline mode, allow access to admins.
201        if ( $status->is_offline_mode() && current_user_can( 'manage_options' ) ) {
202            return true;
203        }
204
205        // If not in offline mode but site is not connected, bail.
206        if ( ! Jetpack::is_connection_ready() ) {
207            return false;
208        }
209
210        /*
211         * Additional checks for non-admins.
212        */
213        if ( ! current_user_can( 'manage_options' ) ) {
214            // If the site isn't connected at all, bail.
215            if ( ! $connection->has_connected_owner() ) {
216                return false;
217            }
218
219            /*
220             * If they haven't connected their own account yet,
221             * they have no use for the settings page.
222             * They will not be able to manage any settings.
223             */
224            if ( ! $connection->is_user_connected() ) {
225                return false;
226            }
227
228            /*
229             * Non-admins only have access to settings
230             * for the following modules:
231             * - Publicize
232             * - Post By Email
233             * If those modules are not available, bail.
234             */
235            if (
236                ! Jetpack::is_module_active( 'post-by-email' )
237                    && (
238                        ! Jetpack::is_module_active( 'publicize' ) ||
239                        ! current_user_can( 'publish_posts' )
240                    )
241            ) {
242                return false;
243            }
244        }
245
246        // fallback.
247        return true;
248    }
249
250    /**
251     * Add action to render page specific HTML.
252     *
253     * @return void
254     */
255    public function page_render() {
256        /** This action is already documented in class.jetpack-admin-page.php */
257        do_action( 'jetpack_notices' );
258
259        if ( $this->should_render_wp_build() ) {
260            jetpack_plugin_jetpack_settings_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.
261            return;
262        }
263
264        echo '<p class="jp-settings-build-error">';
265        esc_html_e( 'Jetpack Settings could not load. Try running: ', 'jetpack' );
266        echo '<code>pnpm jetpack build plugins/jetpack</code>';
267        echo '</p>';
268    }
269
270    /**
271     * Load styles for static page.
272     */
273    public function additional_styles() {
274        // The route bundle carries these styles.
275        if ( $this->should_render_wp_build() ) {
276            return;
277        }
278
279        Jetpack_Admin_Page::load_wrapper_styles();
280    }
281
282    /**
283     * Load admin page scripts.
284     */
285    public function page_admin_scripts() {
286        // A fallback page, or one without the route (IDC-blocked or unbuilt), renders no app to feed.
287        if ( $this->is_redirecting || ! $this->should_render_wp_build() ) {
288            return;
289        }
290
291        $status          = new Status();
292        $is_offline_mode = $status->is_offline_mode();
293        $site_suffix     = $status->get_site_suffix();
294
295        // wp-build enqueues the route bundle; this handle carries the inline state and classic dependencies.
296        wp_register_script( 'react-plugin', false, $this->get_wp_build_script_dependencies(), JETPACK__VERSION, true );
297        wp_enqueue_script( 'react-plugin' );
298
299        $blog_id_prop = '';
300        if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
301            $blog_id = Connection_Manager::get_site_id( true );
302            if ( $blog_id ) {
303                $blog_id_prop = ', currentBlogID: "' . (int) $blog_id . '"';
304            }
305        }
306
307        if ( ! $is_offline_mode && Jetpack::is_connection_ready() ) {
308            // Required for Analytics.
309            wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
310        }
311
312        // Add objects to be passed to the initial state of the app.
313        // Use wp_add_inline_script instead of wp_localize_script, see https://core.trac.wordpress.org/ticket/25280.
314        wp_add_inline_script( 'react-plugin', 'var Initial_State=' . wp_json_encode( Jetpack_Redux_State_Helper::get_initial_state(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';', 'before' );
315
316        // This will set the default URL of the jp_redirects lib.
317        wp_add_inline_script( 'react-plugin', 'var jetpack_redirects = { currentSiteRawUrl: "' . $site_suffix . '"' . $blog_id_prop . ' };', 'before' );
318
319        // Adds Connection package initial state.
320        Connection_Initial_State::render_script( 'react-plugin' );
321    }
322}