Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.77% |
59 / 65 |
|
80.00% |
8 / 10 |
CRAP | |
0.00% |
0 / 1 |
| WPCOM_Scan | |
96.72% |
59 / 61 |
|
80.00% |
8 / 10 |
43 | |
0.00% |
0 / 1 |
| should_register | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| is_dashboard_module_off | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| get_state | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
5 | |||
| is_awaiting_connection | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| get_initial_state | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
6 | |||
| register_page | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| remove_menu_entry | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| handle_enable | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
7.03 | |||
| filter_available_modules | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| maybe_activate_protect_dashboard_module | |
92.86% |
13 / 14 |
|
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 | |
| 9 | namespace Automattic\Jetpack\Jetpack_Mu_Wpcom; |
| 10 | |
| 11 | use Automattic\Jetpack\Connection\Manager as Connection_Manager; |
| 12 | use Automattic\Jetpack\Modules; |
| 13 | |
| 14 | require_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 | */ |
| 19 | class 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 | |
| 272 | add_action( 'init', array( WPCOM_Scan::class, 'init' ) ); |
| 273 | add_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. |
| 275 | add_action( 'init', array( WPCOM_Scan::class, 'maybe_activate_protect_dashboard_module' ), 20 ); |