class-activity-log-bridge.php
1 week ago
class-capabilities-bridge.php
1 week ago
class-download-bridge.php
1 week ago
class-file-browser-bridge.php
1 week ago
class-rest-controller.php
1 week ago
class-restore-bridge.php
1 week ago
class-rest-controller.php
249 lines
| 1 | <?php |
| 2 | /** |
| 3 | * REST controller for the modernized Backup dashboard. |
| 4 | * |
| 5 | * @package automattic/jetpack-backup-plugin |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\Backup\V0005\REST; |
| 9 | |
| 10 | use Automattic\Jetpack\Backup\V0005\Jetpack_Backup; |
| 11 | use Automattic\Jetpack\Connection\Manager as Connection_Manager; |
| 12 | use Jetpack_Options; |
| 13 | use WP_Error; |
| 14 | use WP_REST_Request; |
| 15 | |
| 16 | if ( ! defined( 'ABSPATH' ) ) { |
| 17 | exit( 0 ); |
| 18 | } |
| 19 | |
| 20 | /** |
| 21 | * Registers REST routes that back the modernized dashboard. |
| 22 | * |
| 23 | * Each bridge class declares its routes via `register_routes()` and uses |
| 24 | * the shared `permission_check()` helper for the `manage_options` gate. |
| 25 | * Routes only register when the modernization filter is on, so the |
| 26 | * legacy plugin is byte-identical when the flag is off. |
| 27 | */ |
| 28 | class Rest_Controller { |
| 29 | |
| 30 | /** |
| 31 | * Every category WPCOM's rewind endpoints recognize in a `types` map. |
| 32 | * |
| 33 | * The first six are the whole-site checklist the Restore and Download |
| 34 | * screens render. |
| 35 | * |
| 36 | * `paths` covers the granular *download* shape only — `types: { paths: |
| 37 | * true }`, paired with `include_path_list` / `exclude_path_list`. |
| 38 | * Nothing sends it yet (C3), but leaving it out would make the file |
| 39 | * browser's granular download fail closed with a confusing 400 the day |
| 40 | * it is wired up. |
| 41 | * |
| 42 | * It does not carry over to granular *restore*, whatever that ends up |
| 43 | * spelling: the v1 route took `types: 'paths'` as a bare string, which |
| 44 | * is not a map at all and would never reach this allowlist. Granular |
| 45 | * restore has to establish its own shape against the v2 route before |
| 46 | * anything here can claim to cover it. |
| 47 | * |
| 48 | * This list has to grow if VaultPress adds a category. WPCOM's own |
| 49 | * route deliberately does not allowlist, so that it stays open to new |
| 50 | * types; we can afford to be stricter because we also own the UI that |
| 51 | * produces the values, and here a value that names nothing is the |
| 52 | * dangerous case rather than merely a useless one. |
| 53 | * |
| 54 | * @var string[] |
| 55 | */ |
| 56 | private const CATEGORIES = array( |
| 57 | 'themes', |
| 58 | 'plugins', |
| 59 | 'roots', |
| 60 | 'contents', |
| 61 | 'sqls', |
| 62 | 'uploads', |
| 63 | 'paths', |
| 64 | ); |
| 65 | |
| 66 | /** |
| 67 | * Hook entry point. Registers all bridge routes if the modernization |
| 68 | * filter is enabled. |
| 69 | * |
| 70 | * @return void |
| 71 | */ |
| 72 | public static function register_routes() { |
| 73 | if ( ! Jetpack_Backup::is_modernized() ) { |
| 74 | return; |
| 75 | } |
| 76 | |
| 77 | Capabilities_Bridge::register_routes(); |
| 78 | Activity_Log_Bridge::register_routes(); |
| 79 | File_Browser_Bridge::register_routes(); |
| 80 | Download_Bridge::register_routes(); |
| 81 | Restore_Bridge::register_routes(); |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Permission check shared by every modernized-dashboard route. |
| 86 | * |
| 87 | * Mirrors the activity-log package's pattern: `manage_options` is |
| 88 | * necessary but not sufficient — every bridge eventually proxies a |
| 89 | * WPCOM endpoint that's user-gated, so a site admin who isn't |
| 90 | * personally WPCOM-linked needs a clearer error than the opaque |
| 91 | * "Only Administrators can query…" WPCOM returns. |
| 92 | * |
| 93 | * @return bool|WP_Error True when the current user can call the bridges, WP_Error otherwise. |
| 94 | */ |
| 95 | public static function permission_check() { |
| 96 | if ( ! current_user_can( 'manage_options' ) ) { |
| 97 | return false; |
| 98 | } |
| 99 | |
| 100 | if ( ! ( new Connection_Manager() )->is_user_connected() ) { |
| 101 | return new WP_Error( |
| 102 | 'user_not_connected', |
| 103 | __( 'Your WordPress.com account is not connected to this site.', 'jetpack-backup-pkg' ), |
| 104 | array( 'status' => 403 ) |
| 105 | ); |
| 106 | } |
| 107 | |
| 108 | return true; |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * Returns the site's WPCOM blog id, or a `not_connected` WP_Error |
| 113 | * when the site hasn't been registered yet. Shared across the bridges |
| 114 | * so the `sprintf( '/sites/%d/…', $blog_id )` upstream path is never |
| 115 | * built with an empty id. |
| 116 | * |
| 117 | * @return int|WP_Error Blog id, or WP_Error when not connected. |
| 118 | */ |
| 119 | public static function get_blog_id_or_error() { |
| 120 | $blog_id = (int) Jetpack_Options::get_option( 'id' ); |
| 121 | if ( ! $blog_id ) { |
| 122 | return new WP_Error( |
| 123 | 'not_connected', |
| 124 | __( 'This site is not connected to Jetpack.', 'jetpack-backup-pkg' ), |
| 125 | array( 'status' => 412 ) |
| 126 | ); |
| 127 | } |
| 128 | return $blog_id; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Rebuild a restore/download `types` parameter as a named map. |
| 133 | * |
| 134 | * The PHP counterpart of the client's `requireTypes`, and the reason |
| 135 | * it exists here rather than being trusted from the request: WordPress |
| 136 | * validates `'type' => 'object'` with `rest_is_object()`, which is |
| 137 | * `is_array()`. A JSON list therefore passes validation and arrives as |
| 138 | * a PHP list, whose numeric keys WPCOM reads as category names. The |
| 139 | * route schema rejects the realistic version of that, but only because |
| 140 | * the members fail a boolean check — shape itself is never asserted — |
| 141 | * so the guarantee is made here, where the payload is actually built. |
| 142 | * |
| 143 | * Only known categories with a truthy value survive, and every |
| 144 | * surviving value is normalized to `true`. Values are read with |
| 145 | * `rest_sanitize_boolean()` so a form-encoded `"false"` or `"0"` means |
| 146 | * skip rather than select. |
| 147 | * |
| 148 | * Unknown keys are dropped rather than forwarded, which is what makes |
| 149 | * `request_names_no_types()` a total guard: without it a payload naming |
| 150 | * only categories WPCOM does not recognize would satisfy the guard and |
| 151 | * be sent on, and what WPCOM does with a `types` that matches nothing |
| 152 | * is not characterized. Dropping them means such a payload names |
| 153 | * nothing, and is refused. The realistic way to get there is not an |
| 154 | * attacker — an admin who can craft the request can already omit |
| 155 | * `types` for a whole-site operation — but a future client-side typo: |
| 156 | * renaming a checklist key `sqls` to `sql` would otherwise go through |
| 157 | * silently. |
| 158 | * |
| 159 | * @param mixed $types Raw `types` parameter from the request. |
| 160 | * @return array<string, true> Named types, empty when none are selected. |
| 161 | */ |
| 162 | public static function named_types( $types ) { |
| 163 | if ( ! is_array( $types ) && ! is_object( $types ) ) { |
| 164 | return array(); |
| 165 | } |
| 166 | |
| 167 | $named = array(); |
| 168 | foreach ( (array) $types as $key => $value ) { |
| 169 | if ( in_array( $key, self::CATEGORIES, true ) && rest_sanitize_boolean( $value ) ) { |
| 170 | $named[ $key ] = true; |
| 171 | } |
| 172 | } |
| 173 | return $named; |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * Whether the request supplied a `types` parameter that names no category. |
| 178 | * |
| 179 | * The distinction this draws is the whole point of the helper, and it |
| 180 | * is the opposite of what it looks like. An **absent** `types` is a |
| 181 | * valid, deliberate request for every category — WPCOM's contract is |
| 182 | * "omit it for everything" — so the mutations leave the key out for a |
| 183 | * whole-site restore or a full archive. A **supplied** `types` that |
| 184 | * survives into nothing is the other thing entirely: the caller tried |
| 185 | * to name categories and named none, and forwarding that as an |
| 186 | * omission would quietly upgrade "restore nothing" into "restore |
| 187 | * everything", against a live site. |
| 188 | * |
| 189 | * It takes the request rather than the value because the value cannot |
| 190 | * answer the question. `{"types": null}` is supplied and names nothing, |
| 191 | * but arrives as the same `null` an omitted key does — and the schema |
| 192 | * never sees it, since `WP_REST_Request::has_valid_params()` skips |
| 193 | * `validate_callback` for a null param. `has_param()` is the only thing |
| 194 | * that knows the key was on the wire. |
| 195 | * |
| 196 | * Nothing upstream catches it on both routes. The v2 restore route |
| 197 | * rejects a `types` naming nothing, but `/rewind/downloads` does not, |
| 198 | * so the guarantee has to be made here for the pair to behave alike. |
| 199 | * |
| 200 | * @param WP_REST_Request $request The REST request. |
| 201 | * @return bool True when the caller supplied a `types` that names no category. |
| 202 | */ |
| 203 | public static function request_names_no_types( WP_REST_Request $request ) { |
| 204 | if ( ! $request->has_param( 'types' ) ) { |
| 205 | return false; |
| 206 | } |
| 207 | |
| 208 | return ! self::named_types( $request->get_param( 'types' ) ); |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Convert a transport-level failure into a bridge error. |
| 213 | * |
| 214 | * `Client::wpcom_json_api_request_as_*` answers with a `WP_Error` when |
| 215 | * the request never reached WPCOM at all — DNS, TLS, or the cURL |
| 216 | * timeout behind JETPACK-2173's "cURL error 28". Returning that error |
| 217 | * unchanged hands cURL's own text to the browser, where the dashboard |
| 218 | * renders the message verbatim in a notice; it also carries no |
| 219 | * `status`, so core answers 500 for what is really a reachability |
| 220 | * problem rather than a server fault. |
| 221 | * |
| 222 | * The raw text is preserved under `transport` rather than discarded. |
| 223 | * It is the only part a support agent can act on, and it is the same |
| 224 | * reason the non-200 branches forward WPCOM's status instead of |
| 225 | * flattening it. |
| 226 | * |
| 227 | * Always 502: telling a timeout from a refused connection would mean |
| 228 | * matching on cURL's English message text, and no caller reads the |
| 229 | * difference. |
| 230 | * |
| 231 | * @param WP_Error $error Transport error from the HTTP client. |
| 232 | * @param string $code Bridge error code for the operation that failed. |
| 233 | * @return WP_Error |
| 234 | */ |
| 235 | public static function transport_error( WP_Error $error, $code ) { |
| 236 | return new WP_Error( |
| 237 | $code, |
| 238 | __( 'Could not reach WordPress.com. Check your connection and try again.', 'jetpack-backup-pkg' ), |
| 239 | array( |
| 240 | 'status' => 502, |
| 241 | 'transport' => array( |
| 242 | 'code' => $error->get_error_code(), |
| 243 | 'message' => $error->get_error_message(), |
| 244 | ), |
| 245 | ) |
| 246 | ); |
| 247 | } |
| 248 | } |
| 249 |