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