Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.48% covered (success)
93.48%
43 / 46
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
Capabilities_Bridge
97.73% covered (success)
97.73%
43 / 44
50.00% covered (danger)
50.00%
1 / 2
8
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 get_capabilities
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
7
1<?php
2/**
3 * Capabilities REST bridge — WPCOM's site rewind state.
4 *
5 * @package automattic/jetpack-backup-plugin
6 */
7
8namespace Automattic\Jetpack\Backup\V0005\REST;
9
10use Automattic\Jetpack\Connection\Client;
11use WP_Error;
12use WP_REST_Server;
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit( 0 );
16}
17
18/**
19 * Returns what the modernized dashboard is allowed to show.
20 */
21class Capabilities_Bridge {
22
23    /**
24     * Register the GET /jetpack/v4/site/capabilities route.
25     *
26     * @return void
27     */
28    public static function register_routes() {
29        register_rest_route(
30            'jetpack/v4',
31            '/site/capabilities',
32            array(
33                'methods'             => WP_REST_Server::READABLE,
34                'callback'            => array( __CLASS__, 'get_capabilities' ),
35                'permission_callback' => array( Rest_Controller::class, 'permission_check' ),
36            )
37        );
38    }
39
40    /**
41     * Proxy `/sites/{id}/rewind/capabilities` (v2, as_user) and project
42     * the response into the shape the React layer expects.
43     *
44     * This is the same endpoint the legacy `/jetpack/v4/backup-capabilities`
45     * route hits — it returns a flat `{ capabilities: [...] }` envelope.
46     * The earlier `/rewind?force=wpcom` variant returns site *state*, not
47     * a capabilities list, and on some plan shapes (e.g. Jetpack Complete)
48     * the `capabilities` key is missing entirely, which produced a false
49     * "no plan" gate for plans that do include Backup.
50     *
51     * @return \WP_REST_Response|WP_Error The decoded capabilities, or WP_Error on failure.
52     */
53    public static function get_capabilities() {
54        $blog_id = Rest_Controller::get_blog_id_or_error();
55        if ( is_wp_error( $blog_id ) ) {
56            return $blog_id;
57        }
58
59        $response = Client::wpcom_json_api_request_as_user(
60            sprintf( '/sites/%d/rewind/capabilities', $blog_id ),
61            'v2',
62            array(),
63            null,
64            'wpcom'
65        );
66
67        if ( is_wp_error( $response ) ) {
68            return Rest_Controller::transport_error( $response, 'capabilities_fetch_failed' );
69        }
70
71        // Cast: `wp_remote_retrieve_response_code()` returns whatever the
72        // transport put there, and a numeric string fails a strict
73        // comparison against 200 — sending a perfectly good response down
74        // the failure branch, and reporting it as a failure rather than as
75        // the success it was.
76        $status_code = (int) wp_remote_retrieve_response_code( $response );
77        if ( 200 !== $status_code ) {
78            return Rest_Controller::upstream_error(
79                $response,
80                'capabilities_fetch_failed',
81                __( 'Could not fetch site capabilities.', 'jetpack-backup-pkg' )
82            );
83        }
84
85        $body = json_decode( wp_remote_retrieve_body( $response ), true );
86
87        // A 200 we cannot read is refused rather than projected.
88        //
89        // Coercing it to an empty list is the same as answering "this site
90        // has no Backup plan", and that answer is acted on: `<Gates>`
91        // renders the upgrade screen. So a truncated response, an HTML
92        // error page from something in front of WordPress.com, or a shape
93        // change upstream would each show a paying customer an advert for
94        // what they already own, with no error anywhere to explain it.
95        // The docblock above records this exact mechanism firing once
96        // already; that fix repointed the endpoint and left the tolerant
97        // projection in place.
98        //
99        // An *empty* list is not this case. It is a legitimate answer —
100        // the one every site without Backup gives — and refusing it would
101        // put a permanent error in front of precisely the people the
102        // upgrade screen is for.
103        // `wp_is_numeric_array()` and not `is_array()`, because the two
104        // differ on the shape most likely to arrive if upstream drifts: a
105        // keyed map. `is_array()` accepts `{"capabilities":{"backup":true}}`,
106        // and `in_array()` then compares against that map's *values* — so
107        // the site reads as having no plan, which is the outcome this
108        // whole guard exists to prevent. It returns true for an empty
109        // array, so the carve-out below survives.
110        if (
111            ! is_array( $body )
112            || ! isset( $body['capabilities'] )
113            || ! wp_is_numeric_array( $body['capabilities'] )
114        ) {
115            return new WP_Error(
116                'capabilities_unreadable',
117                __( "Could not read this site's plan details.", 'jetpack-backup-pkg' ),
118                // Deliberately not the 502 `Rest_Controller::transport_error()`
119                // uses: the client reads 502 as "the answer went missing",
120                // a meaning it shares with the destructive restore
121                // mutation. Nothing was in flight here. WordPress.com
122                // answered; we could not read what it said.
123                array( 'status' => 500 )
124            );
125        }
126
127        $capabilities = $body['capabilities'];
128
129        return rest_ensure_response(
130            array(
131                'hasBackupPlan' => in_array( 'backup', $capabilities, true ),
132                'hasScan'       => in_array( 'scan', $capabilities, true ),
133            )
134        );
135    }
136}