Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.14% covered (warning)
53.14%
93 / 175
50.00% covered (danger)
50.00%
10 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Backup
53.18% covered (warning)
53.18%
92 / 173
50.00% covered (danger)
50.00%
10 / 20
353.30
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
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_title
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 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
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_post_activation_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_manage_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_paid_plan_product_slugs
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 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\My_Jetpack\Hybrid_Product;
12use Automattic\Jetpack\My_Jetpack\Wpcom_Products;
13use Automattic\Jetpack\Redirect;
14use WP_Error;
15
16if ( ! defined( 'ABSPATH' ) ) {
17    exit( 0 );
18}
19
20/**
21 * Class responsible for handling the Backup product
22 */
23class Backup extends Hybrid_Product {
24    public const BACKUP_STATUS_TRANSIENT_KEY = 'my-jetpack-backup-status';
25
26    /**
27     * The product slug
28     *
29     * @var string
30     */
31    public static $slug = 'backup';
32
33    /**
34     * The filename (id) of the plugin associated with this product.
35     *
36     * @var string
37     */
38    public static $plugin_filename = array(
39        'jetpack-backup/jetpack-backup.php',
40        'backup/jetpack-backup.php',
41        'jetpack-backup-dev/jetpack-backup.php',
42    );
43
44    /**
45     * The slug of the plugin associated with this product.
46     *
47     * @var string
48     */
49    public static $plugin_slug = 'jetpack-backup';
50
51    /**
52     * The category of the product
53     *
54     * @var string
55     */
56    public static $category = 'security';
57
58    /**
59     * Backup has a standalone plugin
60     *
61     * @var bool
62     */
63    public static $has_standalone_plugin = true;
64
65    /**
66     * Whether this product has a free offering
67     *
68     * @var bool
69     */
70    public static $has_free_offering = false;
71
72    /**
73     * Whether this product requires a plan to work at all
74     *
75     * @var bool
76     */
77    public static $requires_plan = true;
78
79    /**
80     * The feature slug that identifies the paid plan
81     *
82     * @var string
83     */
84    public static $feature_identifying_paid_plan = 'backups';
85
86    /**
87     * Backup initialization
88     *
89     * @return void
90     */
91    public static function register_endpoints(): void {
92        parent::register_endpoints();
93        // Get backup undo event
94        register_rest_route(
95            'my-jetpack/v1',
96            '/site/backup/undo-event',
97            array(
98                'methods'             => \WP_REST_Server::READABLE,
99                'callback'            => __CLASS__ . '::get_site_backup_undo_event',
100                'permission_callback' => __CLASS__ . '::permissions_callback',
101            )
102        );
103    }
104
105    /**
106     * Get the product name
107     *
108     * @return string
109     */
110    public static function get_name() {
111        return 'VaultPress Backup';
112    }
113
114    /**
115     * Get the product title
116     *
117     * @return string
118     */
119    public static function get_title() {
120        return 'Jetpack VaultPress Backup';
121    }
122
123    /**
124     * Get the internationalized product description
125     *
126     * @return string
127     */
128    public static function get_description() {
129        return __( 'Real-time backups save every change, and one-click restores get you back online quickly.', 'jetpack-my-jetpack' );
130    }
131
132    /**
133     * Get the internationalized product long description
134     *
135     * @return string
136     */
137    public static function get_long_description() {
138        return __( 'Never lose a word, image, page, or time worrying about your site with automated backups & one-click restores.', 'jetpack-my-jetpack' );
139    }
140
141    /**
142     * Get the internationalized features list
143     *
144     * @return array Backup features list
145     */
146    public static function get_features() {
147        return array(
148            _x( 'Real-time cloud backups', 'Backup Product Feature', 'jetpack-my-jetpack' ),
149            _x( '10GB of backup storage', 'Backup Product Feature', 'jetpack-my-jetpack' ),
150            _x( '30-day archive & activity log*', 'Backup Product Feature', 'jetpack-my-jetpack' ),
151            _x( 'One-click restores', 'Backup Product Feature', 'jetpack-my-jetpack' ),
152        );
153    }
154
155    /**
156     * Get disclaimers corresponding to a feature
157     *
158     * @return array Backup disclaimers list
159     */
160    public static function get_disclaimers() {
161        return array(
162            array(
163                'text'      => _x( '* Subject to your usage and storage limit.', 'Backup Product Disclaimer', 'jetpack-my-jetpack' ),
164                'link_text' => _x( 'Learn more', 'Backup Product Disclaimer', 'jetpack-my-jetpack' ),
165                'url'       => Redirect::get_url( 'jetpack-faq-backup-disclaimer' ),
166            ),
167        );
168    }
169
170    /**
171     * Get the WPCOM product slug used to make the purchase
172     *
173     * @return ?string
174     */
175    public static function get_wpcom_product_slug() {
176        return 'jetpack_backup_t1_yearly';
177    }
178
179    /**
180     * Get the URL where the user should be redirected after checkout
181     */
182    public static function get_post_checkout_url() {
183        return self::get_manage_url();
184    }
185
186    /**
187     * Get the product princing details
188     *
189     * @return array Pricing details
190     */
191    public static function get_pricing_for_ui() {
192        return array_merge(
193            array(
194                'available'          => true,
195                'wpcom_product_slug' => static::get_wpcom_product_slug(),
196            ),
197            Wpcom_Products::get_product_pricing( static::get_wpcom_product_slug() )
198        );
199    }
200
201    /**
202     * Checks if the user has the correct permissions
203     */
204    public static function permissions_callback() {
205        return current_user_can( 'manage_options' );
206    }
207
208    /**
209     * This will fetch the last rewindable event from the Activity Log and
210     * the last rewind_id prior to that.
211     *
212     * @return array|WP_Error|null
213     */
214    public static function get_site_backup_undo_event() {
215        $blog_id = \Jetpack_Options::get_option( 'id' );
216
217        $response = Client::wpcom_json_api_request_as_user(
218            '/sites/' . $blog_id . '/activity/rewindable?force=wpcom',
219            'v2',
220            array(),
221            null,
222            'wpcom'
223        );
224
225        if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
226            return null;
227        }
228
229        $body = json_decode( $response['body'], true );
230
231        if ( ! isset( $body['current'] ) ) {
232            return null;
233        }
234
235        // Preparing the response structure
236        $undo_event = array(
237            'last_rewindable_event' => null,
238            'undo_backup_id'        => null,
239        );
240
241        // List of events that will not be considered to be undo.
242        // Basically we should not `undo` a full backup event, but we could
243        // use them to undo any other action like plugin updates.
244        $last_event_exceptions = array(
245            'rewind__backup_only_complete_full',
246            'rewind__backup_only_complete_initial',
247            'rewind__backup_only_complete',
248            'rewind__backup_complete_full',
249            'rewind__backup_complete_initial',
250            'rewind__backup_complete',
251        );
252
253        // Looping through the events to find the last rewindable event and the last backup_id.
254        // The idea is to find the last rewindable event and then the last rewind_id before that.
255        $found_last_event = false;
256        foreach ( $body['current']['orderedItems'] as $event ) {
257            if ( $event['is_rewindable'] ) {
258                if ( ! $found_last_event && ! in_array( $event['name'], $last_event_exceptions, true ) ) {
259                    $undo_event['last_rewindable_event'] = $event;
260                    $found_last_event                    = true;
261                } elseif ( $found_last_event ) {
262                    $undo_event['undo_backup_id'] = $event['rewind_id'];
263                    break;
264                }
265            }
266        }
267
268        return rest_ensure_response( $undo_event );
269    }
270
271    /**
272     * Hits the wpcom api to check rewind status.
273     *
274     * @todo Maybe add caching.
275     *
276     * @return object|WP_Error
277     */
278    private static function get_state_from_wpcom() {
279        static $status = null;
280
281        if ( $status !== null ) {
282            return $status;
283        }
284
285        $site_id = \Jetpack_Options::get_option( 'id' );
286
287        $response = Client::wpcom_json_api_request_as_blog(
288            sprintf( '/sites/%d/rewind', $site_id ) . '?force=wpcom',
289            '2',
290            array( 'timeout' => 2 ),
291            null,
292            'wpcom'
293        );
294
295        if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
296            $status = new WP_Error( 'rewind_state_fetch_failed' );
297            return $status;
298        }
299
300        $body   = wp_remote_retrieve_body( $response );
301        $status = json_decode( $body );
302        return $status;
303    }
304
305    /**
306     * Hits the wpcom api to retrieve the last 10 backup records.
307     *
308     * @return object|WP_Error
309     */
310    public static function get_latest_backups() {
311        static $backups = null;
312
313        if ( $backups !== null ) {
314            return $backups;
315        }
316
317        $site_id  = \Jetpack_Options::get_option( 'id' );
318        $response = Client::wpcom_json_api_request_as_blog(
319            sprintf( '/sites/%d/rewind/backups', $site_id ) . '?force=wpcom',
320            '2',
321            array( 'timeout' => 2 ),
322            null,
323            'wpcom'
324        );
325
326        if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
327            $backups = new WP_Error( 'rewind_backups_fetch_failed' );
328            return $backups;
329        }
330
331        $body    = wp_remote_retrieve_body( $response );
332        $backups = json_decode( $body );
333        return $backups;
334    }
335
336    /**
337     * Determines whether the module/plugin/product needs the users attention.
338     * Typically due to some sort of error where user troubleshooting is needed.
339     *
340     * @return boolean|array
341     */
342    public static function does_module_need_attention() {
343        $previous_backup_status = get_transient( self::BACKUP_STATUS_TRANSIENT_KEY );
344
345        // If we have a previous backup status, show it.
346        if ( ! empty( $previous_backup_status ) ) {
347            return $previous_backup_status === 'no_errors' ? false : $previous_backup_status;
348        }
349
350        $backup_failed_status = false;
351        // First check the status of Rewind for failure.
352        $rewind_state = self::get_state_from_wpcom();
353        if ( ! is_wp_error( $rewind_state ) ) {
354            // Special case: 'unavailable' with 'site_new' reason is a normal provisioning state for brand new sites.
355            $is_new_site_provisioning = ( 'unavailable' === $rewind_state->state &&
356                                        'site_new' === ( $rewind_state->reason ?? '' ) );
357
358            if (
359                ! in_array( $rewind_state->state, array( 'active', 'provisioning', 'awaiting_credentials' ), true ) &&
360                ! $is_new_site_provisioning
361            ) {
362                $backup_failed_status = array(
363                    'type' => 'error',
364                    'data' => array(
365                        'source'       => 'rewind',
366                        'status'       => isset( $rewind_state->reason ) && ! empty( $rewind_state->reason ) ? $rewind_state->reason : $rewind_state->state,
367                        'last_updated' => $rewind_state->last_updated,
368                    ),
369                );
370            }
371        }
372        // Next check for a failed last backup.
373        $latest_backups = self::get_latest_backups();
374        if ( ! is_wp_error( $latest_backups ) ) {
375            // Get the last/latest backup record.
376            $last_backup = null;
377            foreach ( $latest_backups as $backup ) {
378                if ( $backup->is_backup ) {
379                    $last_backup = $backup;
380                    break;
381                }
382            }
383
384            if ( $last_backup && isset( $last_backup->status ) ) {
385                if ( $last_backup->status !== 'started' && ! preg_match( '/-will-retry$/', $last_backup->status ) && $last_backup->status !== 'finished' ) {
386                    $backup_failed_status = array(
387                        'type' => 'error',
388                        'data' => array(
389                            'source'       => 'last_backup',
390                            'status'       => $last_backup->status,
391                            'last_updated' => $last_backup->last_updated,
392                        ),
393                    );
394                }
395            }
396        }
397
398        if ( is_array( $backup_failed_status ) ) {
399            set_transient( self::BACKUP_STATUS_TRANSIENT_KEY, $backup_failed_status, 5 * MINUTE_IN_SECONDS );
400        } else {
401            set_transient( self::BACKUP_STATUS_TRANSIENT_KEY, 'no_errors', HOUR_IN_SECONDS );
402        }
403
404        return $backup_failed_status;
405    }
406
407    /**
408     * Return product bundles list
409     * that supports the product.
410     *
411     * @return boolean|array Products bundle list.
412     */
413    public static function is_upgradable_by_bundle() {
414        return array( 'security', 'complete' );
415    }
416
417    /**
418     * Get the URL the user is taken after activating the product
419     *
420     * @return ?string
421     */
422    public static function get_post_activation_url() {
423        return ''; // stay in My Jetpack page or continue the purchase flow if needed.
424    }
425
426    /**
427     * Get the URL where the user manages the product
428     *
429     * @return ?string
430     */
431    public static function get_manage_url() {
432        // check standalone first
433        if ( static::is_standalone_plugin_active() ) {
434            return admin_url( 'admin.php?page=jetpack-backup' );
435            // otherwise, check for the main Jetpack plugin
436        } elseif ( static::is_jetpack_plugin_active() ) {
437            return Redirect::get_url( 'my-jetpack-manage-backup' );
438        }
439    }
440
441    /**
442     * Get the product-slugs of the paid plans for this product.
443     * (Do not include bundle plans, unless it's a bundle plan itself).
444     *
445     * @return array
446     */
447    public static function get_paid_plan_product_slugs() {
448        return array(
449            'jetpack_backup_daily',
450            'jetpack_backup_daily_monthly',
451            'jetpack_backup_realtime',
452            'jetpack_backup_realtime_monthly',
453            'jetpack_backup_t1_yearly',
454            'jetpack_backup_t1_monthly',
455            'jetpack_backup_t1_bi_yearly',
456            'jetpack_backup_t2_yearly',
457            'jetpack_backup_t2_monthly',
458            'jetpack_backup_t0_yearly',
459            'jetpack_backup_t0_monthly',
460        );
461    }
462
463    /**
464     * Override the product status to return INACTIVE when backups are deactivated.
465     *
466     * @return string
467     */
468    public static function get_status() {
469        // Get the default status from parent.
470        $status = parent::get_status();
471
472        // Check if backups are deactivated (not an error, just manually turned off).
473        $needs_attention = static::does_module_need_attention();
474        if (
475            is_array( $needs_attention ) &&
476            isset( $needs_attention['data']['status'] ) &&
477            'backups-deactivated' === $needs_attention['data']['status']
478        ) {
479            // Preserve NEEDS_PLAN status - user must purchase before reactivating.
480            if ( \Automattic\Jetpack\My_Jetpack\Products::STATUS_NEEDS_PLAN === $status ) {
481                return $status;
482            }
483
484            return \Automattic\Jetpack\My_Jetpack\Products::STATUS_INACTIVE;
485        }
486
487        return $status;
488    }
489}