Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.37% covered (warning)
75.37%
101 / 134
60.87% covered (warning)
60.87%
14 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard
76.52% covered (warning)
76.52%
101 / 132
60.87% covered (warning)
60.87%
14 / 23
91.77
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 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
 get_settings_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_module_settings_urls
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filter_module_configuration_url
100.00% covered (success)
100.00%
3 / 3
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
 maybe_redirect_threat_report
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 get_threat_report_redirect
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 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.2.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     * The settings section that owns each Jetpack security module, for links from other screens.
56     *
57     * @since $$next-version$$
58     *
59     * @var array<string, string>
60     */
61    const MODULE_SECTIONS = array(
62        'waf'                => 'firewall',
63        'protect'            => 'loginProtection',
64        'account-protection' => 'loginProtection',
65        'sso'                => 'loginProtection',
66        'monitor'            => 'monitor',
67    );
68
69    /**
70     * The Jetpack filter that sets a module's Configure link, minus the module slug.
71     *
72     * @var string
73     */
74    const CONFIGURATION_URL_FILTER = 'jetpack_module_configuration_url_';
75
76    /**
77     * Extra Admin_Menu item options, such as the module that owns the item.
78     *
79     * @var array
80     */
81    private static $menu_options = array();
82
83    /**
84     * The screen ID alias_screen_id() replaced, until it is restored.
85     *
86     * @var string|null
87     */
88    private static $original_screen_id = null;
89
90    /**
91     * Registered sections, keyed by section key.
92     *
93     * @var Dashboard_Section[]
94     */
95    private static $sections = array();
96
97    /**
98     * Wire the hooks.
99     *
100     * @param array $menu_options Admin_Menu item options merged over the defaults, e.g. `module`.
101     * @return void
102     */
103    public static function init( array $menu_options = array() ) {
104        self::$menu_options = $menu_options;
105
106        add_action( 'admin_init', array( __CLASS__, 'maybe_redirect_threat_report' ) );
107        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
108        // Before Admin_Menu registers its items at 1000, and after the Protect plugin adds its own on `_admin_menu`.
109        add_action( 'admin_menu', array( __CLASS__, 'add_menu' ), 999 );
110        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
111        Threat_Count::init();
112
113        foreach ( array_keys( self::MODULE_SECTIONS ) as $module ) {
114            add_filter( self::CONFIGURATION_URL_FILTER . $module, array( __CLASS__, 'filter_module_configuration_url' ) );
115            // Firewall and Account protection have no Configure link otherwise.
116            add_filter( 'jetpack_module_configurable_' . $module, '__return_true' );
117        }
118
119        self::load_sections( __DIR__ . '/sections' );
120
121        /**
122         * Fires once the Protect dashboard has wired its hooks, so the page exists.
123         *
124         * @since 0.1.0
125         */
126        do_action( 'jetpack_protect_dashboard_initialized' );
127    }
128
129    /**
130     * Register the section class each `class-<name>.php` file in a folder declares.
131     *
132     * Each feature lives in its own file, so features can land independently. The class for
133     * `class-login-protection.php` is `Sections\Login_Protection`; the file must have no side effects,
134     * since the classmap autoloader can also load it.
135     *
136     * @param string $dir Folder holding the section files.
137     * @return void
138     */
139    public static function load_sections( $dir ) {
140        $files = glob( $dir . '/class-*.php' );
141        foreach ( is_array( $files ) ? $files : array() as $file ) {
142            $name  = str_replace( ' ', '_', ucwords( str_replace( '-', ' ', substr( basename( $file, '.php' ), 6 ) ) ) );
143            $class = __NAMESPACE__ . '\\Sections\\' . $name;
144            if ( ! class_exists( $class ) ) {
145                require_once $file;
146            }
147            if ( ! is_subclass_of( $class, Dashboard_Section::class ) || self::has_section_of_class( $class ) ) {
148                continue;
149            }
150            self::register_section( new $class() );
151        }
152    }
153
154    /**
155     * Whether a section of the given class is already registered, so init() can run twice.
156     *
157     * @param string $class Section class name.
158     * @return bool
159     */
160    private static function has_section_of_class( $class ) {
161        foreach ( self::$sections as $section ) {
162            if ( $section instanceof $class ) {
163                return true;
164            }
165        }
166        return false;
167    }
168
169    /**
170     * Add a section to the dashboard.
171     *
172     * @param Dashboard_Section $section The section.
173     * @return void
174     */
175    public static function register_section( Dashboard_Section $section ) {
176        $key = $section->get_key();
177        if ( isset( self::$sections[ $key ] ) ) {
178            /* translators: %s is a dashboard section key. */
179            $message = sprintf( __( 'A Protect dashboard section with the key "%s" is already registered.', 'jetpack-protect-pkg' ), $key );
180            _doing_it_wrong( __METHOD__, esc_html( $message ), '0.1.0' );
181            return;
182        }
183        self::$sections[ $key ] = $section;
184    }
185
186    /**
187     * Register every section's REST routes.
188     *
189     * @return void
190     */
191    public static function register_rest_routes() {
192        foreach ( self::$sections as $section ) {
193            $section->register_routes();
194        }
195    }
196
197    /**
198     * Each section's state, keyed by section key.
199     *
200     * @return array
201     */
202    public static function get_initial_state() {
203        $state = array();
204        foreach ( self::$sections as $key => $section ) {
205            $state[ $key ] = $section->get_state();
206        }
207        return $state;
208    }
209
210    /**
211     * Print each section's state as `window.jetpackProtectDashboard`, for the page's scripts.
212     *
213     * @return void
214     */
215    public static function print_initial_state() {
216        $state = wp_json_encode( (object) self::get_initial_state(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
217        wp_print_inline_script_tag( 'window.jetpackProtectDashboard = ' . ( false === $state ? '{}' : $state ) . ';' );
218    }
219
220    /**
221     * Whether the current user may see and use the dashboard's REST routes.
222     *
223     * @return bool
224     */
225    public static function can_manage() {
226        return current_user_can( 'manage_options' );
227    }
228
229    /**
230     * The Settings tab's URL, scrolled to a section's card.
231     *
232     * @since $$next-version$$
233     *
234     * @param string $section Section key, such as `firewall`; empty for the top of the tab.
235     * @return string
236     */
237    public static function get_settings_url( $section = '' ) {
238        $query = array( 'tab' => 'settings' );
239        if ( '' !== $section ) {
240            $query['section'] = $section;
241        }
242
243        return admin_url( 'admin.php?page=' . self::MENU_SLUG . '&p=' . rawurlencode( '/?' . http_build_query( $query ) ) );
244    }
245
246    /**
247     * Each security module's card on the Settings tab, for screens that link here instead of offering the setting.
248     *
249     * @since $$next-version$$
250     *
251     * @return array<string, string> Settings URLs keyed by module slug.
252     */
253    public static function get_module_settings_urls() {
254        return array_map( array( __CLASS__, 'get_settings_url' ), self::MODULE_SECTIONS );
255    }
256
257    /**
258     * Point a security module's Configure link at its card on the Settings tab.
259     *
260     * @since $$next-version$$
261     *
262     * @param string $url The URL Jetpack would use.
263     * @return string
264     */
265    public static function filter_module_configuration_url( $url ) {
266        $module = substr( current_filter(), strlen( self::CONFIGURATION_URL_FILTER ) );
267        $urls   = self::get_module_settings_urls();
268
269        return $urls[ $module ] ?? $url;
270    }
271
272    /**
273     * Whether the site has a plan that includes Scan.
274     *
275     * @return bool
276     */
277    public static function has_scan_plan() {
278        return Plan::has_required_plan();
279    }
280
281    /**
282     * Whether a module can run on this site, and whether it is on.
283     *
284     * @param string $module Module slug.
285     * @return array
286     */
287    public static function get_module_state( $module ) {
288        $modules = new Modules();
289
290        return array(
291            'available' => $modules->is_module( $module ),
292            'active'    => $modules->is_active( $module ),
293        );
294    }
295
296    /**
297     * Load wp-build output, only on this page's request.
298     *
299     * Scoped to the page so WP_Build_Polyfills does not replace core scripts everywhere else.
300     *
301     * @return void
302     */
303    public static function maybe_load_wp_build() {
304        if ( ! self::is_dashboard_request() ) {
305            return;
306        }
307
308        self::load_wp_build( dirname( __DIR__ ) . '/build/build.php' );
309    }
310
311    /**
312     * Whether the request is for the dashboard's page.
313     *
314     * @return bool
315     */
316    public static function is_dashboard_request() {
317        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only.
318        return isset( $_GET['page'] ) && self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
319    }
320
321    /**
322     * Send a `&threats=` link, such as a threat email's, to the route that reads it.
323     *
324     * @return void
325     */
326    public static function maybe_redirect_threat_report() {
327        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only redirects; nothing is changed.
328        $redirect = self::is_dashboard_request() ? self::get_threat_report_redirect( wp_unslash( $_GET ) ) : null;
329        if ( $redirect ) {
330            wp_safe_redirect( $redirect );
331            exit( 0 );
332        }
333    }
334
335    /**
336     * The threat report URL for a request whose top-level `threats` param lists threat ids.
337     *
338     * The wp-build router reads search params only from inside `p`, so links name the ids at the top level instead.
339     *
340     * @since $$next-version$$
341     *
342     * @param array $query The request's query args.
343     * @return string|null The URL, or null when the request needs no redirect.
344     */
345    public static function get_threat_report_redirect( array $query ) {
346        if ( isset( $query['p'] ) || ! isset( $query['threats'] ) || ! is_string( $query['threats'] ) ) {
347            return null;
348        }
349        // Drop any id that isn't all digits, as the client does, rather than merging what's left of it.
350        $ids = array_filter( array_map( 'trim', explode( ',', $query['threats'] ) ), 'ctype_digit' );
351        if ( ! $ids ) {
352            return null;
353        }
354        return add_query_arg(
355            array(
356                'page' => self::MENU_SLUG,
357                'p'    => rawurlencode( '/?threats=' . implode( ',', $ids ) ),
358            ),
359            admin_url( 'admin.php' )
360        );
361    }
362
363    /**
364     * Load a wp-build index and, if it can render the page, its scripts and polyfills.
365     *
366     * @param string $build_index Path to the generated build.php.
367     * @return bool Whether the build can render the page.
368     */
369    public static function load_wp_build( $build_index ) {
370        if ( ! file_exists( $build_index ) ) {
371            return false;
372        }
373
374        $load_wp_build = static function () use ( $build_index ) {
375            require_once $build_index;
376        };
377
378        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
379        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
380            WP_Build_Screen_Id::load_with_alias(
381                array( __CLASS__, 'alias_screen_id' ),
382                array( __CLASS__, 'restore_screen_id' ),
383                $load_wp_build
384            );
385        } else {
386            add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id' ) );
387            $load_wp_build();
388            add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id' ) );
389        }
390
391        // A stale or partial build can't render the page, so don't swap core's scripts for it.
392        if ( ! function_exists( self::RENDER_FUNCTION ) ) {
393            return false;
394        }
395
396        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_i18n_loader' ) );
397
398        // wp-build hooks module registration to wp_default_scripts, which has already fired by
399        // admin_menu — call it directly or the init module never reaches the import map.
400        if ( function_exists( 'jetpack_protect_register_script_modules' ) ) {
401            jetpack_protect_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
402        }
403
404        WP_Build_Polyfills::register(
405            'jetpack-protect',
406            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
407        );
408
409        return true;
410    }
411
412    /**
413     * Point the screen ID at the wp-build page while its generated enqueue check runs.
414     *
415     * @return void
416     */
417    public static function alias_screen_id() {
418        $screen = get_current_screen();
419        if ( ! $screen ) {
420            return;
421        }
422
423        self::$original_screen_id = $screen->id;
424        $screen->id               = self::WP_BUILD_PAGE_ID;
425    }
426
427    /**
428     * Undo alias_screen_id(), since JITM builds its message path from the screen ID.
429     *
430     * @return void
431     */
432    public static function restore_screen_id() {
433        $screen = get_current_screen();
434        if ( ! $screen || null === self::$original_screen_id ) {
435            return;
436        }
437
438        $screen->id               = self::$original_screen_id;
439        self::$original_screen_id = null;
440    }
441
442    /**
443     * Enqueue the JS translation loader, which the esbuild route bundles don't pull in.
444     *
445     * @return void
446     */
447    public static function enqueue_i18n_loader() {
448        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
449            wp_enqueue_script( 'wp-jp-i18n-loader' );
450        }
451    }
452
453    /**
454     * Add the "Protect" item to the Jetpack sidebar, in place of the Jetpack Protect plugin's.
455     *
456     * @return void
457     */
458    public static function add_menu() {
459        // Take the slug over from the Jetpack Protect plugin: drop its item and the scripts it loads for that page.
460        // The plugin's instance isn't reachable, so this clears every earlier callback on those load hooks, not just its own.
461        while ( Admin_Menu::remove_menu( self::MENU_SLUG ) ) {
462            continue;
463        }
464        remove_all_actions( 'load-jetpack_page_' . self::MENU_SLUG );
465        remove_all_actions( 'load-admin_page_' . self::MENU_SLUG );
466
467        Admin_Menu::add_menu(
468            // "Protect" is a product name and is not translated.
469            'Protect',
470            'Protect',
471            'manage_options',
472            self::MENU_SLUG,
473            array( __CLASS__, 'render' ),
474            null,
475            array_merge( array( 'key' => self::MENU_SLUG ), self::$menu_options )
476        );
477
478        Threat_Count::register_menu_badge();
479    }
480
481    /**
482     * Render the wp-build page, or say why it could not be rendered.
483     *
484     * @return void
485     */
486    public static function render() {
487        $render_fn = self::RENDER_FUNCTION;
488        if ( function_exists( $render_fn ) ) {
489            self::print_initial_state();
490            // @phan-suppress-next-line PhanUndeclaredFunctionInCallable -- Checked with function_exists(); defined in the generated build/, which Phan excludes.
491            $render_fn();
492            return;
493        }
494
495        printf(
496            '<div class="wrap"><h1>Protect</h1><div class="notice notice-error"><p>%s</p></div></div>',
497            esc_html__( 'The Protect dashboard could not be loaded because its assets are missing.', 'jetpack-protect-pkg' )
498        );
499    }
500}