Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.77% covered (success)
90.77%
59 / 65
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Scan
96.72% covered (success)
96.72%
59 / 61
80.00% covered (warning)
80.00%
8 / 10
43
0.00% covered (danger)
0.00%
0 / 1
 should_register
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 is_dashboard_module_off
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_state
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
 is_awaiting_connection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_initial_state
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 register_page
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 remove_menu_entry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle_enable
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 filter_available_modules
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 maybe_activate_protect_dashboard_module
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
9.03
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- Feature entry file, named after the feature, also holds the WPCOM_Scan bootstrap class.
2/**
3 * An in-wp-admin Protect page for WordPress.com Simple and WoA sites.
4 * Prompts for a plan upgrade or activation of Scan.
5 *
6 * @package automattic/jetpack-mu-wpcom
7 */
8
9namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;
10
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Modules;
13
14require_once __DIR__ . '/../wpcom-hosting-feature-page/class-wpcom-hosting-feature-page.php';
15
16/**
17 * Registers the Protect admin page and its wp-build assets.
18 */
19class WPCOM_Scan extends WPCOM_Hosting_Feature_Page {
20
21    /**
22     * The slug the Jetpack plugin is expected to serve Protect from on WoA once Scan is live.
23     */
24    const MENU_SLUG = 'jetpack-protect';
25
26    /**
27     * The Jetpack plugin module that serves MENU_SLUG with the Protect package's dashboard.
28     */
29    const DASHBOARD_MODULE = 'protect-dashboard';
30
31    /**
32     * Set once the Protect dashboard has been on, so a site that turns it off later keeps it off.
33     */
34    const DASHBOARD_MODULE_ACTIVATED_OPTION = 'wpcom_protect_dashboard_activated';
35
36    /**
37     * As declared in routes/wpcom-scan/package.json.
38     */
39    const WP_BUILD_PAGE = 'wpcom-scan';
40
41    /**
42     * Product name, do not translate.
43     */
44    const PAGE_TITLE = 'Jetpack Protect';
45
46    /**
47     * Product name, do not translate.
48     */
49    const MENU_TITLE = 'Protect';
50
51    /**
52     * SCAN_SELF_SERVE, not SCAN: SCAN also covers Personal and Premium plans, which run scans
53     * without exposing Scan's UI. It is also what the dashboard's Scan page gates on.
54     */
55    const FEATURE = 'SCAN_SELF_SERVE';
56
57    /**
58     * The admin-post action, and nonce action, that turns the Protect dashboard back on.
59     */
60    const ENABLE_ACTION = 'wpcom_enable_protect_dashboard';
61
62    /**
63     * The `enable_failed` reason when the site is not connected to WordPress.com, which the module requires.
64     */
65    const ENABLE_ERROR_CONNECTION = 'connection';
66
67    /**
68     * The `enable_failed` reason for any other refusal.
69     */
70    const ENABLE_ERROR_UNKNOWN = 'unknown';
71
72    /**
73     * Also claim the slug on a WoA site whose plan includes Scan but whose Protect dashboard is off,
74     * so a link into it gets a way to turn it back on rather than core's access error.
75     *
76     * @return bool
77     */
78    public static function should_register() {
79        if ( ! self::is_atomic() || ! self::has_feature() ) {
80            return parent::should_register();
81        }
82
83        return is_admin() && self::is_dashboard_module_off();
84    }
85
86    /**
87     * Whether the Jetpack plugin offers the Protect dashboard here, but it is turned off.
88     *
89     * @return bool
90     */
91    public static function is_dashboard_module_off() {
92        $modules = new Modules();
93
94        // Unavailable beside the standalone Protect plugin, which serves the slug itself.
95        return ! $modules->is_active( self::DASHBOARD_MODULE )
96            && in_array( self::DASHBOARD_MODULE, $modules->get_available(), true );
97    }
98
99    /**
100     * Offer to turn the Protect dashboard on wherever should_register() kept the page for that.
101     *
102     * @param int $blog_id Blog ID.
103     * @return string One of the STATE_* constants.
104     */
105    public static function get_state( $blog_id ) {
106        if ( self::is_atomic() && self::has_feature() && self::is_dashboard_module_off() ) {
107            return self::is_awaiting_connection() ? self::STATE_IN_PROGRESS : self::STATE_ENABLE;
108        }
109
110        return parent::get_state( $blog_id );
111    }
112
113    /**
114     * Whether a dashboard that has never been on is waiting for the connection, as just after a transfer.
115     *
116     * Nobody turned it off: maybe_activate_protect_dashboard_module() turns it on once the connection is ready.
117     *
118     * @return bool
119     */
120    public static function is_awaiting_connection() {
121        return ! get_option( self::DASHBOARD_MODULE_ACTIVATED_OPTION ) && ! ( new Connection_Manager() )->is_connected();
122    }
123
124    /**
125     * The resolved prompt, plus the link that turns the Protect dashboard on.
126     *
127     * @return array
128     */
129    public static function get_initial_state() {
130        $state = parent::get_initial_state();
131
132        if ( self::STATE_IN_PROGRESS === $state['state'] && self::is_atomic() ) {
133            $state['awaitingConnection'] = true;
134        }
135
136        if ( self::STATE_ENABLE === $state['state'] ) {
137            $state['enableUrl'] = add_query_arg(
138                array(
139                    'action'   => self::ENABLE_ACTION,
140                    '_wpnonce' => wp_create_nonce( self::ENABLE_ACTION ),
141                ),
142                admin_url( 'admin-post.php' )
143            );
144            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only flag set by handle_enable()'s redirect.
145            $error                = isset( $_GET['enable_failed'] ) ? sanitize_key( wp_unslash( $_GET['enable_failed'] ) ) : '';
146            $state['enableError'] = in_array( $error, array( self::ENABLE_ERROR_CONNECTION, self::ENABLE_ERROR_UNKNOWN ), true ) ? $error : null;
147        }
148
149        return $state;
150    }
151
152    /**
153     * Register the page, keeping its entry out of the sidebar while it only offers to turn the dashboard back on.
154     *
155     * Turning the module off hides the dashboard on purpose, so the entry goes as on self-hosted while links still land.
156     *
157     * @return void
158     */
159    public static function register_page() {
160        global $submenu;
161
162        parent::register_page();
163
164        if ( ! self::owns_page() || self::STATE_ENABLE !== self::get_state( get_current_blog_id() ) ) {
165            return;
166        }
167
168        // For Calypso's sidebar, whose REST request never reaches admin_head.
169        foreach ( $submenu[ self::PARENT_SLUG ] ?? array() as $i => $item ) {
170            if ( self::MENU_SLUG === ( $item[2] ?? null ) ) {
171                // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
172                $submenu[ self::PARENT_SLUG ][ $i ][4] = trim( ( $item[4] ?? '' ) . ' hide-if-js' );
173            }
174        }
175
176        add_action( 'admin_head', array( self::class, 'remove_menu_entry' ) );
177    }
178
179    /**
180     * Take the entry out of wp-admin's sidebar, after core's access check and page title have read it.
181     *
182     * Removed rather than left hidden: My Jetpack refreshes the sidebar by link, so a hidden entry sharing the
183     * dashboard's link would keep whichever state was on screen when the module is switched.
184     *
185     * @return void
186     */
187    public static function remove_menu_entry() {
188        remove_submenu_page( self::PARENT_SLUG, self::MENU_SLUG );
189    }
190
191    /**
192     * Turn the Protect dashboard on, then return to its page, which it now serves.
193     *
194     * @return never
195     */
196    public static function handle_enable() {
197        check_admin_referer( self::ENABLE_ACTION );
198
199        if ( ! current_user_can( 'jetpack_activate_modules' ) ) {
200            wp_die( esc_html__( 'Sorry, you are not allowed to turn on Protect.', 'jetpack-mu-wpcom' ), '', 403 );
201        }
202
203        $url = self::get_page_url();
204
205        if ( self::is_atomic() && self::has_feature() && self::is_dashboard_module_off() ) {
206            $modules = new Modules();
207            $modules->activate( self::DASHBOARD_MODULE, false, false );
208
209            // Read back: activate() returns true even when its write to the saved list is filtered away.
210            if ( ! $modules->is_active( self::DASHBOARD_MODULE ) ) {
211                $error = ( new Connection_Manager() )->is_connected() ? self::ENABLE_ERROR_UNKNOWN : self::ENABLE_ERROR_CONNECTION;
212                $url   = add_query_arg( 'enable_failed', $error, $url );
213            }
214        }
215
216        wp_safe_redirect( $url );
217        exit( 0 );
218    }
219
220    /**
221     * Keep the Jetpack plugin's Protect dashboard off a WoA site whose plan lacks Scan, so this page's upgrade prompt keeps the slug.
222     *
223     * @param array $modules Available modules, keyed by slug.
224     * @return array
225     */
226    public static function filter_available_modules( $modules ) {
227        if ( ! self::has_feature() ) {
228            unset( $modules[ self::DASHBOARD_MODULE ] );
229        }
230
231        return $modules;
232    }
233
234    /**
235     * Turn the Protect dashboard on once for a WoA site that can have it.
236     *
237     * WoA sites passed the module's First Introduced version while it was unavailable there, so Jetpack's upgrade never turned it on.
238     *
239     * @return void
240     */
241    public static function maybe_activate_protect_dashboard_module() {
242        if ( ! self::is_atomic() || ! is_admin() || get_option( self::DASHBOARD_MODULE_ACTIVATED_OPTION ) ) {
243            return;
244        }
245
246        $modules = new Modules();
247        // Unavailable without the Jetpack plugin, without self-serve Scan, or beside the standalone Protect plugin, so it is retried once offered.
248        if ( ! in_array( self::DASHBOARD_MODULE, $modules->get_available(), true ) ) {
249            return;
250        }
251
252        $was_active = $modules->is_active( self::DASHBOARD_MODULE );
253        if ( ! $was_active ) {
254            $modules->activate( self::DASHBOARD_MODULE, false, false );
255        }
256
257        // Checked again rather than trusting activate(), which also refuses while the connection is not ready.
258        if ( ! $modules->is_active( self::DASHBOARD_MODULE ) ) {
259            return;
260        }
261
262        update_option( self::DASHBOARD_MODULE_ACTIVATED_OPTION, true, false );
263
264        // This page already claimed the slug at init 10, but the dashboard only loads on the next request.
265        if ( ! $was_active && self::is_page_request() ) {
266            wp_safe_redirect( self::get_page_url() );
267            exit( 0 );
268        }
269    }
270}
271
272add_action( 'init', array( WPCOM_Scan::class, 'init' ) );
273add_action( 'admin_post_' . WPCOM_Scan::ENABLE_ACTION, array( WPCOM_Scan::class, 'handle_enable' ) );
274// After Jetpack::activate_new_modules() on init 10, and before admin_menu registers the page.
275add_action( 'init', array( WPCOM_Scan::class, 'maybe_activate_protect_dashboard_module' ), 20 );