Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.64% covered (warning)
72.64%
77 / 106
55.56% covered (warning)
55.56%
10 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard
74.04% covered (warning)
74.04%
77 / 104
55.56% covered (warning)
55.56%
10 / 18
70.41
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 load_sections
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 has_section_of_class
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 register_section
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 register_rest_routes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_initial_state
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 print_initial_state
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 can_manage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_scan_plan
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_module_state
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_load_wp_build
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 is_dashboard_request
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 load_wp_build
54.17% covered (warning)
54.17%
13 / 24
0.00% covered (danger)
0.00%
0 / 1
7.41
 alias_screen_id
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 restore_screen_id
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 enqueue_i18n_loader
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 add_menu
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 render
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2/**
3 * Protect dashboard: a wp-build page in the Jetpack sidebar.
4 *
5 * @package automattic/jetpack-protect
6 */
7
8namespace Automattic\Jetpack\Protect;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Modules;
12use Automattic\Jetpack\Protect_Status\Plan;
13use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
14use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
15
16if ( ! defined( 'ABSPATH' ) ) {
17    exit( 0 );
18}
19
20/**
21 * Registers the Protect sidebar item and renders its wp-build route.
22 */
23class Dashboard {
24
25    /**
26     * Package version, bumped at release through composer.json `version-constants`.
27     *
28     * @var string
29     */
30    const PACKAGE_VERSION = '0.1.0';
31
32    /**
33     * Sidebar menu slug, shared with the Jetpack Protect plugin so the page address never changes.
34     *
35     * @var string
36     */
37    const MENU_SLUG = 'jetpack-protect';
38
39    /**
40     * The wp-build route's page id. It must not be the menu slug: the generated
41     * standalone page.php intercepts `admin_init` for its own id and exits.
42     *
43     * @var string
44     */
45    const WP_BUILD_PAGE_ID = 'jetpack-protect-dashboard';
46
47    /**
48     * Name of the wp-build render function generated for WP_BUILD_PAGE_ID.
49     *
50     * @var string
51     */
52    const RENDER_FUNCTION = 'jetpack_protect_jetpack_protect_dashboard_wp_admin_render_page';
53
54    /**
55     * Extra Admin_Menu item options, such as the module that owns the item.
56     *
57     * @var array
58     */
59    private static $menu_options = array();
60
61    /**
62     * The screen ID alias_screen_id() replaced, until it is restored.
63     *
64     * @var string|null
65     */
66    private static $original_screen_id = null;
67
68    /**
69     * Registered sections, keyed by section key.
70     *
71     * @var Dashboard_Section[]
72     */
73    private static $sections = array();
74
75    /**
76     * Wire the hooks.
77     *
78     * @param array $menu_options Admin_Menu item options merged over the defaults, e.g. `module`.
79     * @return void
80     */
81    public static function init( array $menu_options = array() ) {
82        self::$menu_options = $menu_options;
83
84        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
85        // Before Admin_Menu registers its items at 1000, and after the Protect plugin adds its own on `_admin_menu`.
86        add_action( 'admin_menu', array( __CLASS__, 'add_menu' ), 999 );
87        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
88        Threat_Count::init();
89
90        self::load_sections( __DIR__ . '/sections' );
91
92        /**
93         * Fires once the Protect dashboard has wired its hooks, so the page exists.
94         *
95         * @since 0.1.0
96         */
97        do_action( 'jetpack_protect_dashboard_initialized' );
98    }
99
100    /**
101     * Register the section class each `class-<name>.php` file in a folder declares.
102     *
103     * Each feature lives in its own file, so features can land independently. The class for
104     * `class-login-protection.php` is `Sections\Login_Protection`; the file must have no side effects,
105     * since the classmap autoloader can also load it.
106     *
107     * @param string $dir Folder holding the section files.
108     * @return void
109     */
110    public static function load_sections( $dir ) {
111        $files = glob( $dir . '/class-*.php' );
112        foreach ( is_array( $files ) ? $files : array() as $file ) {
113            $name  = str_replace( ' ', '_', ucwords( str_replace( '-', ' ', substr( basename( $file, '.php' ), 6 ) ) ) );
114            $class = __NAMESPACE__ . '\\Sections\\' . $name;
115            if ( ! class_exists( $class ) ) {
116                require_once $file;
117            }
118            if ( ! is_subclass_of( $class, Dashboard_Section::class ) || self::has_section_of_class( $class ) ) {
119                continue;
120            }
121            self::register_section( new $class() );
122        }
123    }
124
125    /**
126     * Whether a section of the given class is already registered, so init() can run twice.
127     *
128     * @param string $class Section class name.
129     * @return bool
130     */
131    private static function has_section_of_class( $class ) {
132        foreach ( self::$sections as $section ) {
133            if ( $section instanceof $class ) {
134                return true;
135            }
136        }
137        return false;
138    }
139
140    /**
141     * Add a section to the dashboard.
142     *
143     * @param Dashboard_Section $section The section.
144     * @return void
145     */
146    public static function register_section( Dashboard_Section $section ) {
147        $key = $section->get_key();
148        if ( isset( self::$sections[ $key ] ) ) {
149            /* translators: %s is a dashboard section key. */
150            $message = sprintf( __( 'A Protect dashboard section with the key "%s" is already registered.', 'jetpack-protect-pkg' ), $key );
151            _doing_it_wrong( __METHOD__, esc_html( $message ), '0.1.0' );
152            return;
153        }
154        self::$sections[ $key ] = $section;
155    }
156
157    /**
158     * Register every section's REST routes.
159     *
160     * @return void
161     */
162    public static function register_rest_routes() {
163        foreach ( self::$sections as $section ) {
164            $section->register_routes();
165        }
166    }
167
168    /**
169     * Each section's state, keyed by section key.
170     *
171     * @return array
172     */
173    public static function get_initial_state() {
174        $state = array();
175        foreach ( self::$sections as $key => $section ) {
176            $state[ $key ] = $section->get_state();
177        }
178        return $state;
179    }
180
181    /**
182     * Print each section's state as `window.jetpackProtectDashboard`, for the page's scripts.
183     *
184     * @return void
185     */
186    public static function print_initial_state() {
187        $state = wp_json_encode( (object) self::get_initial_state(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
188        wp_print_inline_script_tag( 'window.jetpackProtectDashboard = ' . ( false === $state ? '{}' : $state ) . ';' );
189    }
190
191    /**
192     * Whether the current user may see and use the dashboard's REST routes.
193     *
194     * @return bool
195     */
196    public static function can_manage() {
197        return current_user_can( 'manage_options' );
198    }
199
200    /**
201     * Whether the site has a plan that includes Scan.
202     *
203     * @return bool
204     */
205    public static function has_scan_plan() {
206        return Plan::has_required_plan();
207    }
208
209    /**
210     * Whether a module can run on this site, and whether it is on.
211     *
212     * @param string $module Module slug.
213     * @return array
214     */
215    public static function get_module_state( $module ) {
216        $modules = new Modules();
217
218        return array(
219            'available' => $modules->is_module( $module ),
220            'active'    => $modules->is_active( $module ),
221        );
222    }
223
224    /**
225     * Load wp-build output, only on this page's request.
226     *
227     * Scoped to the page so WP_Build_Polyfills does not replace core scripts everywhere else.
228     *
229     * @return void
230     */
231    public static function maybe_load_wp_build() {
232        if ( ! self::is_dashboard_request() ) {
233            return;
234        }
235
236        self::load_wp_build( dirname( __DIR__ ) . '/build/build.php' );
237    }
238
239    /**
240     * Whether the request is for the dashboard's page.
241     *
242     * @return bool
243     */
244    public static function is_dashboard_request() {
245        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only.
246        return isset( $_GET['page'] ) && self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
247    }
248
249    /**
250     * Load a wp-build index and, if it can render the page, its scripts and polyfills.
251     *
252     * @param string $build_index Path to the generated build.php.
253     * @return bool Whether the build can render the page.
254     */
255    public static function load_wp_build( $build_index ) {
256        if ( ! file_exists( $build_index ) ) {
257            return false;
258        }
259
260        $load_wp_build = static function () use ( $build_index ) {
261            require_once $build_index;
262        };
263
264        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
265        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
266            WP_Build_Screen_Id::load_with_alias(
267                array( __CLASS__, 'alias_screen_id' ),
268                array( __CLASS__, 'restore_screen_id' ),
269                $load_wp_build
270            );
271        } else {
272            add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id' ) );
273            $load_wp_build();
274            add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id' ) );
275        }
276
277        // A stale or partial build can't render the page, so don't swap core's scripts for it.
278        if ( ! function_exists( self::RENDER_FUNCTION ) ) {
279            return false;
280        }
281
282        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_i18n_loader' ) );
283
284        // wp-build hooks module registration to wp_default_scripts, which has already fired by
285        // admin_menu — call it directly or the init module never reaches the import map.
286        if ( function_exists( 'jetpack_protect_register_script_modules' ) ) {
287            jetpack_protect_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
288        }
289
290        WP_Build_Polyfills::register(
291            'jetpack-protect',
292            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
293        );
294
295        return true;
296    }
297
298    /**
299     * Point the screen ID at the wp-build page while its generated enqueue check runs.
300     *
301     * @return void
302     */
303    public static function alias_screen_id() {
304        $screen = get_current_screen();
305        if ( ! $screen ) {
306            return;
307        }
308
309        self::$original_screen_id = $screen->id;
310        $screen->id               = self::WP_BUILD_PAGE_ID;
311    }
312
313    /**
314     * Undo alias_screen_id(), since JITM builds its message path from the screen ID.
315     *
316     * @return void
317     */
318    public static function restore_screen_id() {
319        $screen = get_current_screen();
320        if ( ! $screen || null === self::$original_screen_id ) {
321            return;
322        }
323
324        $screen->id               = self::$original_screen_id;
325        self::$original_screen_id = null;
326    }
327
328    /**
329     * Enqueue the JS translation loader, which the esbuild route bundles don't pull in.
330     *
331     * @return void
332     */
333    public static function enqueue_i18n_loader() {
334        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
335            wp_enqueue_script( 'wp-jp-i18n-loader' );
336        }
337    }
338
339    /**
340     * Add the "Protect" item to the Jetpack sidebar, in place of the Jetpack Protect plugin's.
341     *
342     * @return void
343     */
344    public static function add_menu() {
345        // Take the slug over from the Jetpack Protect plugin: drop its item and the scripts it loads for that page.
346        // The plugin's instance isn't reachable, so this clears every earlier callback on those load hooks, not just its own.
347        while ( Admin_Menu::remove_menu( self::MENU_SLUG ) ) {
348            continue;
349        }
350        remove_all_actions( 'load-jetpack_page_' . self::MENU_SLUG );
351        remove_all_actions( 'load-admin_page_' . self::MENU_SLUG );
352
353        Admin_Menu::add_menu(
354            // "Protect" is a product name and is not translated.
355            'Protect',
356            'Protect',
357            'manage_options',
358            self::MENU_SLUG,
359            array( __CLASS__, 'render' ),
360            null,
361            array_merge( array( 'key' => self::MENU_SLUG ), self::$menu_options )
362        );
363
364        Threat_Count::register_menu_badge();
365    }
366
367    /**
368     * Render the wp-build page, or say why it could not be rendered.
369     *
370     * @return void
371     */
372    public static function render() {
373        $render_fn = self::RENDER_FUNCTION;
374        if ( function_exists( $render_fn ) ) {
375            self::print_initial_state();
376            // @phan-suppress-next-line PhanUndeclaredFunctionInCallable -- Checked with function_exists(); defined in the generated build/, which Phan excludes.
377            $render_fn();
378            return;
379        }
380
381        printf(
382            '<div class="wrap"><h1>Protect</h1><div class="notice notice-error"><p>%s</p></div></div>',
383            esc_html__( 'The Protect dashboard could not be loaded because its assets are missing.', 'jetpack-protect-pkg' )
384        );
385    }
386}