Backup" renders the new * wp-build dashboard instead of the legacy React app. */ const MODERNIZATION_FILTER = 'rsm_jetpack_ui_modernization_backup'; /** * Rewind state read from WordPress.com, memoized for the request. * * A class property and not a function static so tests can clear it. * * @var object|null */ private static $rewind_state = null; /** * Constructor. */ public static function initialize() { if ( did_action( 'jetpack_backup_initialized' ) ) { return; } // Set up the REST authentication hooks. Connection_Rest_Authentication::init(); add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) ); add_action( 'rest_api_init', array( \Automattic\Jetpack\Backup\V0005\REST\Rest_Controller::class, 'register_routes' ) ); add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 1 ); // Akismet uses 4, so we need to use 1 to ensure both menus are added when only they exist. // Init Jetpack packages. add_action( 'plugins_loaded', function () { $config = new Config(); // Connection package. $config->ensure( 'connection', array( 'slug' => self::JETPACK_BACKUP_SLUG, 'name' => self::JETPACK_BACKUP_NAME, 'url_info' => self::JETPACK_BACKUP_URI, ) ); // Sync package. $config->ensure( 'sync' ); // Identity crisis package. $config->ensure( 'identity_crisis' ); }, 1 ); add_action( 'plugins_loaded', array( __CLASS__, 'maybe_upgrade_db' ), 20 ); add_filter( 'jetpack_connection_user_has_license', array( __CLASS__, 'jetpack_check_user_licenses' ), 10, 3 ); // Jetpack Backup abilities are registered from `actions.php` at package // autoload time so the surface is available in every consumer that // loads this package (both the standalone Backup plugin and the // Jetpack plugin), not only when `Jetpack_Backup::initialize()` runs. /** * Runs right after the Jetpack Backup package is initialized. * * @since 1.3.0 */ do_action( 'jetpack_backup_initialized' ); } /** * The page to be added to submenu */ public static function add_wp_admin_submenu() { $wp_build_active = self::is_wp_build_dashboard_active(); $callback = $wp_build_active ? 'jetpack_backup_jetpack_backup_dashboard_wp_admin_render_page' : array( __CLASS__, 'plugin_settings_page' ); // The relabel rides the modernized dashboard rather than the filter alone, // so a fallback to the legacy page also falls back to the legacy title. $page_title = $wp_build_active ? 'Jetpack VaultPress Backup' : 'Jetpack Backup'; $menu_title = $wp_build_active ? 'VaultPress Backup' : 'Backup'; // Product name, do not translate. $page_suffix = Admin_Menu::add_menu( $page_title, $menu_title, 'manage_options', self::JETPACK_BACKUP_SLUG, $callback ); if ( $page_suffix ) { add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) ); } } /** * Initialize the admin resources. */ public static function admin_init() { add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_scripts' ) ); if ( self::is_wp_build_dashboard_active() ) { // The modernized Backup overview is a focused, full-screen product // surface. Suppress JITMs and other core/plugin admin notices so they // don't reflow on top of the dual-pane layout. Mirrors how Jetpack // Forms handles its dashboard page // (`plugins/forms/src/dashboard/class-dashboard.php`). remove_all_actions( 'admin_notices' ); remove_all_actions( 'all_admin_notices' ); } } /** * Checks current version against version in code and run upgrades if we are running a new version */ public static function maybe_upgrade_db() { $current_db_version = get_option( 'jetpack_backup_db_version' ); if ( version_compare( $current_db_version, self::JETPACK_BACKUP_DB_VERSION, '<' ) ) { update_option( 'jetpack_backup_db_version', self::JETPACK_BACKUP_DB_VERSION ); Jetpack_Backup_Upgrades::upgrade(); } } /** * Returns whether we are in condition to track to use * Analytics functionality like Tracks, MC, or GA. */ public static function can_use_analytics() { $status = new Status(); $connection = new Connection_Manager( 'jetpack-backup' ); $tracking = new Tracking( 'jetpack', $connection ); return $tracking->should_enable_tracking( new Terms_Of_Service(), $status ); } /** * Enqueue plugin admin scripts and styles. */ public static function enqueue_admin_scripts() { // This callback is registered via `load-{$page_suffix}` in `add_wp_admin_submenu()`, // so it only fires on the Backup admin page — no need to re-check the page here. if ( self::is_wp_build_dashboard_active() ) { // The i18n loader is registered on every admin page by jetpack-assets but // only enqueued when depended on; the esbuild bundles don't pull it in. // Enqueue it here, before the early return, so the wp-build dashboard's // init module can download its JS translation catalogs. if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) { wp_enqueue_script( 'wp-jp-i18n-loader' ); } // The esbuild bundles don't declare the Tracks client as a dependency // either, so it never reaches the page on its own. if ( self::can_use_analytics() ) { Tracking::register_tracks_functions_scripts( true ); } // wp-build manages its own enqueue pipeline. The legacy script and // its initial state are skipped for the wp-build dashboard. return; } Assets::register_script( 'jetpack-backup', '../build/index.js', __FILE__, array( 'in_footer' => true, 'textdomain' => 'jetpack-backup-pkg', ) ); Assets::enqueue_script( 'jetpack-backup' ); // Initial JS state including JP Connection data. wp_add_inline_script( 'jetpack-backup', self::get_initial_state(), 'before' ); Connection_Initial_State::render_script( 'jetpack-backup' ); // Load script for analytics. if ( self::can_use_analytics() ) { Tracking::register_tracks_functions_scripts( true ); } } /** * Main plugin settings page. */ public static function plugin_settings_page() { ?>
render(); } /** * Register REST API */ public static function register_rest_routes() { // Get information on most recent 10 backups. register_rest_route( 'jetpack/v4', '/backups', array( 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::get_recent_backups', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', ) ); // Get site backup/scan/anti-spam capabilities. register_rest_route( 'jetpack/v4', '/backup-capabilities', array( 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::get_backup_capabilities', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', ) ); // Get whether the site has a backup plan register_rest_route( 'jetpack/v4', '/has-backup-plan', array( 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::get_backup_plan_state', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', ) ); // Get site rewind data. register_rest_route( 'jetpack/v4', '/restores', array( 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::get_recent_restores', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', ) ); // Get information on site products. // Backup plugin version of /site/purchases from JP plugin. // Revert once this route and MyPlan component are extracted to a common package. register_rest_route( 'jetpack/v4', '/site/current-purchases', array( 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::get_site_current_purchases', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', ) ); // Get currently promoted product from the product's endpoint. register_rest_route( 'jetpack/v4', '/backup-promoted-product-info', array( 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::get_backup_promoted_product_info', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', ) ); // Get and set value of dismissed_backup_review_request option register_rest_route( 'jetpack/v4', '/site/dismissed-review-request', array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => __CLASS__ . '::manage_dismissed_backup_review_request', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', 'args' => array( 'option_name' => array( 'required' => true, 'type' => 'string', // The two names `Jetpack_Options` recognises. Anything else // falls through its allowlist to a `trigger_error()` and is // stored nowhere, so an unlisted reason would dismiss // nothing while answering as though it had — and on a site // with `display_errors` on, the warning is printed ahead of // the JSON and the response no longer parses. 'enum' => array( 'restore', 'backups' ), ), 'should_dismiss' => array( 'required' => true, 'type' => 'boolean', ), ), ) ); // Get site size register_rest_route( 'jetpack/v4', '/site/backup/size', array( 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::get_site_backup_size', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', ) ); // Get backup schedule time register_rest_route( 'jetpack/v4', '/site/backup/schedule', array( 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::get_site_backup_schedule_time', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', ) ); // Get site policies register_rest_route( 'jetpack/v4', '/site/backup/policies', array( 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::get_site_backup_policies', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', ) ); // Get site add-on offer register_rest_route( 'jetpack/v4', '/site/backup/addon-offer', array( 'methods' => WP_REST_Server::READABLE, 'callback' => __CLASS__ . '::get_site_backup_addon_offer', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', 'args' => array( 'storage_size' => array( 'required' => true, 'type' => 'numeric', ), 'storage_limit' => array( 'required' => true, 'type' => 'numeric', ), ), ) ); // Enqueue a new backup register_rest_route( 'jetpack/v4', '/site/backup/enqueue', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => __CLASS__ . '::enqueue_backup', 'permission_callback' => __CLASS__ . '::backups_permissions_callback', ) ); } /** * The backup calls should only occur from a signed in admin user * * @access public * @static * * @return true|WP_Error */ public static function backups_permissions_callback() { return current_user_can( 'manage_options' ); } /** * The error a route answers with when its WordPress.com request did not come * back with a 200. * * Returning `null` instead — which these routes used to do — is served as an * HTTP 200 carrying a `null` body, so `apiFetch` resolves and nothing throws. * A WordPress.com blip then reaches the dashboard as an empty success, which * is how a paying customer ends up looking at the first-run screen. A * WP_Error makes the REST layer answer with a status, so every caller's * existing failure path runs. * * @param int $status The upstream response code, already cast to an int, or 0 * when the request never reached WordPress.com. * @return WP_Error */ private static function get_failed_fetch_error( $status = 0 ) { return new WP_Error( 'failed_to_fetch_data', esc_html__( 'Unable to fetch the requested data.', 'jetpack-backup-pkg' ), array( // A transport failure has no status at all, and `status_header( 0 )` // emits an invalid status line — so anything falsy becomes a 500. 'status' => $status ? $status : 500, ) ); } /** * Get information about recent backups * * @access public * @static * * @return \WP_REST_Response|WP_Error The recent backups, or a WP_Error if WordPress.com could not be reached. */ public static function get_recent_backups() { $blog_id = Jetpack_Options::get_option( 'id' ); $response = Client::wpcom_json_api_request_as_blog( '/sites/' . $blog_id . '/rewind/backups', 'v2', array(), null, 'wpcom' ); $response_code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $response_code ) { return self::get_failed_fetch_error( $response_code ); } return rest_ensure_response( json_decode( $response['body'], true ) ); } /** * Hits the wpcom api to check rewind status. * * Bounded well clear of a healthy round trip; `Client`'s 10s default is too * long for the synchronous `authorize_redirect` this sits on. * * @return object|WP_Error The decoded rewind state, or a WP_Error if WordPress.com could not be read. */ private static function get_rewind_state_from_wpcom() { if ( self::$rewind_state !== null ) { return self::$rewind_state; } $site_id = Jetpack_Options::get_option( 'id' ); $response = Client::wpcom_json_api_request_as_blog( sprintf( '/sites/%d/rewind', $site_id ) . '?force=wpcom', '2', array( 'timeout' => 5 ), null, 'wpcom' ); // Cast: `wp_remote_retrieve_response_code()` hands back whatever the // transport put there, and a numeric-string `'200'` fails this strict // comparison. $response_code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $response_code ) { return self::get_failed_fetch_error( $response_code ); } $state = json_decode( wp_remote_retrieve_body( $response ) ); // A 200 with no `state` is a read that failed, not a site without a // plan. Caching it would hold that answer for the rest of the request; // refusing lets a later caller ask again and get a real one. if ( ! is_object( $state ) || ! isset( $state->state ) ) { return new WP_Error( 'rewind_state_unreadable', esc_html__( 'Unable to read the backup plan details for this site.', 'jetpack-backup-pkg' ), array( 'status' => 500 ) ); } self::$rewind_state = $state; return self::$rewind_state; } /** * Checks whether the site supports the product, reporting an unreadable answer as an error. * * @since 5.0.1 * * @return bool|WP_Error True when the site has Backup, or a WP_Error if WordPress.com could not be read. */ public static function get_backup_plan_state() { $rewind_data = static::get_rewind_state_from_wpcom(); if ( is_wp_error( $rewind_data ) ) { return $rewind_data; } return 'unavailable' !== $rewind_data->state; } /** * Checks whether the current plan (or purchases) of the site already supports the product * * Answers a plan it could not read as absent, which is the one place that * decision is made for every `bool` caller. * * @return bool */ public static function has_backup_plan() { $state = static::get_backup_plan_state(); return ! is_wp_error( $state ) && $state; } /** * Get an array of backup/scan/anti-spam site capabilities * * @access public * @static * * @return \WP_REST_Response|WP_Error The site capabilities, or a WP_Error if WordPress.com could not be reached. */ public static function get_backup_capabilities() { $blog_id = Jetpack_Options::get_option( 'id' ); $response = Client::wpcom_json_api_request_as_user( '/sites/' . $blog_id . '/rewind/capabilities', 'v2', array(), null, 'wpcom' ); $response_code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $response_code ) { return self::get_failed_fetch_error( $response_code ); } return rest_ensure_response( json_decode( $response['body'], true ) ); } /** * Get information about recent restores * * @access public * @static * * @return \WP_REST_Response|WP_Error The recent restores, or a WP_Error if WordPress.com could not be reached. */ public static function get_recent_restores() { $blog_id = Jetpack_Options::get_option( 'id' ); $response = Client::wpcom_json_api_request_as_blog( '/sites/' . $blog_id . '/rewind/restores', 'v2', array(), null, 'wpcom' ); $response_code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $response_code ) { return self::get_failed_fetch_error( $response_code ); } return rest_ensure_response( json_decode( $response['body'], true ) ); } /** * Query backup-completion events from the wpcom activity-log via the * general `/sites//activity` endpoint with the action filter pinned * to backup-completion event names. This endpoint paginates real-ly * (Elasticsearch `from` offset under the hood) — the `/activity/rewindable` * sibling looks like a more natural fit but hardcodes `page: 1, * totalPages: 1` and ignores the `page` parameter. * * Auth: signs as user. The endpoint gates on the requesting WP user * being an administrator of the blog (see * sites-activity.php::readable_permission_check); blog-level tokens * return 401. * * Returned shape (success): a W3C ActivityStreams envelope: * { * "@context": ..., "type": "OrderedCollection", "totalItems": int, * "page": int, "totalPages": int, "itemsPerPage": int, * "orderedItems": [ , ... ] * } * Each event has at least `published`, `rewind_id`, `is_rewindable`, * `name`, `status`, `summary`. * * @param array $args Query args passed through to wpcom. Supported keys: * `after` (ISO 8601), `before` (ISO 8601), `on` (ISO 8601), * `date_range`, `number` (max 1000), `page` (1-based), * `sort_order` ('asc'|'desc'). Any `action` key is * overridden with the curated backup-completion list. * @return array|\WP_REST_Response|null */ public static function list_backup_events( array $args = array() ) { $blog_id = Jetpack_Options::get_option( 'id' ); // Curated set of activity actions that represent "a backup completed". // Mirrors `WPCOM_REST_API_V2_Endpoint_Site_Activity::$backup_action_names`. // Pinned here (and overriding any caller-supplied `action`) so the // helper is always scoped to backups regardless of what the caller passes. $args['action'] = array( 'backup_complete_full', 'backup_complete_initial', 'backup_only_complete_full', 'backup_only_complete_initial', 'rewind__backup_complete_full', 'rewind__backup_complete_initial', 'rewind__backup_only_complete_full', 'rewind__backup_only_complete_initial', ); $path = '/sites/' . (int) $blog_id . '/activity?' . http_build_query( $args ); $response = Client::wpcom_json_api_request_as_user( $path, 'v2', array(), null, 'wpcom' ); // Cast, as everywhere else this package reads a status. Uncast, a // numeric-string `'200'` discards a good activity page and the // `jetpack-backup/list-backup-events` ability reports that the site // has completed no backups — `unwrap_response()` flattens this // `null` into an empty list, which is a claim rather than an error. if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return null; } return rest_ensure_response( json_decode( $response['body'], true ) ); } /** * Gets information about the currently promoted backup product. * * Answers from a per-locale transient when one is warm; failures are not cached. * * @return object|WP_Error The promoted product, or a WP_Error if it could not be read. */ public static function get_backup_promoted_product_info() { $locale = get_user_locale(); $transient_key = self::PROMOTED_PRODUCT_TRANSIENT_PREFIX . sanitize_key( $locale ); $cached = get_transient( $transient_key ); if ( false !== $cached ) { return $cached; } $request_url = 'https://public-api.wordpress.com/rest/v1.1/products?locale=' . $locale . '&type=jetpack'; $wpcom_request = wp_remote_get( esc_url_raw( $request_url ) ); // Cast: the transport may report the status as a numeric string, which // a strict comparison against 200 sends down the failure path. $response_code = (int) wp_remote_retrieve_response_code( $wpcom_request ); if ( 200 !== $response_code ) { return new WP_Error( 'failed_to_fetch_data', esc_html__( 'Unable to fetch the requested data.', 'jetpack-backup-pkg' ), array( // A transport failure has no status at all; reporting 0 would // leave the REST layer with nothing to serve. 'status' => $response_code ? $response_code : 500, 'request' => $wpcom_request, ) ); } $products = json_decode( wp_remote_retrieve_body( $wpcom_request ) ); // A 200 is not a promise that the promoted product is in the body. A // truncated response decodes to null, and the slug is a constant here // but a catalogue entry upstream — retiring it there leaves this a 200 // with the key absent. Reading through either emits a PHP warning and // yields null, which the route then serves as a 200 carrying `null`: // indistinguishable, to a caller, from a priced answer it failed to // read. Refusing says which of the two happened. if ( ! is_object( $products ) || ! isset( $products->{ self::JETPACK_BACKUP_PROMOTED_PRODUCT } ) ) { return new WP_Error( 'promoted_product_unreadable', esc_html__( 'Unable to read the promoted product information.', 'jetpack-backup-pkg' ), array( 'status' => 500 ) ); } $product = $products->{ self::JETPACK_BACKUP_PROMOTED_PRODUCT }; // Must stay below both guards: a cached failure would leave the no-plan // screen without a price for the whole TTL after WordPress.com recovered. set_transient( $transient_key, $product, self::PROMOTED_PRODUCT_CACHE_TTL ); return $product; } /** * Check for user licenses. * * @param boolean $has_license If the user already has a license found. * @param array $licenses List of unattached licenses belonging to the user. * @param string $plugin_slug The plugin that initiated the flow. * * @return boolean */ public static function jetpack_check_user_licenses( $has_license, $licenses, $plugin_slug ) { if ( $plugin_slug !== static::JETPACK_BACKUP_SLUG || $has_license ) { return $has_license; } $license_found = false; foreach ( $licenses as $license ) { if ( in_array( $license->product_id, static::JETPACK_BACKUP_PRODUCT_IDS, true ) ) { $license_found = true; break; } } // Checking for existing backup plan is costly, so only check if there's an appropriate license. return $license_found && ! static::has_backup_plan(); } /** * Returns the result of `/upgrades` endpoint call. * * @return \WP_REST_Response|WP_Error The site purchases, or a WP_Error if WordPress.com could not be reached. */ public static function get_site_current_purchases() { $request = sprintf( '/upgrades?site=%d', Jetpack_Options::get_option( 'id' ) ); $response = Client::wpcom_json_api_request_as_blog( $request, '1.2' ); // Bail if there was an error or malformed response. if ( is_wp_error( $response ) || ! is_array( $response ) || ! isset( $response['body'] ) ) { return self::get_failed_fetch_error(); } $response_code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $response_code ) { return self::get_failed_fetch_error( $response_code ); } return rest_ensure_response( json_decode( $response['body'], true ) ); } /** * Set value of the dismissed_backup_review_request Jetack option. * Get value if should_dismiss is false * * @access public * @static * @param array $request arguments should_dismiss and option_name. * @return bool value of option if value is requested | updated or not if value is updated. */ public static function manage_dismissed_backup_review_request( $request ) { if ( ! $request['should_dismiss'] ) { return rest_ensure_response( Jetpack_Options::get_option( 'dismissed_backup_review_' . $request['option_name'] ) ); } return Jetpack_Options::update_option( 'dismissed_backup_review_' . $request['option_name'], true ); } /** * Get site storage size * * @return \WP_REST_Response|WP_Error The site storage size, or a WP_Error if WordPress.com could not be reached. */ public static function get_site_backup_size() { $blog_id = Jetpack_Options::get_option( 'id' ); $response = Client::wpcom_json_api_request_as_user( '/sites/' . $blog_id . '/rewind/size?force=wpcom', 'v2', array(), null, 'wpcom' ); $response_code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $response_code ) { return self::get_failed_fetch_error( $response_code ); } return rest_ensure_response( json_decode( $response['body'], true ) ); } /** * Get site policies from WPCOM. It includes the storage limit and activity log limit, if apply. * * @return \WP_REST_Response|WP_Error The site storage policies, or a WP_Error if WordPress.com could not be reached. */ public static function get_site_backup_policies() { $blog_id = Jetpack_Options::get_option( 'id' ); $response = Client::wpcom_json_api_request_as_user( '/sites/' . $blog_id . '/rewind/policies?force=wpcom', 'v2', array(), null, 'wpcom' ); $response_code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $response_code ) { return self::get_failed_fetch_error( $response_code ); } return rest_ensure_response( json_decode( $response['body'], true ) ); } /** * Get suggested storage addon based on storage usage * * @param int $bytes_used Storage used. * @param int $bytes_available Storage limit. * @return string Suggested addon storage slug */ public static function get_storage_addon_upsell_slug( $bytes_used, $bytes_available ) { $bytes_10gb = 10 * 1024 * 1024 * 1024; // 10GB in bytes $bytes_100gb = 100 * 1024 * 1024 * 1024; // 100GB in bytes $bytes_1tb = 1024 * 1024 * 1024 * 1024; // 1TB in bytes $upsell_products = array( $bytes_10gb => 'jetpack_backup_addon_storage_10gb_monthly', $bytes_100gb => 'jetpack_backup_addon_storage_100gb_monthly', $bytes_1tb => 'jetpack_backup_addon_storage_1tb_monthly', ); // If usage has crossed over the storage limit, then dynamically calculate the upgrade option if ( $bytes_used > $bytes_available ) { $additional_bytes_used = $bytes_used - $bytes_available; // Add aditional 25% buffer $additional_bytes_needed = $additional_bytes_used + $additional_bytes_used * 0.25; // Since 1TB is our max upgrade but the additional storage needed is greater than 1TB, then just return 1TB if ( $additional_bytes_needed > $bytes_1tb ) { return $upsell_products[ $bytes_1tb ]; } $matched_bytes = $bytes_10gb; foreach ( $upsell_products as $bytes => $product ) { if ( $bytes > $additional_bytes_needed ) { $matched_bytes = $bytes; break; } } return $upsell_products[ $matched_bytes ]; } // For 1 TB we are going to offer 1 TB by default if ( $bytes_1tb === $bytes_available ) { return $upsell_products[ $bytes_1tb ]; } // Otherwise, we are going to offer 10 GB return $upsell_products[ $bytes_10gb ]; } /** * Get the best addon offer for this site, including pricing details * * @param \WP_REST_Request $request Object including storage usage. * * @return string|WP_Error A JSON object with the suggested storage addon details if the request was successful, * or a WP_Error otherwise. */ public static function get_site_backup_addon_offer( $request ) { $suggested_addon = self::get_storage_addon_upsell_slug( $request['storage_size'], $request['storage_limit'] ); $addons_size_text_map = array( 'jetpack_backup_addon_storage_10gb_monthly' => '10GB', 'jetpack_backup_addon_storage_100gb_monthly' => '100GB', 'jetpack_backup_addon_storage_1tb_monthly' => '1TB', ); // Fetch addon storage price information $pricing_info = Wpcom_Products::get_product_pricing( $suggested_addon ); // Response $response = array( 'slug' => $suggested_addon, 'size_text' => $addons_size_text_map[ $suggested_addon ], 'pricing' => $pricing_info, ); return rest_ensure_response( $response ); } /** * Enqueue a new backup on demand * * @return \WP_REST_Response|WP_Error The enqueue result, or a WP_Error if WordPress.com could not be reached. */ public static function enqueue_backup() { $blog_id = Jetpack_Options::get_option( 'id' ); $endpoint = sprintf( '/sites/%d/rewind/backups/enqueue', $blog_id ); $response = Client::wpcom_json_api_request_as_user( $endpoint, 'v2', array( 'method' => 'POST', ), null, 'wpcom' ); $response_code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $response_code ) { return self::get_failed_fetch_error( $response_code ); } return rest_ensure_response( json_decode( $response['body'], true ) ); } /** * Get site backup schedule time * * @return \WP_REST_Response|WP_Error The backup schedule time, or a WP_Error if WordPress.com could not be reached. */ public static function get_site_backup_schedule_time() { $blog_id = Jetpack_Options::get_option( 'id' ); $response = Client::wpcom_json_api_request_as_user( '/sites/' . $blog_id . '/rewind/scheduled', 'v2', array(), null, 'wpcom' ); $response_code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $response_code ) { return self::get_failed_fetch_error( $response_code ); } return rest_ensure_response( json_decode( $response['body'], true ) ); } /** * Removes plugin from the connection manager * If it's the last plugin using the connection, the site will be disconnected. * * @access public * @static */ public static function plugin_deactivation() { $manager = new Connection_Manager( 'jetpack-backup' ); $manager->remove_connection(); } /** * Load wp-build when modernization is enabled on the Backup admin page. * * @return void */ public static function maybe_load_wp_build() { if ( ! self::is_modernized() || ! self::is_backup_admin_request() ) { return; } self::load_wp_build(); // wp-build registers standalone modules (e.g. the init module) on // wp_default_scripts, which has already fired by admin_menu. Register them // directly so the init module makes it into the import map. if ( function_exists( 'jetpack_backup_register_script_modules' ) ) { jetpack_backup_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes. } add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) ); add_action( 'admin_print_scripts', array( __CLASS__, 'render_connection_initial_state' ), 1 ); } /** * Emit `window.JP_CONNECTION_INITIAL_STATE` inline on the modernized * Backup admin page. * * The modernized enqueue path short-circuits before the legacy * `Connection_Initial_State::render_script()` call, so without this * the React `` component never sees the connection state and * sits on its loading skeleton forever. We emit the same JS payload * the legacy path emits, just outside of a registered script handle * (wp-build's handles aren't reliable here, and the global is * page-scoped — any tag setting it works). * * @return void */ public static function render_connection_initial_state() { echo ''; } /** * Load the wp-build entry file and register its polyfills. * * Only called on `?page=jetpack-backup` admin requests when the * modernization filter is enabled. Keeps wp-build off every other request. * * @return void */ private static function load_wp_build() { $build_index = dirname( __DIR__ ) . '/build/build.php'; if ( ! file_exists( $build_index ) ) { return; } require_once $build_index; \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register( 'jetpack-backup', array_merge( \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::SCRIPT_HANDLES, \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::MODULE_IDS ) ); } /** * Alias the current screen ID to satisfy wp-build's auto-generated enqueue check. * * Wp-build's `-wp-admin` enqueue callback enqueues only when the screen ID * matches the wp-build page slug (`jetpack-backup-dashboard`). Our WP-admin * menu slug stays `jetpack-backup`, so we mutate the screen object in place * to make the check pass without changing the user-facing URL. * * Hooked only when modernization is on AND we're on the Backup admin page, * so this never affects any other request. * * @param \WP_Screen|null $screen The current screen object (passed by WP). * @return void */ public static function alias_screen_id_for_wp_build( $screen ) { if ( ! is_object( $screen ) ) { return; } $screen->id = 'jetpack-backup-dashboard'; } /** * Returns true when the wp-build modernization filter is enabled. * * @since 4.3.14 Changed from private to public; the REST bridges gate their route registration on it. * * @return bool */ public static function is_modernized() { return (bool) apply_filters( self::MODERNIZATION_FILTER, false ); } /** * Returns true when the modernization filter is on AND the wp-build dashboard loaded. * * `build/` is gitignored, so the render function is absent in any unbuilt checkout * and in any release whose wp-build step failed. Every consumer of the modernized * surface has to agree on this, or the menu falls back to the legacy page while the * enqueue path skips the legacy script — an empty div with no JS. * * Only meaningful once `maybe_load_wp_build()` has run. It is hooked on `admin_menu` * at the same priority as `add_wp_admin_submenu()`, so registration order — not * priority — is what keeps it first. Do not reorder those two `add_action()` calls. * * @return bool */ private static function is_wp_build_dashboard_active() { return self::is_modernized() && function_exists( 'jetpack_backup_jetpack_backup_dashboard_wp_admin_render_page' ); } /** * Returns true when the current request targets the Backup admin page. * * Used to scope wp-build loading to the one page that needs it. The * `$_GET['page']` value is populated by wp-admin/admin.php before any of * our hooks fire, so this check is reliable from `initialize()` onwards. * * @return bool */ private static function is_backup_admin_request() { if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return false; } return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === self::JETPACK_BACKUP_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended } }