Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
26.40% covered (danger)
26.40%
52 / 197
7.69% covered (danger)
7.69%
1 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Red_Bubble_Notifications
26.40% covered (danger)
26.40%
52 / 197
7.69% covered (danger)
7.69%
1 / 13
2763.22
0.00% covered (danger)
0.00%
0 / 1
 register_rest_endpoints
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
2.01
 permissions_callback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_paid_plans_plugins_requirements
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 check_for_broken_modules
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
11.84
 alert_if_missing_connection
56.52% covered (warning)
56.52%
13 / 23
0.00% covered (danger)
0.00%
0 / 1
8.96
 alert_if_last_backup_failed
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 alert_if_protect_has_threats
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 alert_if_paid_plan_expiring
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
506
 alert_if_paid_plan_requires_plugin_install_or_activation
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 add_red_bubble_alerts
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 get_cached_alerts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_red_bubble_alerts
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 rest_api_get_red_bubble_alerts
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Sets up the Red Bubble Notifications rest api endpoint and helper functions
4 *
5 * @package automattic/my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Jetpack_Options;
12use WP_Error;
13use WP_REST_Request;
14use WP_REST_Response;
15
16/**
17 * Registers REST route for getting red bubble notification data
18 * and includes all helper functions related to red bubble notifications
19 */
20class Red_Bubble_Notifications {
21    private const MISSING_CONNECTION_NOTIFICATION_KEY = 'missing-connection';
22    private const MY_JETPACK_RED_BUBBLE_TRANSIENT_KEY = 'my-jetpack-red-bubble-transient';
23
24    /**
25     * Summary of register_rest_routes
26     *
27     * @return void
28     */
29    public static function register_rest_endpoints() {
30        register_rest_route(
31            'my-jetpack/v1',
32            'red-bubble-notifications',
33            array(
34                'methods'             => \WP_REST_Server::CREATABLE,
35                'callback'            => __CLASS__ . '::rest_api_get_red_bubble_alerts',
36                'permission_callback' => __CLASS__ . '::permissions_callback',
37                'args'                => array(
38                    'dismissal_cookies' => array(
39                        'type'              => 'array',
40                        'description'       => 'Array of dismissal cookies to set for the red bubble notifications.',
41                        'required'          => false,
42                        'items'             => array(
43                            'type' => 'string',
44                        ),
45                        'sanitize_callback' => function ( $param ) {
46                            if ( ! is_array( $param ) ) {
47                                return array();
48                            }
49                            return array_map( 'sanitize_text_field', $param );
50                        },
51                    ),
52                ),
53            )
54        );
55    }
56
57    /**
58     * Check user capability to access the endpoint.
59     *
60     * @access public
61     * @static
62     *
63     * @return true|WP_Error
64     */
65    public static function permissions_callback() {
66        return current_user_can( 'edit_posts' );
67    }
68
69    /**
70     * Gets the plugins that need installed or activated for each paid plan.
71     *
72     * @return array
73     */
74    public static function get_paid_plans_plugins_requirements() {
75        $plugin_requirements = array();
76        foreach ( Products::get_products_classes() as $slug => $product_class ) {
77            // Skip these- we don't show them in My Jetpack.
78            if ( in_array( $slug, Products::get_not_shown_products(), true ) ) {
79                continue;
80            }
81            // Skip CRM from installation requirements - e.g. don't enforce installation for Complete plan users
82            if ( $slug === 'crm' ) {
83                continue;
84            }
85            if ( ! $product_class::has_paid_plan_for_product() ) {
86                continue;
87            }
88            $purchase = $product_class::get_paid_plan_purchase_for_product();
89            if ( ! $purchase ) {
90                continue;
91            }
92            // Check if required plugin needs installed or activated.
93            if ( ! $product_class::is_plugin_installed() ) {
94                // Plugin needs installed (and activated)
95                $plugin_requirements[ $purchase->product_slug ]['needs_installed'][] = $product_class::$slug;
96            } elseif ( ! $product_class::is_plugin_active() ) {
97                // Plugin is installed, but not activated.
98                $plugin_requirements[ $purchase->product_slug ]['needs_activated_only'][] = $product_class::$slug;
99            }
100        }
101
102        return $plugin_requirements;
103    }
104
105    /**
106     * Check for features broken by a disconnected user or site
107     *
108     * @return array
109     */
110    public static function check_for_broken_modules() {
111        $connection        = new Connection_Manager();
112        $is_user_connected = $connection->is_user_connected() || $connection->has_connected_owner();
113        $is_site_connected = $connection->is_connected();
114        $broken_modules    = array(
115            'needs_site_connection' => array(),
116            'needs_user_connection' => array(),
117        );
118
119        if ( $is_user_connected && $is_site_connected ) {
120            return $broken_modules;
121        }
122
123        $products                    = Products::get_products_classes();
124        $historically_active_modules = Jetpack_Options::get_option( 'historically_active_modules', array() );
125
126        foreach ( $products as $product ) {
127            if ( ! in_array( $product::$slug, $historically_active_modules, true ) ) {
128                continue;
129            }
130
131            if ( $product::$requires_user_connection && ! $is_user_connected ) {
132                if ( ! in_array( $product::$slug, $broken_modules['needs_user_connection'], true ) ) {
133                    $broken_modules['needs_user_connection'][] = $product::$slug;
134                }
135            } elseif ( ! $is_site_connected ) {
136                if ( ! in_array( $product::$slug, $broken_modules['needs_site_connection'], true ) ) {
137                    $broken_modules['needs_site_connection'][] = $product::$slug;
138                }
139            }
140        }
141
142        return $broken_modules;
143    }
144
145    /**
146     * Add an alert slug if the site is missing a site connection, or has no connection owner recorded and the current user can become one.
147     *
148     * @since 6.7.1 Only alerts about a missing user connection when no connection owner is recorded, and to users who can take the vacant owner slot.
149     *
150     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
151     * @return array
152     */
153    public static function alert_if_missing_connection( array $red_bubble_slugs ) {
154        $broken_modules = self::check_for_broken_modules();
155        $connection     = new Connection_Manager();
156
157        // Checking for site connection issues first.
158        if ( ! empty( $broken_modules['needs_site_connection'] ) ) {
159            $red_bubble_slugs[ self::MISSING_CONNECTION_NOTIFICATION_KEY ] = array(
160                'type'     => 'site',
161                'is_error' => true,
162            );
163            return $red_bubble_slugs;
164        }
165
166        // A recorded owner without a token is reported as a connection error instead. Only a user who
167        // can take the vacant owner slot (see Manager::authorize()) fixes a site without an owner.
168        if ( ! empty( $broken_modules['needs_user_connection'] )
169            && ! Jetpack_Options::get_option( 'master_user' )
170            && current_user_can( 'jetpack_connect' ) ) {
171            $red_bubble_slugs[ self::MISSING_CONNECTION_NOTIFICATION_KEY ] = array(
172                'type'     => 'user',
173                'is_error' => true,
174            );
175            return $red_bubble_slugs;
176        }
177
178        if ( ! $connection->is_connected() ) {
179            $red_bubble_slugs[ self::MISSING_CONNECTION_NOTIFICATION_KEY ] = array(
180                'type'     => 'site',
181                'is_error' => false,
182            );
183            return $red_bubble_slugs;
184        }
185
186        return $red_bubble_slugs;
187    }
188
189    /**
190     * Add an alert slug if Backups are failing or having an issue.
191     *
192     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
193     * @return array
194     */
195    public static function alert_if_last_backup_failed( array $red_bubble_slugs ) {
196        // Backup is not supported on multisite installations.
197        if ( is_multisite() ) {
198            return $red_bubble_slugs;
199        }
200        // Make sure the Notice wasn't previously dismissed.
201        if ( ! empty( $_COOKIE['backup_failure_dismissed'] ) ) {
202            return $red_bubble_slugs;
203        }
204        // Make sure there's a Backup paid plan
205        if ( ! Products\Backup::is_plugin_active() || ! Products\Backup::has_paid_plan_for_product() ) {
206            return $red_bubble_slugs;
207        }
208        // Make sure the plan isn't just recently purchased in last 30min.
209        // Give some time to queue & run the first backup.
210        $purchase = Products\Backup::get_paid_plan_purchase_for_product();
211        if ( $purchase ) {
212            $thirty_minutes_after_plan_purchase = strtotime( $purchase->subscribed_date . ' +30 minutes' );
213            if ( strtotime( 'now' ) < $thirty_minutes_after_plan_purchase ) {
214                return $red_bubble_slugs;
215            }
216        }
217
218        $backup_failed_status = Products\Backup::does_module_need_attention();
219        if ( $backup_failed_status ) {
220            $red_bubble_slugs['backup_failure'] = $backup_failed_status;
221        }
222
223        return $red_bubble_slugs;
224    }
225
226    /**
227     * Add an alert slug if Protect has scan threats/vulnerabilities.
228     *
229     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
230     * @return array
231     */
232    public static function alert_if_protect_has_threats( array $red_bubble_slugs ) {
233        // Scan is not supported on multisite installations.
234        if ( is_multisite() ) {
235            return $red_bubble_slugs;
236        }
237        // Make sure the Notice hasn't been dismissed.
238        if ( ! empty( $_COOKIE['protect_threats_detected_dismissed'] ) ) {
239            return $red_bubble_slugs;
240        }
241        // Make sure we're dealing with the Protect product only
242        if ( ! Products\Protect::has_paid_plan_for_product() ) {
243            return $red_bubble_slugs;
244        }
245
246        $protect_threats_status = Products\Protect::does_module_need_attention();
247
248        if ( $protect_threats_status ) {
249            $red_bubble_slugs['protect_has_threats'] = $protect_threats_status;
250        }
251
252        return $red_bubble_slugs;
253    }
254
255    /**
256     * Add an alert slug if any paid plan/products are expiring or expired.
257     *
258     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
259     * @return array
260     */
261    public static function alert_if_paid_plan_expiring( array $red_bubble_slugs ) {
262        $connection = new Connection_Manager();
263        if ( ! $connection->is_connected() ) {
264            return $red_bubble_slugs;
265        }
266        $product_classes = Products::get_products_classes();
267
268        $products_included_in_expiring_plan = array();
269        foreach ( $product_classes as $key => $product ) {
270            // Skip these- we don't show them in My Jetpack.
271            if ( in_array( $key, Products::get_not_shown_products(), true ) ) {
272                continue;
273            }
274
275            if ( $product::has_paid_plan_for_product() ) {
276                $purchase = $product::get_paid_plan_purchase_for_product();
277                if ( $purchase ) {
278                    // Check if this product is covered by an active bundle plan
279                    $is_covered_by_active_bundle = false;
280                    if ( ! $product::is_bundle_product() ) {
281                        foreach ( $product_classes as $bundle_product ) {
282                            if ( $bundle_product::is_bundle_product() &&
283                                $bundle_product::has_paid_plan_for_product() &&
284                                ! $bundle_product::is_paid_plan_expired() &&
285                                ! $bundle_product::is_paid_plan_expiring() &&
286                                method_exists( $bundle_product, 'get_supported_products' ) &&
287                                in_array( $key, $bundle_product::get_supported_products(), true ) ) {
288                                $is_covered_by_active_bundle = true;
289                                break;
290                            }
291                        }
292                    }
293
294                    // Only show expiration alerts if not covered by an active bundle
295                    if ( ! $is_covered_by_active_bundle ) {
296                        $redbubble_notice_data = array(
297                            'product_slug'   => $purchase->product_slug,
298                            'product_name'   => $purchase->product_name,
299                            'expiry_date'    => $purchase->expiry_date,
300                            'expiry_message' => $purchase->expiry_message,
301                            'manage_url'     => $product::get_manage_paid_plan_purchase_url(),
302                        );
303
304                        if ( $product::is_paid_plan_expired() && empty( $_COOKIE[ "$purchase->product_slug--plan_expired_dismissed" ] ) ) {
305                            $red_bubble_slugs[ "$purchase->product_slug--plan_expired" ] = $redbubble_notice_data;
306                            if ( ! $product::is_bundle_product() ) {
307                                $products_included_in_expiring_plan[ "$purchase->product_slug--plan_expired" ][] = $product::get_name();
308                            }
309                        }
310                        if ( $product::is_paid_plan_expiring() && empty( $_COOKIE[ "$purchase->product_slug--plan_expiring_soon_dismissed" ] ) ) {
311                            $red_bubble_slugs[ "$purchase->product_slug--plan_expiring_soon" ]               = $redbubble_notice_data;
312                            $red_bubble_slugs[ "$purchase->product_slug--plan_expiring_soon" ]['manage_url'] = $product::get_renew_paid_plan_purchase_url();
313                            if ( ! $product::is_bundle_product() ) {
314                                $products_included_in_expiring_plan[ "$purchase->product_slug--plan_expiring_soon" ][] = $product::get_name();
315                            }
316                        }
317                    }
318                }
319            }
320        }
321
322        foreach ( $products_included_in_expiring_plan as $expiring_plan => $products ) {
323            $red_bubble_slugs[ $expiring_plan ]['products_effected'] = $products;
324        }
325
326        return $red_bubble_slugs;
327    }
328
329    /**
330     * Add an alert slug if a site's paid plan requires a plugin install and/or activation.
331     *
332     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
333     * @return array
334     */
335    public static function alert_if_paid_plan_requires_plugin_install_or_activation( array $red_bubble_slugs ) {
336        $connection = new Connection_Manager();
337        // Don't trigger red bubble (and show notice) when the site is not connected or if the
338        // user doesn't have plugin installation/activation permissions.
339        if ( ! $connection->is_connected() || ! current_user_can( 'activate_plugins' ) ) {
340            return $red_bubble_slugs;
341        }
342
343        $plugins_needing_installed_activated = self::get_paid_plans_plugins_requirements();
344        if ( empty( $plugins_needing_installed_activated ) ) {
345            return $red_bubble_slugs;
346        }
347
348        foreach ( $plugins_needing_installed_activated as $plan_slug => $plugins_requirements ) {
349            if ( empty( $_COOKIE[ "$plan_slug--plugins_needing_installed_dismissed" ] ) ) {
350                $red_bubble_slugs[ "$plan_slug--plugins_needing_installed_activated" ] = $plugins_requirements;
351            }
352        }
353
354        return $red_bubble_slugs;
355    }
356
357    /**
358     *  Add relevant red bubble notifications
359     *
360     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
361     * @return array
362     */
363    public static function add_red_bubble_alerts( array $red_bubble_slugs ) {
364        if ( wp_doing_ajax() ) {
365            return array();
366        }
367        $connection               = new Connection_Manager();
368        $welcome_banner_dismissed = Jetpack_Options::get_option( 'dismissed_welcome_banner', false );
369        if ( Initializer::is_jetpack_user_new() && ! $welcome_banner_dismissed ) {
370            $red_bubble_slugs['welcome-banner-active'] = array(
371                'is_silent' => $connection->is_connected(), // we don't display the red bubble if the user is connected
372            );
373            return $red_bubble_slugs;
374        } else {
375            return array_merge(
376                self::alert_if_missing_connection( $red_bubble_slugs ),
377                self::alert_if_last_backup_failed( $red_bubble_slugs ),
378                self::alert_if_paid_plan_expiring( $red_bubble_slugs ),
379                self::alert_if_protect_has_threats( $red_bubble_slugs ),
380                self::alert_if_paid_plan_requires_plugin_install_or_activation( $red_bubble_slugs )
381            );
382        }
383    }
384
385    /**
386     * Get cached red bubble alerts without triggering expensive computation.
387     * Returns the cached transient value or false if not cached.
388     *
389     * @return array|false Cached alerts or false if cache is empty.
390     */
391    public static function get_cached_alerts() {
392        return get_transient( self::MY_JETPACK_RED_BUBBLE_TRANSIENT_KEY );
393    }
394
395    /**
396     * Collect all possible alerts that we might use a red bubble notification for
397     *
398     * @param bool $bypass_cache - whether to bypass the red bubble cache.
399     * @return array
400     */
401    public static function get_red_bubble_alerts( bool $bypass_cache = false ) {
402        static $red_bubble_alerts = array();
403
404        // check for stored alerts
405        $stored_alerts = get_transient( self::MY_JETPACK_RED_BUBBLE_TRANSIENT_KEY );
406
407        // Cache bypass for red bubbles should only happen on the My Jetpack page
408        if ( $stored_alerts !== false && ! ( $bypass_cache ) ) {
409            return $stored_alerts;
410        }
411
412        // go find the alerts
413        $red_bubble_alerts = apply_filters( 'my_jetpack_red_bubble_notification_slugs', $red_bubble_alerts );
414
415        // cache the alerts for one hour
416        set_transient( self::MY_JETPACK_RED_BUBBLE_TRANSIENT_KEY, $red_bubble_alerts, 3600 );
417
418        return $red_bubble_alerts;
419    }
420
421    /**
422     * Get the red bubble alerts, bypassing cache when called via the REST API
423     *
424     * @param WP_REST_Request $request The REST API request object.
425     *
426     * @return WP_Error|WP_REST_Response
427     */
428    public static function rest_api_get_red_bubble_alerts( $request ) {
429        add_filter( 'my_jetpack_red_bubble_notification_slugs', array( __CLASS__, 'add_red_bubble_alerts' ) );
430
431        $cookies = $request->get_param( 'dismissal_cookies' );
432
433        // Update $_COOKIE superglobal with the provided cookies
434        if ( ! empty( $cookies ) && is_array( $cookies ) ) {
435            foreach ( $cookies as $cookie_string ) {
436                // Parse cookie string in format "name=value"
437                $parts = explode( '=', $cookie_string, 2 );
438                if ( count( $parts ) === 2 ) {
439                    $name             = trim( $parts[0] );
440                    $value            = trim( $parts[1] );
441                    $_COOKIE[ $name ] = $value;
442                }
443            }
444        }
445
446        $red_bubble_alerts = self::get_red_bubble_alerts( true );
447        return rest_ensure_response( $red_bubble_alerts );
448    }
449}