Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
43.90% covered (danger)
43.90%
90 / 205
7.69% covered (danger)
7.69%
1 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Red_Bubble_Notifications
43.90% covered (danger)
43.90%
90 / 205
7.69% covered (danger)
7.69%
1 / 13
1391.66
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
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
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            if ( $product::has_paid_plan_for_product() ) {
289                $purchase = $product::get_paid_plan_purchase_for_product();
290                if ( $purchase ) {
291                    // Check if this product is covered by an active bundle plan
292                    $is_covered_by_active_bundle = false;
293                    if ( ! $product::is_bundle_product() ) {
294                        foreach ( $product_classes as $bundle_product ) {
295                            if ( $bundle_product::is_bundle_product() &&
296                                $bundle_product::has_paid_plan_for_product() &&
297                                ! $bundle_product::is_paid_plan_expired() &&
298                                ! $bundle_product::is_paid_plan_expiring() &&
299                                method_exists( $bundle_product, 'get_supported_products' ) &&
300                                in_array( $key, $bundle_product::get_supported_products(), true ) ) {
301                                $is_covered_by_active_bundle = true;
302                                break;
303                            }
304                        }
305                    }
306
307                    // Only show expiration alerts if not covered by an active bundle
308                    if ( ! $is_covered_by_active_bundle ) {
309                        $redbubble_notice_data = array(
310                            'product_slug'   => $purchase->product_slug,
311                            'product_name'   => $purchase->product_name,
312                            'expiry_date'    => $purchase->expiry_date,
313                            'expiry_message' => $purchase->expiry_message,
314                            'manage_url'     => $product::get_manage_paid_plan_purchase_url(),
315                        );
316
317                        if ( $product::is_paid_plan_expired() && empty( $_COOKIE[ "$purchase->product_slug--plan_expired_dismissed" ] ) ) {
318                            $red_bubble_slugs[ "$purchase->product_slug--plan_expired" ] = $redbubble_notice_data;
319                            if ( ! $product::is_bundle_product() ) {
320                                $products_included_in_expiring_plan[ "$purchase->product_slug--plan_expired" ][] = $product::get_name();
321                            }
322                        }
323                        if ( $product::is_paid_plan_expiring() && empty( $_COOKIE[ "$purchase->product_slug--plan_expiring_soon_dismissed" ] ) ) {
324                            $red_bubble_slugs[ "$purchase->product_slug--plan_expiring_soon" ]               = $redbubble_notice_data;
325                            $red_bubble_slugs[ "$purchase->product_slug--plan_expiring_soon" ]['manage_url'] = $product::get_renew_paid_plan_purchase_url();
326                            if ( ! $product::is_bundle_product() ) {
327                                $products_included_in_expiring_plan[ "$purchase->product_slug--plan_expiring_soon" ][] = $product::get_name();
328                            }
329                        }
330                    }
331                }
332            }
333        }
334
335        foreach ( $products_included_in_expiring_plan as $expiring_plan => $products ) {
336            $red_bubble_slugs[ $expiring_plan ]['products_effected'] = $products;
337        }
338
339        return $red_bubble_slugs;
340    }
341
342    /**
343     * Add an alert slug if a site's paid plan requires a plugin install and/or activation.
344     *
345     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
346     * @return array
347     */
348    public static function alert_if_paid_plan_requires_plugin_install_or_activation( array $red_bubble_slugs ) {
349        $connection = new Connection_Manager();
350        // Don't trigger red bubble (and show notice) when the site is not connected or if the
351        // user doesn't have plugin installation/activation permissions.
352        if ( ! $connection->is_connected() || ! current_user_can( 'activate_plugins' ) ) {
353            return $red_bubble_slugs;
354        }
355
356        $plugins_needing_installed_activated = self::get_paid_plans_plugins_requirements();
357        if ( empty( $plugins_needing_installed_activated ) ) {
358            return $red_bubble_slugs;
359        }
360
361        foreach ( $plugins_needing_installed_activated as $plan_slug => $plugins_requirements ) {
362            if ( empty( $_COOKIE[ "$plan_slug--plugins_needing_installed_dismissed" ] ) ) {
363                $red_bubble_slugs[ "$plan_slug--plugins_needing_installed_activated" ] = $plugins_requirements;
364            }
365        }
366
367        return $red_bubble_slugs;
368    }
369
370    /**
371     *  Add relevant red bubble notifications
372     *
373     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
374     * @return array
375     */
376    public static function add_red_bubble_alerts( array $red_bubble_slugs ) {
377        if ( wp_doing_ajax() ) {
378            return array();
379        }
380        $connection               = new Connection_Manager();
381        $welcome_banner_dismissed = Jetpack_Options::get_option( 'dismissed_welcome_banner', false );
382        if ( Initializer::is_jetpack_user_new() && ! $welcome_banner_dismissed ) {
383            $red_bubble_slugs['welcome-banner-active'] = array(
384                'is_silent' => $connection->is_connected(), // we don't display the red bubble if the user is connected
385            );
386            return $red_bubble_slugs;
387        } else {
388            return array_merge(
389                self::alert_if_missing_connection( $red_bubble_slugs ),
390                self::alert_if_last_backup_failed( $red_bubble_slugs ),
391                self::alert_if_paid_plan_expiring( $red_bubble_slugs ),
392                self::alert_if_protect_has_threats( $red_bubble_slugs ),
393                self::alert_if_paid_plan_requires_plugin_install_or_activation( $red_bubble_slugs )
394            );
395        }
396    }
397
398    /**
399     * Get cached red bubble alerts without triggering expensive computation.
400     * Returns the cached transient value or false if not cached.
401     *
402     * @return array|false Cached alerts or false if cache is empty.
403     */
404    public static function get_cached_alerts() {
405        return get_transient( self::MY_JETPACK_RED_BUBBLE_TRANSIENT_KEY );
406    }
407
408    /**
409     * Collect all possible alerts that we might use a red bubble notification for
410     *
411     * @param bool $bypass_cache - whether to bypass the red bubble cache.
412     * @return array
413     */
414    public static function get_red_bubble_alerts( bool $bypass_cache = false ) {
415        static $red_bubble_alerts = array();
416
417        // check for stored alerts
418        $stored_alerts = get_transient( self::MY_JETPACK_RED_BUBBLE_TRANSIENT_KEY );
419
420        // Cache bypass for red bubbles should only happen on the My Jetpack page
421        if ( $stored_alerts !== false && ! ( $bypass_cache ) ) {
422            return $stored_alerts;
423        }
424
425        // go find the alerts
426        $red_bubble_alerts = apply_filters( 'my_jetpack_red_bubble_notification_slugs', $red_bubble_alerts );
427
428        // cache the alerts for one hour
429        set_transient( self::MY_JETPACK_RED_BUBBLE_TRANSIENT_KEY, $red_bubble_alerts, 3600 );
430
431        return $red_bubble_alerts;
432    }
433
434    /**
435     * Get the red bubble alerts, bypassing cache when called via the REST API
436     *
437     * @param WP_REST_Request $request The REST API request object.
438     *
439     * @return WP_Error|WP_REST_Response
440     */
441    public static function rest_api_get_red_bubble_alerts( $request ) {
442        add_filter( 'my_jetpack_red_bubble_notification_slugs', array( __CLASS__, 'add_red_bubble_alerts' ) );
443
444        $cookies = $request->get_param( 'dismissal_cookies' );
445
446        // Update $_COOKIE superglobal with the provided cookies
447        if ( ! empty( $cookies ) && is_array( $cookies ) ) {
448            foreach ( $cookies as $cookie_string ) {
449                // Parse cookie string in format "name=value"
450                $parts = explode( '=', $cookie_string, 2 );
451                if ( count( $parts ) === 2 ) {
452                    $name             = trim( $parts[0] );
453                    $value            = trim( $parts[1] );
454                    $_COOKIE[ $name ] = $value;
455                }
456            }
457        }
458
459        $red_bubble_alerts = self::get_red_bubble_alerts( true );
460        return rest_ensure_response( $red_bubble_alerts );
461    }
462}