Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.22% covered (success)
93.22%
110 / 118
66.67% covered (warning)
66.67%
10 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Bar_Notice
93.22% covered (success)
93.22%
110 / 118
66.67% covered (warning)
66.67%
10 / 15
50.78
0.00% covered (danger)
0.00%
0 / 1
 instance
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 init_hooks
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
2.02
 should_try_to_display_notice
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
8.23
 enqueue_toolbar_script
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
5
 add_inline_styles
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 add_threats_to_toolbar
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
5
 has_protect_dashboard
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_protect_dashboard_page
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
 keeps_node
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 uses_protect_count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_protect_count
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_scan_url
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_icon
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_threats
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 get_threat_count
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
1<?php
2/**
3 * A class that adds the scan notice to the admin bar.
4 *
5 * @package automattic/jetpack
6 */
7
8namespace Automattic\Jetpack\Scan;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Protect\Threat_Count;
12use Automattic\Jetpack\Protect_Status\Status as Protect_Status;
13use Automattic\Jetpack\Redirect;
14use Automattic\Jetpack\Status;
15use WP_Admin_Bar;
16
17/**
18 * Class Main
19 *
20 * Responsible for loading the admin bar notice if threats are found.
21 *
22 * @package Automattic\Jetpack\Scan
23 */
24class Admin_Bar_Notice {
25    const SCRIPT_NAME = 'jetpack-scan-show-notice';
26
27    /**
28     * The singleton instance of this class.
29     *
30     * @var Admin_Bar_Notice
31     */
32    protected static $instance;
33
34    /**
35     * The Protect dashboard's stored count, once read.
36     *
37     * @var int|null
38     */
39    private $protect_count;
40
41    /**
42     * Whether the Protect dashboard's count has been read in this request.
43     *
44     * @var bool
45     */
46    private $protect_count_read = false;
47
48    /**
49     * Get the singleton instance of the class.
50     *
51     * @return Admin_Bar_Notice
52     */
53    public static function instance() {
54        if ( ! isset( self::$instance ) ) {
55            self::$instance = new Admin_Bar_Notice();
56            self::$instance->init_hooks();
57        }
58
59        return self::$instance;
60    }
61    /**
62     * Initalize the hooks as needed.
63     */
64    private function init_hooks() {
65        if ( ! $this->should_try_to_display_notice() ) {
66            return;
67        }
68
69        add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_toolbar_script' ) );
70        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_toolbar_script' ) );
71        add_action( 'admin_bar_menu', array( $this, 'add_threats_to_toolbar' ), 999 );
72
73        // Inject the data-ampdevmode attribute into the inline <script> output via wp_localize_script(). To revisit after https://github.com/ampproject/amp-wp/issues/4598.
74        add_filter(
75            'amp_dev_mode_element_xpaths',
76            static function ( $expressions ) {
77                $expressions[] = '//script[ contains( text(), "Jetpack_Scan" ) ]';
78                return $expressions;
79            }
80        );
81    }
82
83    /**
84     * Whether to even try to display the notice or now.
85     *
86     * @return bool
87     */
88    private function should_try_to_display_notice() {
89        if ( ( new Status() )->is_offline_mode() ) {
90            return false;
91        }
92
93        // Jetpack Scan is currently not supported on multisite.
94        if ( is_multisite() ) {
95            return false;
96        }
97
98        if ( method_exists( Protect_Status::class, 'should_notify_threats' ) && ! Protect_Status::should_notify_threats() ) {
99            return false;
100        }
101
102        // Check if VaultPress is active, the assumtion there is that VaultPress is working.
103        // It has its own notice in the admin bar.
104        if ( class_exists( 'VaultPress' ) ) {
105            return false;
106        }
107
108        // Check if Protect is active.
109        // It has its own notice in the admin bar.
110        if ( class_exists( 'Jetpack_Protect' ) ) {
111            return false;
112        }
113
114        // Only show the notice to admins.
115        if ( ! current_user_can( 'manage_options' ) ) {
116            return false;
117        }
118
119        return true;
120    }
121
122    /**
123     * Add the inline styles and scripts if they are needed.
124     */
125    public function enqueue_toolbar_script() {
126        $this->add_inline_styles();
127
128        // The Protect dashboard's own script refreshes its count; otherwise this one fetches it when nothing is cached.
129        // On the Protect page, fixing or ignoring a threat sends the new count with the same event.
130        $needs_refresh = $this->uses_protect_count()
131            ? Threat_Count::needs_refresh() || $this->is_protect_dashboard_page()
132            : null === $this->has_threats();
133        if ( ! $needs_refresh ) {
134            return;
135        }
136
137        Assets::register_script(
138            self::SCRIPT_NAME,
139            '_inc/build/scan/admin-bar-notice.min.js',
140            JETPACK__PLUGIN_FILE,
141            array(
142                'in_footer'    => true,
143                'strategy'     => 'defer',
144                'nonmin_path'  => 'modules/scan/admin-bar-notice.js',
145                'dependencies' => array( 'admin-bar' ),
146                'enqueue'      => true,
147            )
148        );
149        $script_data = array(
150            'nonce'              => wp_create_nonce( 'wp_rest' ),
151            'scan_endpoint'      => get_rest_url( null, 'jetpack/v4/scan' ),
152            'scan_dashboard_url' => $this->get_scan_url(),
153            /* translators: %s is the alert icon */
154            'singular'           => sprintf( esc_html__( '%s Threat found', 'jetpack' ), $this->get_icon() ),
155            /* translators: %s is the alert icon */
156            'multiple'           => sprintf( esc_html__( '%s Threats found', 'jetpack' ), $this->get_icon() ),
157        );
158        if ( $this->uses_protect_count() ) {
159            $script_data['count_event'] = Threat_Count::EVENT;
160        }
161        wp_localize_script( self::SCRIPT_NAME, 'Jetpack_Scan', $script_data );
162    }
163
164    /**
165     * Adds the inline styles if they are needed.
166     */
167    public function add_inline_styles() {
168        // We know there are no threats so lets not include any css.
169        if ( ! $this->keeps_node() ) {
170            return;
171        }
172
173        // We might be showing the threats in the admin bar lets make sure that they look great!
174        $hide_wording_on_mobile = '#wp-admin-bar-jetpack-scan-notice.is-hidden { display:none; } @media screen and (max-width: 959px ) { #wpadminbar #wp-admin-bar-jetpack-scan-notice { width:32px; } #wpadminbar #wp-admin-bar-jetpack-scan-notice a { color: transparent!important; } }';
175        $style                  = '#wp-admin-bar-jetpack-scan-notice svg { float:left; margin-top: 4px; margin-right: 6px; width: 18px; height: 22px; }' . $hide_wording_on_mobile;
176        if ( is_rtl() ) {
177            $style = '#wp-admin-bar-jetpack-scan-notice svg { float:right; margin-top: 4px; margin-left: 6px; width: 18px; height: 22px; }' . $hide_wording_on_mobile;
178        }
179        wp_add_inline_style( 'admin-bar', $style );
180    }
181
182    /**
183     * Add the link to the admin bar.
184     *
185     * @param WP_Admin_Bar $wp_admin_bar WP Admin Bar class object.
186     */
187    public function add_threats_to_toolbar( $wp_admin_bar ) {
188        if ( ! $this->should_try_to_display_notice() ) {
189            return;
190        }
191
192        if ( ! $this->keeps_node() ) {
193            return;
194        }
195        $has_threats = $this->has_threats();
196
197        $node = array(
198            'id'     => 'jetpack-scan-notice',
199            'title'  => '',
200            'parent' => 'top-secondary',
201            'meta'   => array(
202                'title' => esc_attr__( 'View security scan details', 'jetpack' ),
203                'class' => 'error is-hidden',
204            ),
205        );
206
207        if ( $has_threats ) {
208            $node['href']          = esc_url( $this->get_scan_url() );
209            $node['meta']['class'] = 'error';
210            if ( ! $this->has_protect_dashboard() ) {
211                $node['meta']['target'] = '_blank';
212                $node['meta']['rel']    = 'noopener noreferrer';
213            }
214            $node['title'] = sprintf(
215                esc_html(
216                /* translators: %s is the alert icon */
217                    _n( '%s Threat found', '%s Threats found', $this->get_threat_count(), 'jetpack' )
218                ),
219                $this->get_icon()
220            );
221        }
222
223        $wp_admin_bar->add_node( $node );
224    }
225
226    /**
227     * Whether the Protect dashboard, which lists the threats inside wp-admin, is loaded.
228     *
229     * @return bool
230     */
231    private function has_protect_dashboard() {
232        // The standalone Protect plugin needs no check: with it active, this notice is never shown.
233        return (bool) did_action( 'jetpack_protect_dashboard_initialized' );
234    }
235
236    /**
237     * Whether this is the Protect dashboard page, where the threat count can change without a reload.
238     *
239     * @return bool
240     */
241    private function is_protect_dashboard_page() {
242        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only compared, to pick which script loads.
243        return is_admin() && $this->has_protect_dashboard() && isset( $_GET['page'] ) && 'jetpack-protect' === $_GET['page'];
244    }
245
246    /**
247     * Whether the admin bar gets a node, hidden while there are no threats, so a later count can show it.
248     *
249     * @return bool
250     */
251    private function keeps_node() {
252        return false !== $this->has_threats() || ( $this->uses_protect_count() && $this->is_protect_dashboard_page() );
253    }
254
255    /**
256     * Whether the count comes from the Protect dashboard, so the admin bar and the Protect menu item agree.
257     *
258     * @return bool
259     */
260    private function uses_protect_count() {
261        return $this->has_protect_dashboard() && method_exists( Threat_Count::class, 'get_cached' );
262    }
263
264    /**
265     * The Protect dashboard's stored count, read once per request.
266     *
267     * @return int|null Null when no status has been stored yet.
268     */
269    private function get_protect_count() {
270        if ( ! $this->protect_count_read ) {
271            $this->protect_count      = Threat_Count::get_cached();
272            $this->protect_count_read = true;
273        }
274        return $this->protect_count;
275    }
276
277    /**
278     * Where the threats are listed: the Protect dashboard when it is loaded, Jetpack Cloud otherwise.
279     *
280     * @return string
281     */
282    private function get_scan_url() {
283        return $this->has_protect_dashboard()
284            ? admin_url( 'admin.php?page=jetpack-protect' )
285            : Redirect::get_url( 'calypso-scanner' );
286    }
287
288    /**
289     * Returns the shield icon.
290     *
291     * @return string
292     */
293    private function get_icon() {
294        return '<svg width="18" height="22" viewBox="0 0 18 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9 0L0 4V10C0 15.55 3.84 20.74 9 22C14.16 20.74 18 15.55 18 10V4L9 0Z" fill="#D63638"/><path d="M7.99121 6.00894H10.0085V11.9968H7.99121V6.00894Z" fill="#FFF"/><path d="M7.99121 14.014H10.0085V15.9911H7.99121V14.014Z" fill="#FFF"/></svg>';
295    }
296
297    /**
298     *
299     * Return Whether boolean cached threats exist or null if the state is unknown.
300     * * @return boolean or null
301     */
302    public function has_threats() {
303        if ( $this->uses_protect_count() ) {
304            $count = $this->get_protect_count();
305            // An expired zero is unknown, so the notice keeps a hidden node the refresh can reveal.
306            if ( null === $count || ( 0 === $count && Threat_Count::needs_refresh() ) ) {
307                return null;
308            }
309            return $count > 0;
310        }
311
312        $scan_state = get_transient( 'jetpack_scan_state' );
313        if ( empty( $scan_state ) ) {
314            return null;
315        }
316        // Return true if there is at least one threat found.
317        return isset( $scan_state->threats[0] );
318    }
319
320    /**
321     * Returns the number of threats found or 0.
322     *
323     * @return int
324     */
325    public function get_threat_count() {
326        if ( ! $this->has_threats() ) {
327            return 0;
328        }
329
330        if ( $this->uses_protect_count() ) {
331            return (int) $this->get_protect_count();
332        }
333
334        $scan_state = get_transient( 'jetpack_scan_state' );
335        return is_array( $scan_state->threats ) ? count( $scan_state->threats ) : 0;
336    }
337}