Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.00% covered (danger)
4.00%
3 / 75
11.11% covered (danger)
11.11%
1 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Scan
4.11% covered (danger)
4.11%
3 / 73
11.11% covered (danger)
11.11%
1 / 9
576.07
0.00% covered (danger)
0.00%
0 / 1
 initialize
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 load_wp_build
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 is_scan_admin_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 bridge_wp_build_enqueue
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 add_wp_admin_submenu
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 is_available
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 admin_init
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 render_page_fallback
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 register_rest_routes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Primary class for the Jetpack Scan package.
4 *
5 * @package automattic/jetpack-scan-page
6 */
7
8namespace Automattic\Jetpack\Scan_Page;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14use Automattic\Jetpack\Admin_UI\Admin_Menu;
15use Automattic\Jetpack\Connection\Manager as Connection_Manager;
16use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
17use function add_action;
18use function add_filter;
19use function apply_filters;
20use function call_user_func;
21use function current_user_can;
22use function did_action;
23use function do_action;
24use function function_exists;
25use function is_admin;
26use function is_multisite;
27use function remove_action;
28use function remove_all_actions;
29use function sanitize_text_field;
30use function wp_unslash;
31
32/**
33 * Class Jetpack_Scan
34 *
35 * Registers the Scan admin page and its REST routes inside the main
36 * Jetpack plugin. The page bundle is built by `@wordpress/build`
37 * (mirroring Newsletter / Forms); this class wires the wp-admin menu
38 * + the bridges that route our user-facing slug to wp-build's
39 * auto-generated enqueue / render functions.
40 */
41class Jetpack_Scan {
42
43    /**
44     * URL-facing menu slug.
45     *
46     * @var string
47     */
48    const PAGE_SLUG = 'jetpack-scan';
49
50    /**
51     * Internal slug emitted by `@wordpress/build` (`wpPlugin.pages[0]`
52     * plus the `-wp-admin` suffix the build template appends). Used to
53     * find the auto-generated render / enqueue functions.
54     *
55     * @var string
56     */
57    const WP_BUILD_SLUG = 'jetpack-scan-wp-admin';
58
59    /**
60     * Filter name that gates the wp-build–based Scan dashboard.
61     *
62     * When this filter returns true, the new wp-admin Scan page is
63     * registered and rendered. Default false during the modernization
64     * roll-out — the package registers no admin menu and changes
65     * nothing about the existing Jetpack UI when this filter is off.
66     *
67     * @var string
68     */
69    const MODERNIZATION_FILTER = 'rsm_jetpack_ui_modernization_scan';
70
71    /**
72     * Entry point. Idempotent: safe to call from multiple bootstraps.
73     */
74    public static function initialize() {
75        if ( did_action( 'jetpack_scan_page_initialized' ) ) {
76            return;
77        }
78
79        if ( ! (bool) apply_filters( self::MODERNIZATION_FILTER, false ) ) {
80            return;
81        }
82
83        self::load_wp_build();
84        self::bridge_wp_build_enqueue();
85
86        add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ) );
87        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
88        add_filter( 'jetpack_package_versions', array( Package_Version::class, 'send_package_version_to_tracker' ) );
89
90        /**
91         * Fires once the Jetpack Scan package has wired its hooks.
92         *
93         * @since 0.1.0
94         */
95        do_action( 'jetpack_scan_page_initialized' );
96    }
97
98    /**
99     * Load wp-build generated registration files. Mirrors Newsletter / Forms.
100     */
101    public static function load_wp_build() {
102        // The polyfills force-replace core script handles (notably
103        // `wp-private-apis`, a stateful singleton shared by every @wordpress
104        // package on the page) during `wp_default_scripts`. Scope registration
105        // to the Scan admin page so it never runs on other admin pages such as
106        // the block editor. `$_GET['page']` is reliable this early — it's the
107        // raw query param, available well before `current_screen` exists.
108        if ( self::is_scan_admin_request() ) {
109            WP_Build_Polyfills::register(
110                'jetpack-scan',
111                array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
112            );
113        }
114
115        $wp_build_index = dirname( __DIR__ ) . '/build/build.php';
116        if ( file_exists( $wp_build_index ) ) {
117            require_once $wp_build_index;
118        }
119
120        // `page.php` ships an `admin_init` interceptor that takes over our
121        // slug with a standalone (non-wp-admin) render. We want the
122        // wp-admin integrated experience, so unregister it as soon as it's
123        // loaded.
124        remove_action(
125            'admin_init',
126            'jetpack_scan_jetpack_scan_intercept_render'
127        );
128    }
129
130    /**
131     * Whether the current request targets the Scan admin page.
132     *
133     * Used to scope the wp-build polyfill registration (which force-replaces
134     * core script handles) to this one page, so it never affects other admin
135     * pages. Reads the menu page slug directly so it is cheap and safe to call
136     * at plugin-load time, before `current_screen` exists.
137     *
138     * @return bool True when serving the Scan page in wp-admin.
139     */
140    public static function is_scan_admin_request() {
141        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
142        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
143            return false;
144        }
145
146        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
147        return self::PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
148    }
149
150    /**
151     * Bridge wp-build's auto-generated enqueue function — which checks for
152     * `?page=jetpack-scan-wp-admin` — to our user-facing slug
153     * `?page=jetpack-scan`. Hooked at priority 9 so the wp-build copy
154     * (registered at priority 10) sees the original `$_GET['page']` and
155     * skips its own enqueue.
156     */
157    public static function bridge_wp_build_enqueue() {
158        add_action(
159            'admin_enqueue_scripts',
160            static function ( $hook_suffix ) {
161                // phpcs:ignore WordPress.Security.NonceVerification.Recommended
162                if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) {
163                    return;
164                }
165
166                $enqueue_fn = 'jetpack_scan_jetpack_scan_wp_admin_enqueue_scripts';
167                if ( ! function_exists( $enqueue_fn ) ) {
168                    return;
169                }
170
171                // phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
172                $original     = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : null;
173                $_GET['page'] = self::WP_BUILD_SLUG;
174                // @phan-suppress-next-line PhanUndeclaredFunctionInCallable -- Function is generated by @wordpress/build into build/pages/jetpack-scan/page-wp-admin.php, which is outside Phan's analysis scope. The function_exists() guard above protects the call at runtime.
175                call_user_func( $enqueue_fn, $hook_suffix );
176                if ( null === $original ) {
177                    unset( $_GET['page'] );
178                } else {
179                    $_GET['page'] = $original;
180                }
181                // phpcs:enable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
182            },
183            9
184        );
185    }
186
187    /**
188     * Register the Scan submenu under Jetpack.
189     *
190     * @return string|null The resulting page's hook suffix, if registered.
191     */
192    public static function add_wp_admin_submenu() {
193        if ( ! self::is_available() ) {
194            return null;
195        }
196
197        $render_fn = 'jetpack_scan_jetpack_scan_wp_admin_render_page';
198        $render    = function_exists( $render_fn )
199            ? $render_fn
200            : array( __CLASS__, 'render_page_fallback' );
201
202        $page_suffix = Admin_Menu::add_menu(
203            /** "Scan" is a product name, do not translate. */
204            'Scan',
205            'Scan',
206            'manage_options',
207            self::PAGE_SLUG,
208            $render,
209            null,
210            array(
211                'product' => 'scan',
212                'key'     => 'jetpack-scan',
213            )
214        );
215
216        if ( $page_suffix ) {
217            add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
218        }
219
220        return $page_suffix;
221    }
222
223    /**
224     * Whether the Scan page should be shown to the current user.
225     *
226     * @return bool
227     */
228    public static function is_available() {
229        if ( is_multisite() ) {
230            return false;
231        }
232
233        if ( ! current_user_can( 'manage_options' ) ) {
234            return false;
235        }
236
237        return ( new Connection_Manager() )->is_user_connected();
238    }
239
240    /**
241     * Fires when the admin page is loaded.
242     *
243     * Silences the standard wp-admin notice channels so JITMs and
244     * plugin-update messages don't reflow the focused Scan layout
245     * mid-scan or while a fix modal is open.
246     */
247    public static function admin_init() {
248        remove_all_actions( 'admin_notices' );
249        remove_all_actions( 'all_admin_notices' );
250    }
251
252    /**
253     * Fallback render — used only if the wp-build registration file
254     * isn't loaded (e.g. the package wasn't built yet). Renders a bare
255     * mount node so the page doesn't 500 in dev.
256     */
257    public static function render_page_fallback() {
258        ?>
259            <div id="jetpack-scan-page-root"></div>
260        <?php
261    }
262
263    /**
264     * Register the REST routes backing the Scan UI.
265     */
266    public static function register_rest_routes() {
267        REST_Controller::register_rest_routes();
268    }
269}