Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.25% covered (warning)
81.25%
52 / 64
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Threat_Count
81.25% covered (warning)
81.25%
52 / 64
71.43% covered (warning)
71.43%
5 / 7
20.14
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_cached
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 register_menu_badge
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 needs_refresh
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 maybe_enqueue_refresh
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 register_rest_route
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 get_fresh_count
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * The number of threats shown next to the Protect menu item and in the admin bar.
4 *
5 * @package automattic/jetpack-protect
6 */
7
8namespace Automattic\Jetpack\Protect;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Menu_Badges\Menu_Badges;
12use Automattic\Jetpack\Menu_Badges\Notification_Counts;
13use Automattic\Jetpack\Protect_Status\Status;
14use WP_Error;
15use WP_REST_Response;
16
17/**
18 * Shows the stored threat count while rendering, and refreshes it from the browser.
19 *
20 * Rendering never waits on WordPress.com: an expired or missing count is fetched by a script after the page loads.
21 */
22class Threat_Count {
23
24    /**
25     * Handle of the script that refreshes the count.
26     *
27     * @var string
28     */
29    const SCRIPT_HANDLE = 'jetpack-protect-threat-count';
30
31    /**
32     * Document event the script fires with the fresh count, for the admin bar notice.
33     *
34     * @var string
35     */
36    const EVENT = 'jetpack-protect-threat-count';
37
38    /**
39     * REST route, under `jetpack/v4`, that returns a fresh count.
40     *
41     * @var string
42     */
43    const REST_ROUTE = '/protect-dashboard/threat-count';
44
45    /**
46     * Transient set while the count is not refreshed after a failed status fetch.
47     *
48     * @var string
49     */
50    const BACKOFF_TRANSIENT = 'jetpack_protect_dashboard_threat_count_backoff';
51
52    /**
53     * Wire the hooks.
54     *
55     * @return void
56     */
57    public static function init() {
58        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_route' ) );
59        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_refresh' ) );
60        add_action( 'wp_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_refresh' ) );
61    }
62
63    /**
64     * The stored count, even if it has expired.
65     *
66     * @return int|null Null when no status has been stored yet.
67     */
68    public static function get_cached() {
69        try {
70            $status = Status::get_cached_status();
71        } catch ( \Throwable $e ) {
72            // This runs on every admin page, so a malformed stored status must not take them all down.
73            return null;
74        }
75        return null === $status ? null : count( $status->threats );
76    }
77
78    /**
79     * Show the stored count next to the "Protect" item, for users who can open the page.
80     *
81     * A missing count registers a hidden badge, which the refresh script reveals.
82     *
83     * @return void
84     */
85    public static function register_menu_badge() {
86        if ( ! Dashboard::can_manage() ) {
87            return;
88        }
89
90        Menu_Badges::init();
91        // The same ID as the Jetpack Protect plugin's entry, so the threats are not counted twice.
92        Notification_Counts::register(
93            Dashboard::MENU_SLUG,
94            array(
95                'menu_slug' => Dashboard::MENU_SLUG,
96                'count'     => (int) self::get_cached(),
97                'type'      => 'count',
98            )
99        );
100    }
101
102    /**
103     * Whether the stored count should be refreshed from the browser.
104     *
105     * @return bool
106     */
107    public static function needs_refresh() {
108        return ! get_transient( self::BACKOFF_TRANSIENT ) && Status::is_cached_status_expired();
109    }
110
111    /**
112     * Load the refresh script on pages showing the menu badge or the admin bar, when the count is stale.
113     *
114     * @return void
115     */
116    public static function maybe_enqueue_refresh() {
117        if ( ( ! is_admin() && ! is_admin_bar_showing() ) || ! Dashboard::can_manage() || ! self::needs_refresh() ) {
118            return;
119        }
120
121        $dependencies = array( 'wp-api-fetch' );
122        if ( is_admin() ) {
123            // The menu badge script only loads in wp-admin; elsewhere only the admin bar is updated.
124            Menu_Badges::enqueue_client();
125            $dependencies[] = 'jetpack-menu-badges';
126        }
127        Assets::register_script(
128            self::SCRIPT_HANDLE,
129            'js/threat-count.js',
130            __FILE__,
131            array(
132                'dependencies' => $dependencies,
133                'in_footer'    => true,
134                'enqueue'      => true,
135            )
136        );
137        wp_localize_script(
138            self::SCRIPT_HANDLE,
139            'jetpackProtectThreatCountConfig',
140            array(
141                'path'     => 'jetpack/v4' . self::REST_ROUTE,
142                'menuSlug' => Dashboard::MENU_SLUG,
143                'event'    => self::EVENT,
144            )
145        );
146    }
147
148    /**
149     * Register the route the refresh script calls.
150     *
151     * @return void
152     */
153    public static function register_rest_route() {
154        register_rest_route(
155            'jetpack/v4',
156            self::REST_ROUTE,
157            array(
158                'methods'             => \WP_REST_Server::READABLE,
159                'callback'            => array( __CLASS__, 'get_fresh_count' ),
160                'permission_callback' => array( Dashboard::class, 'can_manage' ),
161            )
162        );
163    }
164
165    /**
166     * The count, fetching the status from WordPress.com when the stored one has expired.
167     *
168     * @return WP_REST_Response|WP_Error
169     */
170    public static function get_fresh_count() {
171        try {
172            $status = Status::get_status();
173        } catch ( \Throwable $e ) {
174            $status = (object) array( 'error' => true );
175        }
176        if ( ! empty( $status->error ) ) {
177            // A failed fetch is not stored, so without this every page load would ask WordPress.com again.
178            set_transient( self::BACKOFF_TRANSIENT, 1, 5 * MINUTE_IN_SECONDS );
179            return new WP_Error(
180                'jetpack_protect_threat_count_unavailable',
181                __( 'The number of threats could not be fetched.', 'jetpack-protect-pkg' ),
182                array( 'status' => 503 )
183            );
184        }
185
186        return rest_ensure_response( array( 'count' => count( $status->threats ) ) );
187    }
188}