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