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