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