Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.00% covered (danger)
3.00%
3 / 100
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Scan
3.06% covered (danger)
3.06%
3 / 98
10.00% covered (danger)
10.00%
1 / 10
964.81
0.00% covered (danger)
0.00%
0 / 1
 initialize
0.00% covered (danger)
0.00%
0 / 11
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
 fix_boot_import_map_ordering
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 add_wp_admin_submenu
0.00% covered (danger)
0.00%
0 / 17
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_print_inline_script_tag;
31use function wp_scripts;
32use function wp_unslash;
33
34/**
35 * Class Jetpack_Scan
36 *
37 * Registers the Scan admin page and its REST routes inside the main
38 * Jetpack plugin. The page bundle is built by `@wordpress/build`
39 * (mirroring Newsletter / Forms); this class wires the wp-admin menu
40 * + the bridges that route our user-facing slug to wp-build's
41 * auto-generated enqueue / render functions.
42 */
43class Jetpack_Scan {
44
45    /**
46     * URL-facing menu slug.
47     *
48     * @var string
49     */
50    const PAGE_SLUG = 'jetpack-scan';
51
52    /**
53     * Internal slug emitted by `@wordpress/build` (`wpPlugin.pages[0]`
54     * plus the `-wp-admin` suffix the build template appends). Used to
55     * find the auto-generated render / enqueue functions.
56     *
57     * @var string
58     */
59    const WP_BUILD_SLUG = 'jetpack-scan-wp-admin';
60
61    /**
62     * Filter name that gates the wp-build–based Scan dashboard.
63     *
64     * When this filter returns true, the new wp-admin Scan page is
65     * registered and rendered. Default false during the modernization
66     * roll-out — the package registers no admin menu and changes
67     * nothing about the existing Jetpack UI when this filter is off.
68     *
69     * @var string
70     */
71    const MODERNIZATION_FILTER = 'rsm_jetpack_ui_modernization_scan';
72
73    /**
74     * Entry point. Idempotent: safe to call from multiple bootstraps.
75     */
76    public static function initialize() {
77        if ( did_action( 'jetpack_scan_page_initialized' ) ) {
78            return;
79        }
80
81        if ( ! (bool) apply_filters( self::MODERNIZATION_FILTER, false ) ) {
82            return;
83        }
84
85        self::load_wp_build();
86        self::fix_boot_import_map_ordering();
87        self::bridge_wp_build_enqueue();
88
89        add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ) );
90        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
91        add_filter( 'jetpack_package_versions', array( Package_Version::class, 'send_package_version_to_tracker' ) );
92
93        /**
94         * Fires once the Jetpack Scan package has wired its hooks.
95         *
96         * @since 0.1.0
97         */
98        do_action( 'jetpack_scan_page_initialized' );
99    }
100
101    /**
102     * Load wp-build generated registration files. Mirrors Newsletter / Forms.
103     */
104    public static function load_wp_build() {
105        // The polyfills force-replace core script handles (notably
106        // `wp-private-apis`, a stateful singleton shared by every @wordpress
107        // package on the page) during `wp_default_scripts`. Scope registration
108        // to the Scan admin page so it never runs on other admin pages such as
109        // the block editor. `$_GET['page']` is reliable this early — it's the
110        // raw query param, available well before `current_screen` exists.
111        if ( self::is_scan_admin_request() ) {
112            WP_Build_Polyfills::register(
113                'jetpack-scan',
114                array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
115            );
116        }
117
118        $wp_build_index = dirname( __DIR__ ) . '/build/build.php';
119        if ( file_exists( $wp_build_index ) ) {
120            require_once $wp_build_index;
121        }
122
123        // `page.php` ships an `admin_init` interceptor that takes over our
124        // slug with a standalone (non-wp-admin) render. We want the
125        // wp-admin integrated experience, so unregister it as soon as it's
126        // loaded.
127        remove_action(
128            'admin_init',
129            'jetpack_scan_jetpack_scan_intercept_render'
130        );
131    }
132
133    /**
134     * Whether the current request targets the Scan admin page.
135     *
136     * Used to scope the wp-build polyfill registration (which force-replaces
137     * core script handles) to this one page, so it never affects other admin
138     * pages. Reads the menu page slug directly so it is cheap and safe to call
139     * at plugin-load time, before `current_screen` exists.
140     *
141     * @return bool True when serving the Scan page in wp-admin.
142     */
143    public static function is_scan_admin_request() {
144        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
145        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
146            return false;
147        }
148
149        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
150        return self::PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
151    }
152
153    /**
154     * Bridge wp-build's auto-generated enqueue function — which checks for
155     * `?page=jetpack-scan-wp-admin` — to our user-facing slug
156     * `?page=jetpack-scan`. Hooked at priority 9 so the wp-build copy
157     * (registered at priority 10) sees the original `$_GET['page']` and
158     * skips its own enqueue.
159     */
160    public static function bridge_wp_build_enqueue() {
161        add_action(
162            'admin_enqueue_scripts',
163            static function ( $hook_suffix ) {
164                // phpcs:ignore WordPress.Security.NonceVerification.Recommended
165                if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) {
166                    return;
167                }
168
169                $enqueue_fn = 'jetpack_scan_jetpack_scan_wp_admin_enqueue_scripts';
170                if ( ! function_exists( $enqueue_fn ) ) {
171                    return;
172                }
173
174                // phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
175                $original     = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : null;
176                $_GET['page'] = self::WP_BUILD_SLUG;
177                // @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.
178                call_user_func( $enqueue_fn, $hook_suffix );
179                if ( null === $original ) {
180                    unset( $_GET['page'] );
181                } else {
182                    $_GET['page'] = $original;
183                }
184                // phpcs:enable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
185            },
186            9
187        );
188    }
189
190    /**
191     * Fix import map ordering for the wp-build boot script.
192     *
193     * In wp-admin, `_wp_footer_scripts` (classic scripts) and
194     * `print_import_map` both hook into `admin_print_footer_scripts` at
195     * priority 10, but `_wp_footer_scripts` is registered first. This
196     * causes the inline `import("@wordpress/boot")` to execute before
197     * the import map exists.
198     *
199     * This fix moves the import() call from the classic inline script to
200     * a `<script type="module">` printed at priority 20 (after the import
201     * map).
202     *
203     * @todo Remove once @wordpress/build ships with the loader.js fix
204     *       upstream (WordPress/gutenberg#76870) and Jetpack updates the
205     *       dependency.
206     */
207    public static function fix_boot_import_map_ordering() {
208        $handle = self::WP_BUILD_SLUG . '-prerequisites';
209
210        add_action(
211            'admin_enqueue_scripts',
212            static function () use ( $handle ) {
213                // phpcs:ignore WordPress.Security.NonceVerification.Recommended
214                if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) {
215                    return;
216                }
217
218                $data = wp_scripts()->get_data( $handle, 'after' );
219                if ( empty( $data ) ) {
220                    return;
221                }
222
223                $boot_script = null;
224                $remaining   = array();
225                foreach ( $data as $line ) {
226                    if ( strpos( $line, '@wordpress/boot' ) !== false ) {
227                        $boot_script = $line;
228                    } else {
229                        $remaining[] = $line;
230                    }
231                }
232
233                if ( null === $boot_script ) {
234                    return;
235                }
236
237                wp_scripts()->add_data( $handle, 'after', $remaining );
238
239                add_action(
240                    'admin_print_footer_scripts',
241                    static function () use ( $boot_script ) {
242                        wp_print_inline_script_tag( $boot_script, array( 'type' => 'module' ) );
243                    },
244                    20
245                );
246            },
247            PHP_INT_MAX
248        );
249    }
250
251    /**
252     * Register the Scan submenu under Jetpack.
253     *
254     * @return string|null The resulting page's hook suffix, if registered.
255     */
256    public static function add_wp_admin_submenu() {
257        if ( ! self::is_available() ) {
258            return null;
259        }
260
261        $render_fn = 'jetpack_scan_jetpack_scan_wp_admin_render_page';
262        $render    = function_exists( $render_fn )
263            ? $render_fn
264            : array( __CLASS__, 'render_page_fallback' );
265
266        $page_suffix = Admin_Menu::add_menu(
267            /** "Scan" is a product name, do not translate. */
268            'Scan',
269            'Scan',
270            'manage_options',
271            self::PAGE_SLUG,
272            $render,
273            6
274        );
275
276        if ( $page_suffix ) {
277            add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
278        }
279
280        return $page_suffix;
281    }
282
283    /**
284     * Whether the Scan page should be shown to the current user.
285     *
286     * @return bool
287     */
288    public static function is_available() {
289        if ( is_multisite() ) {
290            return false;
291        }
292
293        if ( ! current_user_can( 'manage_options' ) ) {
294            return false;
295        }
296
297        return ( new Connection_Manager() )->is_user_connected();
298    }
299
300    /**
301     * Fires when the admin page is loaded.
302     *
303     * Silences the standard wp-admin notice channels so JITMs and
304     * plugin-update messages don't reflow the focused Scan layout
305     * mid-scan or while a fix modal is open.
306     */
307    public static function admin_init() {
308        remove_all_actions( 'admin_notices' );
309        remove_all_actions( 'all_admin_notices' );
310    }
311
312    /**
313     * Fallback render — used only if the wp-build registration file
314     * isn't loaded (e.g. the package wasn't built yet). Renders a bare
315     * mount node so the page doesn't 500 in dev.
316     */
317    public static function render_page_fallback() {
318        ?>
319            <div id="jetpack-scan-page-root"></div>
320        <?php
321    }
322
323    /**
324     * Register the REST routes backing the Scan UI.
325     */
326    public static function register_rest_routes() {
327        REST_Controller::register_rest_routes();
328    }
329}