Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.65% covered (danger)
30.65%
61 / 199
7.69% covered (danger)
7.69%
1 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Red_Bubble_Notifications
30.65% covered (danger)
30.65%
61 / 199
7.69% covered (danger)
7.69%
1 / 13
2437.08
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
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
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 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        }
102
103        return $plugin_requirements;
104    }
105
106    /**
107     * Check for features broken by a disconnected user or site
108     *
109     * @return array
110     */
111    public static function check_for_broken_modules() {
112        $connection        = new Connection_Manager();
113        $is_user_connected = $connection->is_user_connected() || $connection->has_connected_owner();
114        $is_site_connected = $connection->is_connected();
115        $broken_modules    = array(
116            'needs_site_connection' => array(),
117            'needs_user_connection' => array(),
118        );
119
120        if ( $is_user_connected && $is_site_connected ) {
121            return $broken_modules;
122        }
123
124        $products                    = Products::get_products_classes();
125        $historically_active_modules = Jetpack_Options::get_option( 'historically_active_modules', array() );
126
127        foreach ( $products as $product ) {
128            if ( ! in_array( $product::$slug, $historically_active_modules, true ) ) {
129                continue;
130            }
131
132            if ( $product::$requires_user_connection && ! $is_user_connected ) {
133                if ( ! in_array( $product::$slug, $broken_modules['needs_user_connection'], true ) ) {
134                    $broken_modules['needs_user_connection'][] = $product::$slug;
135                }
136            } elseif ( ! $is_site_connected ) {
137                if ( ! in_array( $product::$slug, $broken_modules['needs_site_connection'], true ) ) {
138                    $broken_modules['needs_site_connection'][] = $product::$slug;
139                }
140            }
141        }
142
143        return $broken_modules;
144    }
145
146    /**
147     * 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.
148     *
149     * @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.
150     *
151     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
152     * @return array
153     */
154    public static function alert_if_missing_connection( array $red_bubble_slugs ) {
155        $broken_modules = self::check_for_broken_modules();
156        $connection     = new Connection_Manager();
157
158        // Checking for site connection issues first.
159        if ( ! empty( $broken_modules['needs_site_connection'] ) ) {
160            $red_bubble_slugs[ self::MISSING_CONNECTION_NOTIFICATION_KEY ] = array(
161                'type'     => 'site',
162                'is_error' => true,
163            );
164            return $red_bubble_slugs;
165        }
166
167        // A recorded owner without a token is reported as a connection error instead. Only a user who
168        // can take the vacant owner slot (see Manager::authorize()) fixes a site without an owner.
169        if ( ! empty( $broken_modules['needs_user_connection'] )
170            && ! Jetpack_Options::get_option( 'master_user' )
171            && current_user_can( 'jetpack_connect' ) ) {
172            $red_bubble_slugs[ self::MISSING_CONNECTION_NOTIFICATION_KEY ] = array(
173                'type'     => 'user',
174                'is_error' => true,
175            );
176            return $red_bubble_slugs;
177        }
178
179        if ( ! $connection->is_connected() ) {
180            $red_bubble_slugs[ self::MISSING_CONNECTION_NOTIFICATION_KEY ] = array(
181                'type'     => 'site',
182                'is_error' => false,
183            );
184            return $red_bubble_slugs;
185        }
186
187        return $red_bubble_slugs;
188    }
189
190    /**
191     * Add an alert slug if Backups are failing or having an issue.
192     *
193     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
194     * @return array
195     */
196    public static function alert_if_last_backup_failed( array $red_bubble_slugs ) {
197        // Backup is not supported on multisite installations.
198        if ( is_multisite() ) {
199            return $red_bubble_slugs;
200        }
201        // Make sure the Notice wasn't previously dismissed.
202        if ( ! empty( $_COOKIE['backup_failure_dismissed'] ) ) {
203            return $red_bubble_slugs;
204        }
205        // Make sure there's a Backup paid plan
206        if ( ! Products\Backup::is_plugin_active() || ! Products\Backup::has_paid_plan_for_product() ) {
207            return $red_bubble_slugs;
208        }
209        // Make sure the plan isn't just recently purchased in last 30min.
210        // Give some time to queue & run the first backup.
211        $purchase = Products\Backup::get_paid_plan_purchase_for_product();
212        if ( $purchase ) {
213            $thirty_minutes_after_plan_purchase = strtotime( $purchase->subscribed_date . ' +30 minutes' );
214            if ( strtotime( 'now' ) < $thirty_minutes_after_plan_purchase ) {
215                return $red_bubble_slugs;
216            }
217        }
218
219        $backup_failed_status = Products\Backup::does_module_need_attention();
220        if ( $backup_failed_status ) {
221            $red_bubble_slugs['backup_failure'] = $backup_failed_status;
222        }
223
224        return $red_bubble_slugs;
225    }
226
227    /**
228     * Add an alert slug if Protect has scan threats/vulnerabilities.
229     *
230     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
231     * @return array
232     */
233    public static function alert_if_protect_has_threats( array $red_bubble_slugs ) {
234        // Scan is not supported on multisite installations.
235        if ( is_multisite() ) {
236            return $red_bubble_slugs;
237        }
238        if ( method_exists( Protect_Status::class, 'should_notify_threats' ) && ! Protect_Status::should_notify_threats() ) {
239            return $red_bubble_slugs;
240        }
241        // Make sure the Notice hasn't been dismissed.
242        if ( ! empty( $_COOKIE['protect_threats_detected_dismissed'] ) ) {
243            return $red_bubble_slugs;
244        }
245        // Make sure we're dealing with the Protect product only
246        if ( ! Products\Protect::has_paid_plan_for_product() ) {
247            return $red_bubble_slugs;
248        }
249
250        $protect_threats_status = Products\Protect::does_module_need_attention();
251
252        if ( $protect_threats_status ) {
253            $red_bubble_slugs['protect_has_threats'] = $protect_threats_status;
254        }
255
256        return $red_bubble_slugs;
257    }
258
259    /**
260     * Add an alert slug if any paid plan/products are expiring or expired.
261     *
262     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
263     * @return array
264     */
265    public static function alert_if_paid_plan_expiring( array $red_bubble_slugs ) {
266        $connection = new Connection_Manager();
267        if ( ! $connection->is_connected() ) {
268            return $red_bubble_slugs;
269        }
270        $product_classes = Products::get_products_classes();
271
272        $products_included_in_expiring_plan = array();
273        foreach ( $product_classes as $key => $product ) {
274            // Skip these- we don't show them in My Jetpack.
275            if ( in_array( $key, Products::get_not_shown_products(), true ) ) {
276                continue;
277            }
278
279            if ( $product::has_paid_plan_for_product() ) {
280                $purchase = $product::get_paid_plan_purchase_for_product();
281                if ( $purchase ) {
282                    // Check if this product is covered by an active bundle plan
283                    $is_covered_by_active_bundle = false;
284                    if ( ! $product::is_bundle_product() ) {
285                        foreach ( $product_classes as $bundle_product ) {
286                            if ( $bundle_product::is_bundle_product() &&
287                                $bundle_product::has_paid_plan_for_product() &&
288                                ! $bundle_product::is_paid_plan_expired() &&
289                                ! $bundle_product::is_paid_plan_expiring() &&
290                                method_exists( $bundle_product, 'get_supported_products' ) &&
291                                in_array( $key, $bundle_product::get_supported_products(), true ) ) {
292                                $is_covered_by_active_bundle = true;
293                                break;
294                            }
295                        }
296                    }
297
298                    // Only show expiration alerts if not covered by an active bundle
299                    if ( ! $is_covered_by_active_bundle ) {
300                        $redbubble_notice_data = array(
301                            'product_slug'   => $purchase->product_slug,
302                            'product_name'   => $purchase->product_name,
303                            'expiry_date'    => $purchase->expiry_date,
304                            'expiry_message' => $purchase->expiry_message,
305                            'manage_url'     => $product::get_manage_paid_plan_purchase_url(),
306                        );
307
308                        if ( $product::is_paid_plan_expired() && empty( $_COOKIE[ "$purchase->product_slug--plan_expired_dismissed" ] ) ) {
309                            $red_bubble_slugs[ "$purchase->product_slug--plan_expired" ] = $redbubble_notice_data;
310                            if ( ! $product::is_bundle_product() ) {
311                                $products_included_in_expiring_plan[ "$purchase->product_slug--plan_expired" ][] = $product::get_name();
312                            }
313                        }
314                        if ( $product::is_paid_plan_expiring() && empty( $_COOKIE[ "$purchase->product_slug--plan_expiring_soon_dismissed" ] ) ) {
315                            $red_bubble_slugs[ "$purchase->product_slug--plan_expiring_soon" ]               = $redbubble_notice_data;
316                            $red_bubble_slugs[ "$purchase->product_slug--plan_expiring_soon" ]['manage_url'] = $product::get_renew_paid_plan_purchase_url();
317                            if ( ! $product::is_bundle_product() ) {
318                                $products_included_in_expiring_plan[ "$purchase->product_slug--plan_expiring_soon" ][] = $product::get_name();
319                            }
320                        }
321                    }
322                }
323            }
324        }
325
326        foreach ( $products_included_in_expiring_plan as $expiring_plan => $products ) {
327            $red_bubble_slugs[ $expiring_plan ]['products_effected'] = $products;
328        }
329
330        return $red_bubble_slugs;
331    }
332
333    /**
334     * Add an alert slug if a site's paid plan requires a plugin install and/or activation.
335     *
336     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
337     * @return array
338     */
339    public static function alert_if_paid_plan_requires_plugin_install_or_activation( array $red_bubble_slugs ) {
340        $connection = new Connection_Manager();
341        // Don't trigger red bubble (and show notice) when the site is not connected or if the
342        // user doesn't have plugin installation/activation permissions.
343        if ( ! $connection->is_connected() || ! current_user_can( 'activate_plugins' ) ) {
344            return $red_bubble_slugs;
345        }
346
347        $plugins_needing_installed_activated = self::get_paid_plans_plugins_requirements();
348        if ( empty( $plugins_needing_installed_activated ) ) {
349            return $red_bubble_slugs;
350        }
351
352        foreach ( $plugins_needing_installed_activated as $plan_slug => $plugins_requirements ) {
353            if ( empty( $_COOKIE[ "$plan_slug--plugins_needing_installed_dismissed" ] ) ) {
354                $red_bubble_slugs[ "$plan_slug--plugins_needing_installed_activated" ] = $plugins_requirements;
355            }
356        }
357
358        return $red_bubble_slugs;
359    }
360
361    /**
362     *  Add relevant red bubble notifications
363     *
364     * @param array $red_bubble_slugs - slugs that describe the reasons the red bubble is showing.
365     * @return array
366     */
367    public static function add_red_bubble_alerts( array $red_bubble_slugs ) {
368        if ( wp_doing_ajax() ) {
369            return array();
370        }
371        $connection               = new Connection_Manager();
372        $welcome_banner_dismissed = Jetpack_Options::get_option( 'dismissed_welcome_banner', false );
373        if ( Initializer::is_jetpack_user_new() && ! $welcome_banner_dismissed ) {
374            $red_bubble_slugs['welcome-banner-active'] = array(
375                'is_silent' => $connection->is_connected(), // we don't display the red bubble if the user is connected
376            );
377            return $red_bubble_slugs;
378        } else {
379            return array_merge(
380                self::alert_if_missing_connection( $red_bubble_slugs ),
381                self::alert_if_last_backup_failed( $red_bubble_slugs ),
382                self::alert_if_paid_plan_expiring( $red_bubble_slugs ),
383                self::alert_if_protect_has_threats( $red_bubble_slugs ),
384                self::alert_if_paid_plan_requires_plugin_install_or_activation( $red_bubble_slugs )
385            );
386        }
387    }
388
389    /**
390     * Get cached red bubble alerts without triggering expensive computation.
391     * Returns the cached transient value or false if not cached.
392     *
393     * @return array|false Cached alerts or false if cache is empty.
394     */
395    public static function get_cached_alerts() {
396        return get_transient( self::MY_JETPACK_RED_BUBBLE_TRANSIENT_KEY );
397    }
398
399    /**
400     * Collect all possible alerts that we might use a red bubble notification for
401     *
402     * @param bool $bypass_cache - whether to bypass the red bubble cache.
403     * @return array
404     */
405    public static function get_red_bubble_alerts( bool $bypass_cache = false ) {
406        static $red_bubble_alerts = array();
407
408        // check for stored alerts
409        $stored_alerts = get_transient( self::MY_JETPACK_RED_BUBBLE_TRANSIENT_KEY );
410
411        // Cache bypass for red bubbles should only happen on the My Jetpack page
412        if ( $stored_alerts !== false && ! ( $bypass_cache ) ) {
413            return $stored_alerts;
414        }
415
416        // go find the alerts
417        $red_bubble_alerts = apply_filters( 'my_jetpack_red_bubble_notification_slugs', $red_bubble_alerts );
418
419        // cache the alerts for one hour
420        set_transient( self::MY_JETPACK_RED_BUBBLE_TRANSIENT_KEY, $red_bubble_alerts, 3600 );
421
422        return $red_bubble_alerts;
423    }
424
425    /**
426     * Get the red bubble alerts, bypassing cache when called via the REST API
427     *
428     * @param WP_REST_Request $request The REST API request object.
429     *
430     * @return WP_Error|WP_REST_Response
431     */
432    public static function rest_api_get_red_bubble_alerts( $request ) {
433        add_filter( 'my_jetpack_red_bubble_notification_slugs', array( __CLASS__, 'add_red_bubble_alerts' ) );
434
435        $cookies = $request->get_param( 'dismissal_cookies' );
436
437        // Update $_COOKIE superglobal with the provided cookies
438        if ( ! empty( $cookies ) && is_array( $cookies ) ) {
439            foreach ( $cookies as $cookie_string ) {
440                // Parse cookie string in format "name=value"
441                $parts = explode( '=', $cookie_string, 2 );
442                if ( count( $parts ) === 2 ) {
443                    $name             = trim( $parts[0] );
444                    $value            = trim( $parts[1] );
445                    $_COOKIE[ $name ] = $value;
446                }
447            }
448        }
449
450        $red_bubble_alerts = self::get_red_bubble_alerts( true );
451        return rest_ensure_response( $red_bubble_alerts );
452    }
453}