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