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