Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.02% covered (danger)
36.02%
76 / 211
6.67% covered (danger)
6.67%
1 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Admin
36.54% covered (danger)
36.54%
76 / 208
6.67% covered (danger)
6.67%
1 / 15
1715.73
0.00% covered (danger)
0.00%
0 / 1
 init
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 add_no_store_header
n/a
0 / 0
n/a
0 / 0
1
 __construct
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 additional_css_menu
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
90
 customizer_redirect
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 customizer_link
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 sort_requires_connection_last
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_modules
89.13% covered (warning)
89.13%
41 / 46
0.00% covered (danger)
0.00%
0 / 1
13.22
 is_module_available
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
12.04
 get_module_unavailable_reason
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
15.22
 handle_unrecognized_action
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
110
 fix_redirect
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 admin_menu_debugger
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 wrap_debugger_page
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 debugger_page
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 should_display_jitms_on_screen
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * Build the Jetpack admin menu as a whole.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Admin_UI\Admin_Menu;
9use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
10use Automattic\Jetpack\Partner_Coupon as Jetpack_Partner_Coupon;
11use Automattic\Jetpack\Status;
12use Automattic\Jetpack\Status\Host;
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit( 0 );
16}
17
18/**
19 * Build the Jetpack admin menu as a whole.
20 */
21class Jetpack_Admin {
22
23    /**
24     * Static instance.
25     *
26     * @var Jetpack_Admin
27     */
28    private static $instance = null;
29
30    /**
31     * Initialize and fetch the static instance.
32     *
33     * @return self
34     */
35    public static function init() {
36        if ( self::$instance === null ) {
37            self::$instance = new Jetpack_Admin();
38        }
39        return self::$instance;
40    }
41
42    /**
43     * Filter callback to add `no-store` to the `Cache-Control` header.
44     *
45     * @deprecated 14.9
46     * @param array $headers Headers array.
47     * @return array Modified headers array.
48     */
49    public static function add_no_store_header( $headers ) {
50        _deprecated_function( __METHOD__, '14.9' );
51        $headers['Cache-Control'] .= ', no-store';
52        return $headers;
53    }
54
55    /** Constructor. */
56    private function __construct() {
57        require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class.jetpack-react-page.php';
58        $jetpack_react = new Jetpack_React_Page();
59
60        require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class.jetpack-settings-page.php';
61        $fallback_page = new Jetpack_Settings_Page();
62
63        require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-about-page.php';
64        $jetpack_about = new Jetpack_About_Page();
65
66        add_action( 'admin_init', array( $jetpack_react, 'react_redirects' ), 0 );
67        add_action( 'admin_menu', array( $jetpack_react, 'add_actions' ), 998 );
68        add_action( 'admin_menu', array( $jetpack_react, 'remove_jetpack_menu' ), 2000 );
69        add_action( 'jetpack_admin_menu', array( $jetpack_react, 'jetpack_add_settings_sub_nav_item' ) );
70        add_action( 'jetpack_admin_menu', array( $this, 'admin_menu_debugger' ) );
71        add_action( 'jetpack_admin_menu', array( $fallback_page, 'add_actions' ) );
72        add_action( 'jetpack_admin_menu', array( $jetpack_about, 'add_actions' ) );
73
74        // Add redirect to current page for activation/deactivation of modules.
75        add_action( 'jetpack_pre_activate_module', array( $this, 'fix_redirect' ), 10, 2 );
76        add_action( 'jetpack_pre_deactivate_module', array( $this, 'fix_redirect' ), 10, 2 );
77
78        // Add module bulk actions handler.
79        add_action( 'jetpack_unrecognized_action', array( $this, 'handle_unrecognized_action' ) );
80
81        if ( class_exists( 'Akismet_Admin' ) ) {
82            // If the site has Jetpack Anti-spam, change the Akismet menu label and logo accordingly.
83            $site_products         = array_column( Jetpack_Plan::get_products(), 'product_slug' );
84            $has_anti_spam_product = count( array_intersect( array( 'jetpack_anti_spam', 'jetpack_anti_spam_monthly' ), $site_products ) ) > 0;
85
86            if ( Jetpack_Plan::supports( 'akismet' ) || Jetpack_Plan::supports( 'antispam' ) || $has_anti_spam_product ) {
87                // Prevent Akismet from adding a menu item.
88                add_action(
89                    'admin_menu',
90                    function () {
91                        remove_action( 'admin_menu', array( 'Akismet_Admin', 'admin_menu' ), 5 );
92                    },
93                    4
94                );
95
96                // Add an Anti-spam menu item for Jetpack. This is handled automatically by the Admin_Menu as long as it has been initialized.
97                Admin_Menu::init();
98            }
99        }
100
101        // Ensure an Additional CSS menu item is added to the Appearance menu whenever Jetpack is connected.
102        add_action( 'admin_menu', array( $this, 'additional_css_menu' ) );
103
104        add_filter( 'jetpack_display_jitms_on_screen', array( $this, 'should_display_jitms_on_screen' ), 10, 2 );
105
106        // Register Jetpack partner coupon hooks.
107        Jetpack_Partner_Coupon::register_coupon_admin_hooks( 'jetpack', Jetpack::admin_url() );
108    }
109
110    /**
111     * Handle our Additional CSS menu item and legacy page declaration.
112     *
113     * @since 11.0 . Prior to that, this function was located in custom-css-4.7.php (now custom-css.php).
114     */
115    public static function additional_css_menu() {
116        /*
117         * Custom CSS for the Customizer is deprecated for block themes as of WP 6.1, so we only expose it with a menu
118         * if the site already has existing CSS code.
119         */
120        if ( wp_is_block_theme() ) {
121            $styles = wp_get_custom_css();
122            if ( ! $styles ) {
123                return;
124            }
125        }
126
127        // If the site is a WoA site and the custom-css feature is not available, return.
128        // See https://github.com/Automattic/jetpack/pull/19965 for more on how this menu item is dealt with on WoA sites.
129        if ( ( new Host() )->is_woa_site() && ! ( in_array( 'custom-css', Jetpack::get_available_modules(), true ) ) ) {
130            return;
131        } elseif (
132            class_exists( 'Jetpack' ) && (
133                Jetpack::is_module_active( 'custom-css' ) || // If the Custom CSS module is enabled, add the Additional CSS menu item and link to the Customizer.
134                ( wp_is_block_theme() && ! empty( wp_get_custom_css() ) ) // Do the same if the theme is block-based but has existing custom CSS.
135            )
136        ) {
137            // Add in our legacy page to support old bookmarks and such.
138            add_submenu_page( '', __( 'CSS', 'jetpack' ), __( 'Additional CSS', 'jetpack' ), 'edit_theme_options', 'editcss', array( __CLASS__, 'customizer_redirect' ) );
139
140            // Add in our new page slug that will redirect to the customizer.
141            $hook = add_theme_page( __( 'CSS', 'jetpack' ), __( 'Additional CSS', 'jetpack' ), 'edit_theme_options', 'editcss-customizer-redirect', array( __CLASS__, 'customizer_redirect' ) );
142            add_action( "load-{$hook}", array( __CLASS__, 'customizer_redirect' ) );
143        }
144    }
145
146    /**
147     * Handle the redirect for the customizer.  This is necessary because
148     * we can't directly add customizer links to the admin menu.
149     *
150     * @since 11.0 . Prior to that, this function was located in custom-css-4.7.php (now custom-css.php).
151     *
152     * There is a core patch in trac that would make this unnecessary.
153     *
154     * @link https://core.trac.wordpress.org/ticket/39050
155     *
156     * @return never
157     */
158    public static function customizer_redirect() {
159        wp_safe_redirect(
160            self::customizer_link(
161                array(
162                    'return_url' => wp_get_referer(),
163                )
164            )
165        );
166        exit( 0 );
167    }
168
169    /**
170     * Build the URL to deep link to the Customizer.
171     *
172     * You can modify the return url via $args.
173     *
174     * @since 11.0 in this file. This method is also located in custom-css-4.7.php to cover legacy scenarios.
175     *
176     * @param array $args Array of parameters.
177     * @return string
178     */
179    public static function customizer_link( $args = array() ) {
180        if ( isset( $_SERVER['REQUEST_URI'] ) ) {
181            $args = wp_parse_args(
182                $args,
183                array(
184                    'return_url' => rawurlencode( wp_unslash( $_SERVER['REQUEST_URI'] ) ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
185                )
186            );
187        }
188
189        return add_query_arg(
190            array(
191                array(
192                    'autofocus' => array(
193                        'section' => 'custom_css',
194                    ),
195                ),
196                'return' => $args['return_url'],
197            ),
198            admin_url( 'customize.php' )
199        );
200    }
201
202    /**
203     * Sort callback to put modules with `requires_connection` last.
204     *
205     * @param array $module1 Module data.
206     * @param array $module2 Module data.
207     * @return int Indicating the relative ordering of module1 and module2.
208     */
209    public static function sort_requires_connection_last( $module1, $module2 ) {
210        return ( (bool) $module1['requires_connection'] ) <=> ( (bool) $module2['requires_connection'] );
211    }
212
213    /**
214     * Produce JS understandable objects of modules containing information for
215     * presentation like description, name, configuration url, etc.
216     */
217    public function get_modules() {
218        include_once JETPACK__PLUGIN_DIR . 'modules/module-info.php';
219        $available_modules = Jetpack::get_available_modules();
220        $active_modules    = Jetpack::get_active_modules();
221        $modules           = array();
222        $jetpack_active    = Jetpack::is_connection_ready() || ( new Status() )->is_offline_mode();
223        $overrides         = Jetpack_Modules_Overrides::instance();
224        foreach ( $available_modules as $module ) {
225            $module_array = Jetpack::get_module( $module );
226            if ( $module_array ) {
227                /**
228                 * Filters each module's short description.
229                 *
230                 * @since 3.0.0
231                 *
232                 * @param string $module_array['description'] Module description.
233                 * @param string $module Module slug.
234                 */
235                $short_desc = apply_filters( 'jetpack_short_module_description', $module_array['description'], $module );
236                // Fix: correct multibyte strings truncate with checking for mbstring extension.
237                $short_desc_trunc = ( function_exists( 'mb_strlen' ) )
238                            ? ( ( mb_strlen( $short_desc ) > 143 )
239                                ? mb_substr( $short_desc, 0, 140 ) . '...'
240                                : $short_desc )
241                            : ( ( strlen( $short_desc ) > 143 )
242                                ? substr( $short_desc, 0, 140 ) . '...'
243                                : $short_desc );
244
245                $module_array['module'] = $module;
246
247                $is_available = self::is_module_available( $module_array );
248
249                $module_array['activated']          = ( $jetpack_active ? in_array( $module, $active_modules, true ) : false );
250                $module_array['deactivate_nonce']   = wp_create_nonce( 'jetpack_deactivate-' . $module );
251                $module_array['activate_nonce']     = wp_create_nonce( 'jetpack_activate-' . $module );
252                $module_array['available']          = $is_available;
253                $module_array['unavailable_reason'] = $is_available ? false : self::get_module_unavailable_reason( $module_array );
254                $module_array['short_description']  = $short_desc_trunc;
255                $module_array['configure_url']      = Jetpack::module_configuration_url( $module );
256                $module_array['override']           = $overrides->get_module_override( $module );
257                $module_array['disabled']           = $is_available ? '' : 'disabled="disabled"';
258
259                ob_start();
260                /**
261                 * Allow the display of a "Learn More" button.
262                 * The dynamic part of the action, $module, is the module slug.
263                 *
264                 * @since 3.0.0
265                 */
266                do_action( 'jetpack_learn_more_button_' . $module );
267                $module_array['learn_more_button'] = ob_get_clean();
268
269                ob_start();
270                /**
271                 * Allow the display of information text when Jetpack is connected to WordPress.com.
272                 * The dynamic part of the action, $module, is the module slug.
273                 *
274                 * @since 3.0.0
275                 */
276                do_action( 'jetpack_module_more_info_' . $module );
277
278                /**
279                * Filter the long description of a module.
280                *
281                * @since 3.5.0
282                *
283                * @param string ob_get_clean() The module long description.
284                * @param string $module The module name.
285                */
286                $module_array['long_description'] = apply_filters( 'jetpack_long_module_description', ob_get_clean(), $module );
287
288                ob_start();
289                /**
290                 * Filter the search terms for a module
291                 *
292                 * Search terms are typically added to the module headers, under "Additional Search Queries".
293                 *
294                 * Use syntax:
295                 * function jetpack_$module_search_terms( $terms ) {
296                 *  $terms = _x( 'term 1, term 2', 'search terms', 'jetpack' );
297                 *  return $terms;
298                 * }
299                 * add_filter( 'jetpack_search_terms_$module', 'jetpack_$module_search_terms' );
300                 *
301                 * @since 3.5.0
302                 *
303                 * @param string The search terms (comma-separated).
304                 */
305                echo apply_filters( 'jetpack_search_terms_' . $module, $module_array['additional_search_queries'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
306                $module_array['search_terms'] = ob_get_clean();
307
308                $module_array['configurable'] = false;
309                if (
310                    current_user_can( 'manage_options' ) &&
311                    /**
312                     * Allow the display of a configuration link in the Jetpack Settings screen.
313                     *
314                     * @since 3.0.0
315                     *
316                     * @param string $module Module name.
317                     * @param bool false Should the Configure module link be displayed? Default to false.
318                     */
319                    apply_filters( 'jetpack_module_configurable_' . $module, false )
320                ) {
321                    $module_array['configurable'] = sprintf( '<a href="%1$s">%2$s</a>', esc_url( $module_array['configure_url'] ), __( 'Configure', 'jetpack' ) );
322                }
323
324                $modules[ $module ] = $module_array;
325            }
326        }
327
328        uasort( $modules, array( 'Jetpack', 'sort_modules' ) );
329
330        if ( ! Jetpack::is_connection_ready() ) {
331            uasort( $modules, array( __CLASS__, 'sort_requires_connection_last' ) );
332        }
333
334        return $modules;
335    }
336
337    /**
338     * Check if a module is available.
339     *
340     * @param array $module Module data.
341     */
342    public static function is_module_available( $module ) {
343        if ( ! is_array( $module ) || empty( $module ) ) {
344            return false;
345        }
346
347        /**
348         * We never want to show VaultPress as activatable through Jetpack.
349         */
350        if ( 'vaultpress' === $module['module'] ) {
351            return false;
352        }
353
354        /*
355         * WooCommerce Analytics should only be available
356         * when running WooCommerce 3+
357         */
358        if (
359            'woocommerce-analytics' === $module['module']
360            && (
361                ! class_exists( 'WooCommerce' )
362                || version_compare( WC_VERSION, '3.0', '<' )
363            )
364        ) {
365            return false;
366        }
367
368        /*
369         * In Offline mode, modules that require a site or user
370         * level connection should be unavailable.
371         */
372        if ( ( new Status() )->is_offline_mode() ) {
373            return ! ( $module['requires_connection'] || $module['requires_user_connection'] );
374        }
375
376        /*
377         * Jetpack not connected.
378         */
379        if ( ! Jetpack::is_connection_ready() ) {
380            return false;
381        }
382
383        /*
384         * Jetpack connected at a site level only. Make sure to make
385         * modules that require a user connection unavailable.
386         */
387        if ( ! Jetpack::connection()->has_connected_owner() && $module['requires_user_connection'] ) {
388            return false;
389        }
390
391        return Jetpack_Plan::supports( $module['module'] );
392    }
393
394    /**
395     * Returns why a module is unavailable.
396     *
397     * @param  array $module The module.
398     * @return string|false A string stating why the module is not available or false if the module is available.
399     */
400    public static function get_module_unavailable_reason( $module ) {
401        if ( ! is_array( $module ) || empty( $module ) ) {
402            return false;
403        }
404
405        if ( self::is_module_available( $module ) ) {
406            return false;
407        }
408
409        /**
410         * We never want to show VaultPress as activatable through Jetpack so return an empty string.
411         */
412        if ( 'vaultpress' === $module['module'] ) {
413            return '';
414        }
415
416        /*
417         * WooCommerce Analytics should only be available
418         * when running WooCommerce 3+
419         */
420        if (
421            'woocommerce-analytics' === $module['module']
422            && (
423                    ! class_exists( 'WooCommerce' )
424                    || version_compare( WC_VERSION, '3.0', '<' )
425                )
426            ) {
427            return __( 'Requires WooCommerce 3+ plugin', 'jetpack' );
428        }
429
430        /*
431         * In Offline mode, modules that require a site or user
432         * level connection should be unavailable.
433         */
434        if ( ( new Status() )->is_offline_mode() ) {
435            if ( $module['requires_connection'] || $module['requires_user_connection'] ) {
436                return __( 'Offline mode', 'jetpack' );
437            }
438        }
439
440        /*
441         * Jetpack not connected.
442         */
443        if ( ! Jetpack::is_connection_ready() ) {
444            return __( 'Jetpack is not connected', 'jetpack' );
445        }
446
447        /*
448         * Jetpack connected at a site level only and module requires a user connection.
449         */
450        if ( ! Jetpack::connection()->has_connected_owner() && $module['requires_user_connection'] ) {
451            return __( 'Requires a connected WordPress.com account', 'jetpack' );
452        }
453
454        /*
455         * Plan restrictions.
456         */
457        if ( ! Jetpack_Plan::supports( $module['module'] ) ) {
458            return __( 'Not supported by current plan', 'jetpack' );
459        }
460
461        return '';
462    }
463
464    /**
465     * Handle an unrecognized action.
466     *
467     * @param string $action Action.
468     */
469    public function handle_unrecognized_action( $action ) {
470        switch ( $action ) {
471            case 'bulk-activate':
472                check_admin_referer( 'bulk-jetpack_page_jetpack_modules' );
473                if ( ! current_user_can( 'jetpack_activate_modules' ) ) {
474                    break;
475                }
476
477                $modules = isset( $_GET['modules'] ) ? array_map( 'sanitize_key', wp_unslash( (array) $_GET['modules'] ) ) : array();
478                foreach ( $modules as $module ) {
479                    Jetpack::log( 'activate', $module );
480                    Jetpack::activate_module( $module, false );
481                }
482                // The following two lines will rarely happen, as Jetpack::activate_module normally exits at the end.
483                wp_safe_redirect( wp_get_referer() );
484                exit( 0 );
485            case 'bulk-deactivate':
486                check_admin_referer( 'bulk-jetpack_page_jetpack_modules' );
487                if ( ! current_user_can( 'jetpack_deactivate_modules' ) ) {
488                    break;
489                }
490
491                $modules = isset( $_GET['modules'] ) ? array_map( 'sanitize_key', wp_unslash( (array) $_GET['modules'] ) ) : array();
492                foreach ( $modules as $module ) {
493                    Jetpack::log( 'deactivate', $module );
494                    Jetpack::deactivate_module( $module );
495                    Jetpack::state( 'message', 'module_deactivated' );
496                }
497                Jetpack::state( 'module', $modules );
498                wp_safe_redirect( wp_get_referer() );
499                exit( 0 );
500            default:
501                return;
502        }
503    }
504
505    /**
506     * Fix redirect.
507     *
508     * Apparently we redirect to the referrer instead of whatever WordPress
509     * wants to redirect to when activating and deactivating modules.
510     *
511     * @param string $module Module slug.
512     * @param bool   $redirect Should we exit after the module has been activated. Default to true.
513     */
514    public function fix_redirect( $module, $redirect = true ) {
515        if ( ! $redirect ) {
516            return;
517        }
518        if ( wp_get_referer() ) {
519            add_filter( 'wp_redirect', 'wp_get_referer' );
520        }
521    }
522
523    /**
524     * Add debugger admin menu.
525     */
526    public function admin_menu_debugger() {
527        require_once JETPACK__PLUGIN_DIR . '_inc/lib/debugger.php';
528        Jetpack_Debugger::disconnect_and_redirect();
529        $debugger_hook = add_submenu_page(
530            '',
531            __( 'Debugging Center', 'jetpack' ),
532            '',
533            'manage_options',
534            'jetpack-debugger',
535            array( $this, 'wrap_debugger_page' )
536        );
537        add_action( "admin_head-$debugger_hook", array( 'Jetpack_Debugger', 'jetpack_debug_admin_head' ) );
538    }
539
540    /**
541     * Wrap debugger page.
542     */
543    public function wrap_debugger_page() {
544        nocache_headers();
545        if ( ! current_user_can( 'manage_options' ) ) {
546            die( '-1' );
547        }
548        Jetpack_Admin_Page::wrap_ui( array( $this, 'debugger_page' ), array( 'is-wide' => true ) );
549    }
550
551    /**
552     * Display debugger page.
553     */
554    public function debugger_page() {
555        require_once JETPACK__PLUGIN_DIR . '_inc/lib/debugger.php';
556        Jetpack_Debugger::jetpack_debug_display_handler();
557    }
558
559    /**
560     * Determines if JITMs should display on a particular screen.
561     *
562     * @param bool   $value The default value of the filter.
563     * @param string $screen_id The ID of the screen being tested for JITM display.
564     *
565     * @return bool True if JITMs should display, false otherwise.
566     */
567    public function should_display_jitms_on_screen( $value, $screen_id ) {
568        // Disable all JITMs on these pages.
569        if (
570        in_array(
571            $screen_id,
572            array(
573                'jetpack_page_akismet-key-config',
574                'admin_page_jetpack_modules',
575            ),
576            true
577        ) ) {
578            return false;
579        }
580
581        return $value;
582    }
583}
584Jetpack_Admin::init();