Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.36% covered (warning)
66.36%
142 / 214
76.92% covered (warning)
76.92%
20 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Backup
66.51% covered (warning)
66.51%
141 / 212
76.92% covered (warning)
76.92%
20 / 26
334.58
0.00% covered (danger)
0.00%
0 / 1
 register_endpoints
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 get_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_description
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_long_description
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_features
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_disclaimers
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get_wpcom_product_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_post_checkout_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_pricing_for_ui
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 permissions_callback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_site_backup_undo_event
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
 get_state_from_wpcom
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
3.05
 get_latest_backups
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
3.05
 does_module_need_attention
28.21% covered (danger)
28.21%
11 / 39
0.00% covered (danger)
0.00%
0 / 1
137.90
 is_upgradable_by_bundle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_post_activation_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_module_active
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 is_plan_driven
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_manage_url
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 offers_jetpack_dashboard
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 jetpack_can_host_dashboard
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 plan_opens_jetpack_dashboard
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 get_paid_plan_product_slugs
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 has_plan_on_record
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 get_status
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Backup product
4 *
5 * @package my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack\Products;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Current_Plan;
13use Automattic\Jetpack\My_Jetpack\Hybrid_Product;
14use Automattic\Jetpack\My_Jetpack\Wpcom_Products;
15use Automattic\Jetpack\Redirect;
16use WP_Error;
17
18if ( ! defined( 'ABSPATH' ) ) {
19    exit( 0 );
20}
21
22/**
23 * Class responsible for handling the Backup product
24 */
25class Backup extends Hybrid_Product {
26    public const BACKUP_STATUS_TRANSIENT_KEY = 'my-jetpack-backup-status';
27
28    public const PLAN_REFRESH_MISS_TRANSIENT_KEY = 'my-jetpack-backup-plan-refresh-miss';
29
30    /**
31     * The product slug
32     *
33     * @var string
34     */
35    public static $slug = 'backup';
36
37    /**
38     * The filename (id) of the plugin associated with this product.
39     *
40     * @var string
41     */
42    public static $plugin_filename = array(
43        'jetpack-backup/jetpack-backup.php',
44        'backup/jetpack-backup.php',
45        'jetpack-backup-dev/jetpack-backup.php',
46    );
47
48    /**
49     * The slug of the plugin associated with this product.
50     *
51     * @var string
52     */
53    public static $plugin_slug = 'jetpack-backup';
54
55    /**
56     * The Jetpack module name
57     *
58     * @var string
59     */
60    public static $module_name = 'backup';
61
62    /**
63     * The category of the product
64     *
65     * @var string
66     */
67    public static $category = 'security';
68
69    /**
70     * Backup has a standalone plugin
71     *
72     * @var bool
73     */
74    public static $has_standalone_plugin = true;
75
76    /**
77     * Whether this product has a free offering
78     *
79     * @var bool
80     */
81    public static $has_free_offering = false;
82
83    /**
84     * Whether this product requires a plan to work at all
85     *
86     * @var bool
87     */
88    public static $requires_plan = true;
89
90    /**
91     * The feature slug that identifies the paid plan
92     *
93     * @var string
94     */
95    public static $feature_identifying_paid_plan = 'backups';
96
97    /**
98     * Backup initialization
99     *
100     * @return void
101     */
102    public static function register_endpoints(): void {
103        parent::register_endpoints();
104        // Get backup undo event
105        register_rest_route(
106            'my-jetpack/v1',
107            '/site/backup/undo-event',
108            array(
109                'methods'             => \WP_REST_Server::READABLE,
110                'callback'            => __CLASS__ . '::get_site_backup_undo_event',
111                'permission_callback' => __CLASS__ . '::permissions_callback',
112            )
113        );
114    }
115
116    /**
117     * Get the product name
118     *
119     * @return string
120     */
121    public static function get_name() {
122        return 'VaultPress Backup';
123    }
124
125    /**
126     * Get the product title
127     *
128     * @return string
129     */
130    public static function get_title() {
131        return 'Jetpack VaultPress Backup';
132    }
133
134    /**
135     * Get the internationalized product description
136     *
137     * @return string
138     */
139    public static function get_description() {
140        return __( 'Real-time backups save every change, and one-click restores get you back online quickly.', 'jetpack-my-jetpack' );
141    }
142
143    /**
144     * Get the internationalized product long description
145     *
146     * @return string
147     */
148    public static function get_long_description() {
149        return __( 'Never lose a word, image, page, or time worrying about your site with automated backups & one-click restores.', 'jetpack-my-jetpack' );
150    }
151
152    /**
153     * Get the internationalized features list
154     *
155     * @return array Backup features list
156     */
157    public static function get_features() {
158        return array(
159            _x( 'Real-time cloud backups', 'Backup Product Feature', 'jetpack-my-jetpack' ),
160            _x( '10GB of backup storage', 'Backup Product Feature', 'jetpack-my-jetpack' ),
161            _x( '30-day archive & activity log*', 'Backup Product Feature', 'jetpack-my-jetpack' ),
162            _x( 'One-click restores', 'Backup Product Feature', 'jetpack-my-jetpack' ),
163        );
164    }
165
166    /**
167     * Get disclaimers corresponding to a feature
168     *
169     * @return array Backup disclaimers list
170     */
171    public static function get_disclaimers() {
172        return array(
173            array(
174                'text'      => _x( '* Subject to your usage and storage limit.', 'Backup Product Disclaimer', 'jetpack-my-jetpack' ),
175                'link_text' => _x( 'Learn more', 'Backup Product Disclaimer', 'jetpack-my-jetpack' ),
176                'url'       => Redirect::get_url( 'jetpack-faq-backup-disclaimer' ),
177            ),
178        );
179    }
180
181    /**
182     * Get the WPCOM product slug used to make the purchase
183     *
184     * @return ?string
185     */
186    public static function get_wpcom_product_slug() {
187        return 'jetpack_backup_t1_yearly';
188    }
189
190    /**
191     * Get the URL where the user should be redirected after checkout
192     */
193    public static function get_post_checkout_url() {
194        return self::get_manage_url();
195    }
196
197    /**
198     * Get the product princing details
199     *
200     * @return array Pricing details
201     */
202    public static function get_pricing_for_ui() {
203        return array_merge(
204            array(
205                'available'          => true,
206                'wpcom_product_slug' => static::get_wpcom_product_slug(),
207            ),
208            Wpcom_Products::get_product_pricing( static::get_wpcom_product_slug() )
209        );
210    }
211
212    /**
213     * Checks if the user has the correct permissions
214     */
215    public static function permissions_callback() {
216        return current_user_can( 'manage_options' );
217    }
218
219    /**
220     * This will fetch the last rewindable event from the Activity Log and
221     * the last rewind_id prior to that.
222     *
223     * @return array|WP_Error|null
224     */
225    public static function get_site_backup_undo_event() {
226        $blog_id = \Jetpack_Options::get_option( 'id' );
227
228        $response = Client::wpcom_json_api_request_as_user(
229            '/sites/' . $blog_id . '/activity/rewindable?force=wpcom',
230            'v2',
231            array(),
232            null,
233            'wpcom'
234        );
235
236        if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
237            return null;
238        }
239
240        $body = json_decode( $response['body'], true );
241
242        if ( ! isset( $body['current'] ) ) {
243            return null;
244        }
245
246        // Preparing the response structure
247        $undo_event = array(
248            'last_rewindable_event' => null,
249            'undo_backup_id'        => null,
250        );
251
252        // List of events that will not be considered to be undo.
253        // Basically we should not `undo` a full backup event, but we could
254        // use them to undo any other action like plugin updates.
255        $last_event_exceptions = array(
256            'rewind__backup_only_complete_full',
257            'rewind__backup_only_complete_initial',
258            'rewind__backup_only_complete',
259            'rewind__backup_complete_full',
260            'rewind__backup_complete_initial',
261            'rewind__backup_complete',
262        );
263
264        // Looping through the events to find the last rewindable event and the last backup_id.
265        // The idea is to find the last rewindable event and then the last rewind_id before that.
266        $found_last_event = false;
267        foreach ( $body['current']['orderedItems'] as $event ) {
268            if ( $event['is_rewindable'] ) {
269                if ( ! $found_last_event && ! in_array( $event['name'], $last_event_exceptions, true ) ) {
270                    $undo_event['last_rewindable_event'] = $event;
271                    $found_last_event                    = true;
272                } elseif ( $found_last_event ) {
273                    $undo_event['undo_backup_id'] = $event['rewind_id'];
274                    break;
275                }
276            }
277        }
278
279        return rest_ensure_response( $undo_event );
280    }
281
282    /**
283     * Hits the wpcom api to check rewind status.
284     *
285     * @todo Maybe add caching.
286     *
287     * @return object|WP_Error
288     */
289    private static function get_state_from_wpcom() {
290        static $status = null;
291
292        if ( $status !== null ) {
293            return $status;
294        }
295
296        $site_id = \Jetpack_Options::get_option( 'id' );
297
298        $response = Client::wpcom_json_api_request_as_blog(
299            sprintf( '/sites/%d/rewind', $site_id ) . '?force=wpcom',
300            '2',
301            array( 'timeout' => 2 ),
302            null,
303            'wpcom'
304        );
305
306        if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
307            $status = new WP_Error( 'rewind_state_fetch_failed' );
308            return $status;
309        }
310
311        $body   = wp_remote_retrieve_body( $response );
312        $status = json_decode( $body );
313        return $status;
314    }
315
316    /**
317     * Hits the wpcom api to retrieve the last 10 backup records.
318     *
319     * @return object|WP_Error
320     */
321    public static function get_latest_backups() {
322        static $backups = null;
323
324        if ( $backups !== null ) {
325            return $backups;
326        }
327
328        $site_id  = \Jetpack_Options::get_option( 'id' );
329        $response = Client::wpcom_json_api_request_as_blog(
330            sprintf( '/sites/%d/rewind/backups', $site_id ) . '?force=wpcom',
331            '2',
332            array( 'timeout' => 2 ),
333            null,
334            'wpcom'
335        );
336
337        if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
338            $backups = new WP_Error( 'rewind_backups_fetch_failed' );
339            return $backups;
340        }
341
342        $body    = wp_remote_retrieve_body( $response );
343        $backups = json_decode( $body );
344        return $backups;
345    }
346
347    /**
348     * Determines whether the module/plugin/product needs the users attention.
349     * Typically due to some sort of error where user troubleshooting is needed.
350     *
351     * @return boolean|array
352     */
353    public static function does_module_need_attention() {
354        $previous_backup_status = get_transient( self::BACKUP_STATUS_TRANSIENT_KEY );
355
356        // If we have a previous backup status, show it.
357        if ( ! empty( $previous_backup_status ) ) {
358            return $previous_backup_status === 'no_errors' ? false : $previous_backup_status;
359        }
360
361        $backup_failed_status = false;
362        // First check the status of Rewind for failure.
363        $rewind_state = self::get_state_from_wpcom();
364        if ( ! is_wp_error( $rewind_state ) ) {
365            // Special case: 'unavailable' with 'site_new' reason is a normal provisioning state for brand new sites.
366            $is_new_site_provisioning = ( 'unavailable' === $rewind_state->state &&
367                                        'site_new' === ( $rewind_state->reason ?? '' ) );
368
369            if (
370                ! in_array( $rewind_state->state, array( 'active', 'provisioning', 'awaiting_credentials' ), true ) &&
371                ! $is_new_site_provisioning
372            ) {
373                $backup_failed_status = array(
374                    'type' => 'error',
375                    'data' => array(
376                        'source'       => 'rewind',
377                        'status'       => isset( $rewind_state->reason ) && ! empty( $rewind_state->reason ) ? $rewind_state->reason : $rewind_state->state,
378                        'last_updated' => $rewind_state->last_updated,
379                    ),
380                );
381            }
382        }
383        // Next check for a failed last backup.
384        $latest_backups = self::get_latest_backups();
385        if ( ! is_wp_error( $latest_backups ) ) {
386            // Get the last/latest backup record.
387            $last_backup = null;
388            foreach ( $latest_backups as $backup ) {
389                if ( $backup->is_backup ) {
390                    $last_backup = $backup;
391                    break;
392                }
393            }
394
395            if ( $last_backup && isset( $last_backup->status ) ) {
396                if ( $last_backup->status !== 'started' && ! preg_match( '/-will-retry$/', $last_backup->status ) && $last_backup->status !== 'finished' ) {
397                    $backup_failed_status = array(
398                        'type' => 'error',
399                        'data' => array(
400                            'source'       => 'last_backup',
401                            'status'       => $last_backup->status,
402                            'last_updated' => $last_backup->last_updated,
403                        ),
404                    );
405                }
406            }
407        }
408
409        if ( is_array( $backup_failed_status ) ) {
410            set_transient( self::BACKUP_STATUS_TRANSIENT_KEY, $backup_failed_status, 5 * MINUTE_IN_SECONDS );
411        } else {
412            set_transient( self::BACKUP_STATUS_TRANSIENT_KEY, 'no_errors', HOUR_IN_SECONDS );
413        }
414
415        return $backup_failed_status;
416    }
417
418    /**
419     * Return product bundles list
420     * that supports the product.
421     *
422     * @return boolean|array Products bundle list.
423     */
424    public static function is_upgradable_by_bundle() {
425        return array( 'security', 'complete' );
426    }
427
428    /**
429     * Get the URL the user is taken after activating the product
430     *
431     * @return ?string
432     */
433    public static function get_post_activation_url() {
434        return ''; // stay in My Jetpack page or continue the purchase flow if needed.
435    }
436
437    /**
438     * Whether a plugin that can deliver Backup is on, which is all that matters where the plan decides Backup.
439     *
440     * @return bool
441     */
442    public static function is_module_active() {
443        if ( ! static::is_plan_driven() ) {
444            return parent::is_module_active();
445        }
446
447        return static::is_standalone_plugin_active() || static::is_jetpack_plugin_active();
448    }
449
450    /**
451     * Whether the site's plan, not the backup module, decides Backup here.
452     *
453     * An older Jetpack plugin still opens its dashboard only while the module is on.
454     *
455     * @since $$next-version$$
456     *
457     * @return bool
458     */
459    public static function is_plan_driven() {
460        return static::is_standalone_plugin_active()
461            || ! class_exists( 'Jetpack', false )
462            || defined( 'Jetpack::BACKUP_FOLLOWS_PLAN' );
463    }
464
465    /**
466     * Get the URL where the user manages the product
467     *
468     * @return ?string
469     */
470    public static function get_manage_url() {
471        // check standalone first
472        if ( static::is_standalone_plugin_active() ) {
473            return admin_url( 'admin.php?page=jetpack-backup' );
474            // otherwise, check for the main Jetpack plugin
475        } elseif ( static::is_jetpack_plugin_active() ) {
476            // The Jetpack plugin hosts the dashboard wherever it initialized the package, or will on
477            // the next request when this one learned of the plan after Jetpack checked for it.
478            if ( did_action( 'jetpack_backup_initialized' ) || ( static::is_plan_driven() && static::plan_opens_jetpack_dashboard() ) ) {
479                return admin_url( 'admin.php?page=jetpack-backup' );
480            }
481
482            return Redirect::get_url( 'my-jetpack-manage-backup' );
483        }
484    }
485
486    /**
487     * Whether the Jetpack plugin offers its bundled Backup dashboard, which Jetpack::configure_backup_package() asks.
488     *
489     * @since $$next-version$$
490     *
491     * @return bool
492     */
493    public static function offers_jetpack_dashboard() {
494        return static::jetpack_can_host_dashboard() && static::has_plan_on_record();
495    }
496
497    /**
498     * Whether the Jetpack plugin could host the dashboard here, whatever the plan.
499     *
500     * @return bool
501     */
502    private static function jetpack_can_host_dashboard() {
503        $connection = new Connection_Manager();
504
505        /** This filter is documented in projects/plugins/jetpack/class.jetpack.php */
506        $connected = apply_filters( 'jetpack_is_connection_ready', $connection->is_connected(), $connection );
507
508        // Not on multisite, which the Backup package does not support, or without a connected
509        // owner, since buying and managing backups needs a linked account.
510        if ( is_multisite() || ! $connected || ! $connection->has_connected_owner() ) {
511            return false;
512        }
513
514        /** This filter is documented in projects/plugins/jetpack/class.jetpack.php */
515        return (bool) apply_filters( 'jetpack_backup_dashboard_enabled', true );
516    }
517
518    /**
519     * Whether Manage opens the bundled dashboard, which is registered only once the stored plan includes Backup.
520     *
521     * @return bool
522     */
523    private static function plan_opens_jetpack_dashboard() {
524        if ( ! static::jetpack_can_host_dashboard() ) {
525            return false;
526        }
527
528        if ( static::has_plan_on_record() ) {
529            return true;
530        }
531
532        // Right after checkout WordPress.com knows of the plan before the stored copy does, so
533        // catch that up rather than link to a page the next request would not register.
534        if ( get_transient( self::PLAN_REFRESH_MISS_TRANSIENT_KEY ) || ! static::has_paid_plan_for_product() ) {
535            return false;
536        }
537
538        Wpcom_Products::get_site_current_plan( true );
539
540        if ( static::has_plan_on_record() ) {
541            return true;
542        }
543
544        // The two can keep disagreeing, so a miss holds off the next refresh.
545        set_transient( self::PLAN_REFRESH_MISS_TRANSIENT_KEY, true, 5 * MINUTE_IN_SECONDS );
546
547        return false;
548    }
549
550    /**
551     * Get the product-slugs of the paid plans for this product.
552     * (Do not include bundle plans, unless it's a bundle plan itself).
553     *
554     * @return array
555     */
556    public static function get_paid_plan_product_slugs() {
557        return array(
558            'jetpack_backup_daily',
559            'jetpack_backup_daily_monthly',
560            'jetpack_backup_realtime',
561            'jetpack_backup_realtime_monthly',
562            'jetpack_backup_t1_yearly',
563            'jetpack_backup_t1_monthly',
564            'jetpack_backup_t1_bi_yearly',
565            'jetpack_backup_t2_yearly',
566            'jetpack_backup_t2_monthly',
567            'jetpack_backup_t0_yearly',
568            'jetpack_backup_t0_monthly',
569        );
570    }
571
572    /**
573     * Whether the site's stored plan or products include Backup, current or expired, without asking WordPress.com.
574     *
575     * @since $$next-version$$
576     *
577     * @return bool
578     */
579    public static function has_plan_on_record() {
580        $feature = static::$feature_identifying_paid_plan;
581        $plan    = (array) get_option( Current_Plan::PLAN_OPTION, array() );
582
583        // The stored plan lists the features of every current purchase, standalone products included.
584        if ( in_array( $feature, (array) ( $plan['features']['active'] ?? array() ), true ) ) {
585            return true;
586        }
587
588        // An expired bundle stays the stored plan without listing its features, so match its family by slug.
589        $plan_slug = (string) ( $plan['product_slug'] ?? '' );
590        foreach ( array( 'jetpack_starter_', 'jetpack_security_', 'jetpack_complete', 'jetpack_personal', 'jetpack_premium', 'jetpack_business' ) as $prefix ) {
591            if ( str_starts_with( $plan_slug, $prefix ) ) {
592                return true;
593            }
594        }
595
596        foreach ( (array) Current_Plan::get_products() as $product ) {
597            $slug = (string) ( ( (array) $product )['product_slug'] ?? '' );
598
599            foreach ( static::get_paid_plan_product_slugs() as $paid_slug ) {
600                if ( false !== strpos( $slug, $paid_slug ) ) {
601                    return true;
602                }
603            }
604        }
605
606        return false;
607    }
608
609    /**
610     * Override the product status to return INACTIVE when backups are deactivated.
611     *
612     * @return string
613     */
614    public static function get_status() {
615        // Get the default status from parent.
616        $status = parent::get_status();
617
618        // Check if backups are deactivated (not an error, just manually turned off).
619        $needs_attention = static::does_module_need_attention();
620        if (
621            is_array( $needs_attention ) &&
622            isset( $needs_attention['data']['status'] ) &&
623            'backups-deactivated' === $needs_attention['data']['status']
624        ) {
625            // Preserve NEEDS_PLAN status - user must purchase before reactivating.
626            if ( \Automattic\Jetpack\My_Jetpack\Products::STATUS_NEEDS_PLAN === $status ) {
627                return $status;
628            }
629
630            return \Automattic\Jetpack\My_Jetpack\Products::STATUS_INACTIVE;
631        }
632
633        return $status;
634    }
635}