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