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