← All changes
|
jetpack_vendor/automattic/jetpack-backup/src/rest/class-capabilities-bridge.php
+167
-0
16.2-beta
→
16.3
View file →
| @@ -1,0 +1,167 @@ | ||
| 1 | +<?php | |
| 2 | +/** | |
| 3 | + * Capabilities REST bridge — WPCOM's site rewind state, plus the local | |
| 4 | + * facts the dashboard needs in the same breath. | |
| 5 | + * | |
| 6 | + * @package automattic/jetpack-backup-plugin | |
| 7 | + */ | |
| 8 | + | |
| 9 | +namespace Automattic\Jetpack\Backup\V0005\REST; | |
| 10 | + | |
| 11 | +use Automattic\Jetpack\Connection\Client; | |
| 12 | +use WP_Error; | |
| 13 | +use WP_REST_Server; | |
| 14 | + | |
| 15 | +if ( ! defined( 'ABSPATH' ) ) { | |
| 16 | + exit( 0 ); | |
| 17 | +} | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * Returns what the modernized dashboard is allowed to show. | |
| 21 | + * | |
| 22 | + * Two halves kept apart by the response shape: the top level projects | |
| 23 | + * WordPress.com's answer into the flags `<Gates>` reads, while everything under | |
| 24 | + * `local` was decided on the site. They ride together because this is the one | |
| 25 | + * request every screen makes before rendering a body. Add locally-derived | |
| 26 | + * values under `local`, never beside the projections. | |
| 27 | + */ | |
| 28 | +class Capabilities_Bridge { | |
| 29 | + | |
| 30 | + /** | |
| 31 | + * Register the GET /jetpack/v4/site/capabilities route. | |
| 32 | + * | |
| 33 | + * @return void | |
| 34 | + */ | |
| 35 | + public static function register_routes() { | |
| 36 | + register_rest_route( | |
| 37 | + 'jetpack/v4', | |
| 38 | + '/site/capabilities', | |
| 39 | + array( | |
| 40 | + 'methods' => WP_REST_Server::READABLE, | |
| 41 | + 'callback' => array( __CLASS__, 'get_capabilities' ), | |
| 42 | + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), | |
| 43 | + ) | |
| 44 | + ); | |
| 45 | + } | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * Proxy `/sites/{id}/rewind/capabilities` (v2, as_user) and project | |
| 49 | + * the response into the shape the React layer expects. | |
| 50 | + * | |
| 51 | + * This is the same endpoint the legacy `/jetpack/v4/backup-capabilities` | |
| 52 | + * route hits — it returns a flat `{ capabilities: [...] }` envelope. | |
| 53 | + * The earlier `/rewind?force=wpcom` variant returns site *state*, not | |
| 54 | + * a capabilities list, and on some plan shapes (e.g. Jetpack Complete) | |
| 55 | + * the `capabilities` key is missing entirely, which produced a false | |
| 56 | + * "no plan" gate for plans that do include Backup. | |
| 57 | + * | |
| 58 | + * The `local` branch rides along on the same response and is decided | |
| 59 | + * here rather than upstream; the class docblock says why it lives on | |
| 60 | + * this route and why it is nested. | |
| 61 | + * | |
| 62 | + * @return \WP_REST_Response|WP_Error The decoded capabilities, or WP_Error on failure. | |
| 63 | + */ | |
| 64 | + public static function get_capabilities() { | |
| 65 | + $blog_id = Rest_Controller::get_blog_id_or_error(); | |
| 66 | + if ( is_wp_error( $blog_id ) ) { | |
| 67 | + return $blog_id; | |
| 68 | + } | |
| 69 | + | |
| 70 | + $response = Client::wpcom_json_api_request_as_user( | |
| 71 | + sprintf( '/sites/%d/rewind/capabilities', $blog_id ), | |
| 72 | + 'v2', | |
| 73 | + array(), | |
| 74 | + null, | |
| 75 | + 'wpcom' | |
| 76 | + ); | |
| 77 | + | |
| 78 | + if ( is_wp_error( $response ) ) { | |
| 79 | + return Rest_Controller::transport_error( $response, 'capabilities_fetch_failed' ); | |
| 80 | + } | |
| 81 | + | |
| 82 | + // Cast: `wp_remote_retrieve_response_code()` returns whatever the | |
| 83 | + // transport put there, and a numeric string fails a strict | |
| 84 | + // comparison against 200 — sending a perfectly good response down | |
| 85 | + // the failure branch, and reporting it as a failure rather than as | |
| 86 | + // the success it was. | |
| 87 | + $status_code = (int) wp_remote_retrieve_response_code( $response ); | |
| 88 | + if ( 200 !== $status_code ) { | |
| 89 | + return Rest_Controller::upstream_error( | |
| 90 | + $response, | |
| 91 | + 'capabilities_fetch_failed', | |
| 92 | + __( 'Could not fetch site capabilities.', 'jetpack-backup-pkg' ) | |
| 93 | + ); | |
| 94 | + } | |
| 95 | + | |
| 96 | + $body = json_decode( wp_remote_retrieve_body( $response ), true ); | |
| 97 | + | |
| 98 | + // A 200 we cannot read is refused rather than projected. | |
| 99 | + // | |
| 100 | + // Coercing it to an empty list is the same as answering "this site | |
| 101 | + // has no Backup plan", and that answer is acted on: `<Gates>` | |
| 102 | + // renders the upgrade screen. So a truncated response, an HTML | |
| 103 | + // error page from something in front of WordPress.com, or a shape | |
| 104 | + // change upstream would each show a paying customer an advert for | |
| 105 | + // what they already own, with no error anywhere to explain it. | |
| 106 | + // The docblock above records this exact mechanism firing once | |
| 107 | + // already; that fix repointed the endpoint and left the tolerant | |
| 108 | + // projection in place. | |
| 109 | + // | |
| 110 | + // An *empty* list is not this case. It is a legitimate answer — | |
| 111 | + // the one every site without Backup gives — and refusing it would | |
| 112 | + // put a permanent error in front of precisely the people the | |
| 113 | + // upgrade screen is for. | |
| 114 | + // `wp_is_numeric_array()` and not `is_array()`, because the two | |
| 115 | + // differ on the shape most likely to arrive if upstream drifts: a | |
| 116 | + // keyed map. `is_array()` accepts `{"capabilities":{"backup":true}}`, | |
| 117 | + // and `in_array()` then compares against that map's *values* — so | |
| 118 | + // the site reads as having no plan, which is the outcome this | |
| 119 | + // whole guard exists to prevent. It returns true for an empty | |
| 120 | + // array, so the carve-out below survives. | |
| 121 | + if ( | |
| 122 | + ! is_array( $body ) | |
| 123 | + || ! isset( $body['capabilities'] ) | |
| 124 | + || ! wp_is_numeric_array( $body['capabilities'] ) | |
| 125 | + ) { | |
| 126 | + return new WP_Error( | |
| 127 | + 'capabilities_unreadable', | |
| 128 | + __( "Could not read this site's plan details.", 'jetpack-backup-pkg' ), | |
| 129 | + // Deliberately not the 502 `Rest_Controller::transport_error()` | |
| 130 | + // uses: the client reads 502 as "the answer went missing", | |
| 131 | + // a meaning it shares with the destructive restore | |
| 132 | + // mutation. Nothing was in flight here. WordPress.com | |
| 133 | + // answered; we could not read what it said. | |
| 134 | + array( 'status' => 500 ) | |
| 135 | + ); | |
| 136 | + } | |
| 137 | + | |
| 138 | + $capabilities = $body['capabilities']; | |
| 139 | + | |
| 140 | + return rest_ensure_response( | |
| 141 | + array( | |
| 142 | + 'hasBackupPlan' => in_array( 'backup', $capabilities, true ), | |
| 143 | + 'hasScan' => in_array( 'scan', $capabilities, true ), | |
| 144 | + // Decided here, not upstream. See the class docblock for why | |
| 145 | + // these live in their own branch. | |
| 146 | + 'local' => array( | |
| 147 | + 'isStandalonePluginActive' => self::is_standalone_plugin_active(), | |
| 148 | + ), | |
| 149 | + ) | |
| 150 | + ); | |
| 151 | + } | |
| 152 | + | |
| 153 | + /** | |
| 154 | + * Whether the standalone Jetpack VaultPress Backup plugin is active. | |
| 155 | + * | |
| 156 | + * Answered on the server: the modernized page emits no backup-specific global | |
| 157 | + * to read, and a gate the client never decides cannot be bypassed from it. | |
| 158 | + * `JETPACK_BACKUP_PLUGIN_DIR` tracks *plugin* activation, unlike the | |
| 159 | + * `jetpack-backup` slug in `connectedPlugins`, which the package registers | |
| 160 | + * and so would report the plugin present on the one site that lacks it. | |
| 161 | + * | |
| 162 | + * @return bool True when the standalone Backup plugin is active. | |
| 163 | + */ | |
| 164 | + private static function is_standalone_plugin_active() { | |
| 165 | + return defined( 'JETPACK_BACKUP_PLUGIN_DIR' ); | |
| 166 | + } | |
| 167 | +} | |