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