← All changes
|
jetpack_vendor/automattic/jetpack-backup/src/rest/class-rest-controller.php
+445
-0
16.2-beta
→
16.3
View file →
| @@ -1,0 +1,445 @@ | ||
| 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 `is_modernized()` is true, so the | |
| 26 | + * legacy plugin is byte-identical when it is false. | |
| 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 `is_modernized()` is true. | |
| 68 | + * | |
| 69 | + * @return void | |
| 70 | + */ | |
| 71 | + public static function register_routes() { | |
| 72 | + if ( ! Jetpack_Backup::is_modernized() ) { | |
| 73 | + return; | |
| 74 | + } | |
| 75 | + | |
| 76 | + Capabilities_Bridge::register_routes(); | |
| 77 | + Activity_Log_Bridge::register_routes(); | |
| 78 | + File_Browser_Bridge::register_routes(); | |
| 79 | + Download_Bridge::register_routes(); | |
| 80 | + Restore_Bridge::register_routes(); | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** | |
| 84 | + * Permission check shared by every modernized-dashboard route. | |
| 85 | + * | |
| 86 | + * Mirrors the activity-log package's pattern: `manage_options` is | |
| 87 | + * necessary but not sufficient — every bridge eventually proxies a | |
| 88 | + * WPCOM endpoint that's user-gated, so a site admin who isn't | |
| 89 | + * personally WPCOM-linked needs a clearer error than the opaque | |
| 90 | + * "Only Administrators can query…" WPCOM returns. | |
| 91 | + * | |
| 92 | + * @return bool|WP_Error True when the current user can call the bridges, WP_Error otherwise. | |
| 93 | + */ | |
| 94 | + public static function permission_check() { | |
| 95 | + if ( ! current_user_can( 'manage_options' ) ) { | |
| 96 | + return false; | |
| 97 | + } | |
| 98 | + | |
| 99 | + if ( ! ( new Connection_Manager() )->is_user_connected() ) { | |
| 100 | + return new WP_Error( | |
| 101 | + 'user_not_connected', | |
| 102 | + __( 'Your WordPress.com account is not connected to this site.', 'jetpack-backup-pkg' ), | |
| 103 | + array( 'status' => 403 ) | |
| 104 | + ); | |
| 105 | + } | |
| 106 | + | |
| 107 | + return true; | |
| 108 | + } | |
| 109 | + | |
| 110 | + /** | |
| 111 | + * Returns the site's WPCOM blog id, or a `not_connected` WP_Error | |
| 112 | + * when the site hasn't been registered yet. Shared across the bridges | |
| 113 | + * so the `sprintf( '/sites/%d/…', $blog_id )` upstream path is never | |
| 114 | + * built with an empty id. | |
| 115 | + * | |
| 116 | + * @return int|WP_Error Blog id, or WP_Error when not connected. | |
| 117 | + */ | |
| 118 | + public static function get_blog_id_or_error() { | |
| 119 | + $blog_id = (int) Jetpack_Options::get_option( 'id' ); | |
| 120 | + if ( ! $blog_id ) { | |
| 121 | + return new WP_Error( | |
| 122 | + 'not_connected', | |
| 123 | + __( 'This site is not connected to Jetpack.', 'jetpack-backup-pkg' ), | |
| 124 | + array( 'status' => 412 ) | |
| 125 | + ); | |
| 126 | + } | |
| 127 | + return $blog_id; | |
| 128 | + } | |
| 129 | + | |
| 130 | + /** | |
| 131 | + * Rebuild a restore/download `types` parameter as a named map. | |
| 132 | + * | |
| 133 | + * The PHP counterpart of the client's `requireTypes`, and the reason | |
| 134 | + * it exists here rather than being trusted from the request: WordPress | |
| 135 | + * validates `'type' => 'object'` with `rest_is_object()`, which is | |
| 136 | + * `is_array()`. A JSON list therefore passes validation and arrives as | |
| 137 | + * a PHP list, whose numeric keys WPCOM reads as category names. The | |
| 138 | + * route schema rejects the realistic version of that, but only because | |
| 139 | + * the members fail a boolean check — shape itself is never asserted — | |
| 140 | + * so the guarantee is made here, where the payload is actually built. | |
| 141 | + * | |
| 142 | + * Only known categories with a truthy value survive, and every | |
| 143 | + * surviving value is normalized to `true`. Values are read with | |
| 144 | + * `rest_sanitize_boolean()` so a form-encoded `"false"` or `"0"` means | |
| 145 | + * skip rather than select. | |
| 146 | + * | |
| 147 | + * Unknown keys are dropped rather than forwarded, which is what makes | |
| 148 | + * `request_names_no_types()` a total guard: without it a payload naming | |
| 149 | + * only categories WPCOM does not recognize would satisfy the guard and | |
| 150 | + * be sent on, and what WPCOM does with a `types` that matches nothing | |
| 151 | + * is not characterized. Dropping them means such a payload names | |
| 152 | + * nothing, and is refused. The realistic way to get there is not an | |
| 153 | + * attacker — an admin who can craft the request can already omit | |
| 154 | + * `types` for a whole-site operation — but a future client-side typo: | |
| 155 | + * renaming a checklist key `sqls` to `sql` would otherwise go through | |
| 156 | + * silently. | |
| 157 | + * | |
| 158 | + * @param mixed $types Raw `types` parameter from the request. | |
| 159 | + * @return array<string, true> Named types, empty when none are selected. | |
| 160 | + */ | |
| 161 | + public static function named_types( $types ) { | |
| 162 | + if ( ! is_array( $types ) && ! is_object( $types ) ) { | |
| 163 | + return array(); | |
| 164 | + } | |
| 165 | + | |
| 166 | + $named = array(); | |
| 167 | + foreach ( (array) $types as $key => $value ) { | |
| 168 | + if ( in_array( $key, self::CATEGORIES, true ) && rest_sanitize_boolean( $value ) ) { | |
| 169 | + $named[ $key ] = true; | |
| 170 | + } | |
| 171 | + } | |
| 172 | + return $named; | |
| 173 | + } | |
| 174 | + | |
| 175 | + /** | |
| 176 | + * Whether the request supplied a `types` parameter that names no category. | |
| 177 | + * | |
| 178 | + * The distinction this draws is the whole point of the helper, and it | |
| 179 | + * is the opposite of what it looks like. An **absent** `types` is a | |
| 180 | + * valid, deliberate request for every category — WPCOM's contract is | |
| 181 | + * "omit it for everything" — so the mutations leave the key out for a | |
| 182 | + * whole-site restore or a full archive. A **supplied** `types` that | |
| 183 | + * survives into nothing is the other thing entirely: the caller tried | |
| 184 | + * to name categories and named none, and forwarding that as an | |
| 185 | + * omission would quietly upgrade "restore nothing" into "restore | |
| 186 | + * everything", against a live site. | |
| 187 | + * | |
| 188 | + * It takes the request rather than the value because the value cannot | |
| 189 | + * answer the question. `{"types": null}` is supplied and names nothing, | |
| 190 | + * but arrives as the same `null` an omitted key does — and the schema | |
| 191 | + * never sees it, since `WP_REST_Request::has_valid_params()` skips | |
| 192 | + * `validate_callback` for a null param. `has_param()` is the only thing | |
| 193 | + * that knows the key was on the wire. | |
| 194 | + * | |
| 195 | + * Nothing upstream catches it on both routes. The v2 restore route | |
| 196 | + * rejects a `types` naming nothing, but `/rewind/downloads` does not, | |
| 197 | + * so the guarantee has to be made here for the pair to behave alike. | |
| 198 | + * | |
| 199 | + * @param WP_REST_Request $request The REST request. | |
| 200 | + * @return bool True when the caller supplied a `types` that names no category. | |
| 201 | + */ | |
| 202 | + public static function request_names_no_types( WP_REST_Request $request ) { | |
| 203 | + if ( ! $request->has_param( 'types' ) ) { | |
| 204 | + return false; | |
| 205 | + } | |
| 206 | + | |
| 207 | + return ! self::named_types( $request->get_param( 'types' ) ); | |
| 208 | + } | |
| 209 | + | |
| 210 | + /** | |
| 211 | + * Convert a transport-level failure into a bridge error. | |
| 212 | + * | |
| 213 | + * `Client::wpcom_json_api_request_as_*` answers with a `WP_Error` when | |
| 214 | + * the request never reached WPCOM at all — DNS, TLS, or the cURL | |
| 215 | + * timeout behind JETPACK-2173's "cURL error 28". Returning that error | |
| 216 | + * unchanged hands cURL's own text to the browser, where the dashboard | |
| 217 | + * renders the message verbatim in a notice; it also carries no | |
| 218 | + * `status`, so core answers 500 for what is really a reachability | |
| 219 | + * problem rather than a server fault. | |
| 220 | + * | |
| 221 | + * The raw text is preserved under `transport` rather than discarded. | |
| 222 | + * It is the only part a support agent can act on, and it is the same | |
| 223 | + * reason the non-200 branches forward WPCOM's status instead of | |
| 224 | + * flattening it. | |
| 225 | + * | |
| 226 | + * Always 502: telling a timeout from a refused connection would mean | |
| 227 | + * matching on cURL's English message text, and no caller reads the | |
| 228 | + * difference. | |
| 229 | + * | |
| 230 | + * @param WP_Error $error Transport error from the HTTP client. | |
| 231 | + * @param string $code Bridge error code for the operation that failed. | |
| 232 | + * @return WP_Error | |
| 233 | + */ | |
| 234 | + public static function transport_error( WP_Error $error, $code ) { | |
| 235 | + return new WP_Error( | |
| 236 | + $code, | |
| 237 | + __( 'Could not reach WordPress.com. Check your connection and try again.', 'jetpack-backup-pkg' ), | |
| 238 | + array( | |
| 239 | + 'status' => 502, | |
| 240 | + 'transport' => array( | |
| 241 | + 'code' => $error->get_error_code(), | |
| 242 | + 'message' => $error->get_error_message(), | |
| 243 | + ), | |
| 244 | + ) | |
| 245 | + ); | |
| 246 | + } | |
| 247 | + | |
| 248 | + /** | |
| 249 | + * Longest either half of a forwarded upstream reason may be. | |
| 250 | + * | |
| 251 | + * Neither field is bounded upstream and both travel to the browser on | |
| 252 | + * every failed request, so a VaultPress stack trace in `message` would | |
| 253 | + * otherwise be copied out verbatim. | |
| 254 | + * | |
| 255 | + * @var int | |
| 256 | + */ | |
| 257 | + private const REASON_MAX_LENGTH = 200; | |
| 258 | + | |
| 259 | + /** | |
| 260 | + * Convert a non-200 answer from WordPress.com into a bridge error. | |
| 261 | + * | |
| 262 | + * The counterpart of `transport_error()`, for the failure where the | |
| 263 | + * request did arrive and WordPress.com refused it. Every bridge used to | |
| 264 | + * spell this branch out for itself, and every spelling threw away the | |
| 265 | + * only part that says *why*: a plan problem and an expired token both | |
| 266 | + * reached a support agent as `restore_initiate_failed` / "Could not | |
| 267 | + * start the backup restore.", distinguishable only by a status code. | |
| 268 | + * | |
| 269 | + * WordPress.com's own reason is preserved under `wpcom`, deliberately | |
| 270 | + * shaped like `transport_error()`'s `transport` key. The client decides | |
| 271 | + * what to do with it: `failureMessage()` in `_helpers.ts` maps the | |
| 272 | + * codes whose meaning is the code itself, and *renders* the message for | |
| 273 | + * the ones — `rewind_error`, `authorization_required` — where one code | |
| 274 | + * spans several unrelated situations and only the sentence tells them | |
| 275 | + * apart. | |
| 276 | + * | |
| 277 | + * That the message can reach a reader is why the flattening and the | |
| 278 | + * 200-character clip below are not housekeeping. They are the whole | |
| 279 | + * reason it is safe to render, so do not relax them. It stays a plain | |
| 280 | + * string all the way out; the client escapes it by rendering it as | |
| 281 | + * React children. | |
| 282 | + * | |
| 283 | + * Only those two fields are forwarded, never the body — an error body | |
| 284 | + * is unbounded and can echo the request that produced it. | |
| 285 | + * | |
| 286 | + * The status is clamped to the failure range rather than merely tested | |
| 287 | + * for truthiness, and that is load-bearing. The retrieval helper hands | |
| 288 | + * back whatever the transport put there, and its callers must cast | |
| 289 | + * before comparing — a numeric-string `'200'` fails `200 !== | |
| 290 | + * $status_code`, which routed a perfectly good response into the | |
| 291 | + * failure branch. Every status comparison in this package casts now — | |
| 292 | + * bridges and legacy routes alike — so this function should no longer | |
| 293 | + * be reachable with a success code; the clamp stays because "should | |
| 294 | + * not" is not "cannot", and the cost of being wrong is one-sided. | |
| 295 | + * Forwarding a 200 would set `data.status` to 200, WordPress would | |
| 296 | + * serve the error envelope as HTTP 200, `apiFetch` would resolve | |
| 297 | + * instead of rejecting, and `apiCall()` would never throw — so a failed | |
| 298 | + * restore would run the mutation's `onSuccess` and report a restore | |
| 299 | + * that never started. A visible failure becoming an invisible false | |
| 300 | + * success is the worst outcome available on a destructive operation, so | |
| 301 | + * anything outside 4xx/5xx becomes a 500. | |
| 302 | + * | |
| 303 | + * The same clamp is what keeps junk out. `(int)` is a total function: | |
| 304 | + * `'2 Bad'` is 2, `3.7` is 3, `true` is 1, and a zero must never reach | |
| 305 | + * a `WP_Error` at all, because core hands the status to | |
| 306 | + * `status_header()` and a zero there emits an invalid status line. | |
| 307 | + * | |
| 308 | + * What the cast does buy, and the reason it is here rather than an | |
| 309 | + * `is_int()` test, is that a genuine `'404'` from a transport that | |
| 310 | + * reports statuses as strings now travels as 404 instead of being | |
| 311 | + * flattened to 500. The client is literal about the type too: | |
| 312 | + * `isAmbiguousFailure()` only reads a status that is already a number. | |
| 313 | + * | |
| 314 | + * @param array|\WP_Error $response The wp_remote_* response. Non-200 by the time it gets here. | |
| 315 | + * @param string $code Bridge error code for the operation that failed. | |
| 316 | + * @param string $message Translated message for the reader. | |
| 317 | + * @return WP_Error | |
| 318 | + */ | |
| 319 | + public static function upstream_error( $response, $code, $message ) { | |
| 320 | + $status_code = (int) wp_remote_retrieve_response_code( $response ); | |
| 321 | + | |
| 322 | + $data = array( 'status' => $status_code >= 400 && $status_code <= 599 ? $status_code : 500 ); | |
| 323 | + | |
| 324 | + $reason = self::upstream_reason( wp_remote_retrieve_body( $response ) ); | |
| 325 | + if ( ! empty( $reason ) ) { | |
| 326 | + $data['wpcom'] = $reason; | |
| 327 | + } | |
| 328 | + | |
| 329 | + return new WP_Error( $code, $message, $data ); | |
| 330 | + } | |
| 331 | + | |
| 332 | + /** | |
| 333 | + * Read WordPress.com's own reason out of a response body. | |
| 334 | + * | |
| 335 | + * Three shapes have to be read here and they disagree about where the | |
| 336 | + * reason lives. A wpcom/v2 route serializes a `WP_Error` as `{ code, | |
| 337 | + * message, data }`. The older v1 envelope names that same token `error` | |
| 338 | + * and keeps prose beside it in `message`. And the restore endpoint's | |
| 339 | + * own `{ ok: false, error }` body puts VaultPress's sentence directly | |
| 340 | + * in `error`, with no token anywhere. | |
| 341 | + * | |
| 342 | + * So `error` is sometimes a token and sometimes a sentence, and the | |
| 343 | + * only thing separating them is shape: a `WP_Error` code has no | |
| 344 | + * whitespace in it, and a VaultPress refusal is a sentence. Sorting on | |
| 345 | + * that is what keeps the two halves honest. The client matches `code` | |
| 346 | + * against a list of codes it knows, so a sentence landing there would | |
| 347 | + * become a key that can never match — the reason lost again, in a | |
| 348 | + * quieter way. | |
| 349 | + * | |
| 350 | + * Sorting is all it does, though: nothing is ever discarded. When | |
| 351 | + * `error` holds a sentence *and* `message` holds another — which is | |
| 352 | + * what a VaultPress refusal wrapped in a generic envelope looks like — | |
| 353 | + * both are kept, `error` first, because that is the specific half. An | |
| 354 | + * earlier revision promoted `error` only when `message` was empty, and | |
| 355 | + * so threw away the specific reason in exactly the shape this function | |
| 356 | + * exists to read. | |
| 357 | + * | |
| 358 | + * `/u` on the whitespace test is not cosmetic. Without it `\s` is | |
| 359 | + * ASCII-only, so a sentence spaced with U+00A0 has "no whitespace" and | |
| 360 | + * lands in `code` — the precise outcome the sort is here to prevent. | |
| 361 | + * | |
| 362 | + * @param string|array $body Raw response body, or one already decoded. | |
| 363 | + * @return array<string, string> `code` and/or `message`; empty when the body names no reason. | |
| 364 | + */ | |
| 365 | + public static function upstream_reason( $body ) { | |
| 366 | + $decoded = is_array( $body ) ? $body : json_decode( (string) $body, true ); | |
| 367 | + if ( ! is_array( $decoded ) ) { | |
| 368 | + return array(); | |
| 369 | + } | |
| 370 | + | |
| 371 | + $token = ''; | |
| 372 | + foreach ( array( 'code', 'error' ) as $key ) { | |
| 373 | + if ( isset( $decoded[ $key ] ) && is_string( $decoded[ $key ] ) && '' !== trim( $decoded[ $key ] ) ) { | |
| 374 | + $token = trim( $decoded[ $key ] ); | |
| 375 | + break; | |
| 376 | + } | |
| 377 | + } | |
| 378 | + | |
| 379 | + $prose = isset( $decoded['message'] ) && is_string( $decoded['message'] ) ? trim( $decoded['message'] ) : ''; | |
| 380 | + | |
| 381 | + $reason = array(); | |
| 382 | + $sentences = array(); | |
| 383 | + | |
| 384 | + if ( '' !== $token && ! preg_match( '/\s/u', $token ) ) { | |
| 385 | + $reason['code'] = self::clip_reason( $token ); | |
| 386 | + } elseif ( '' !== $token ) { | |
| 387 | + $sentences[] = $token; | |
| 388 | + } | |
| 389 | + | |
| 390 | + // Guarded against the duplicate rather than assumed away: some | |
| 391 | + // envelopes repeat the same text in both keys, and joining it to | |
| 392 | + // itself would say everything twice inside a budget meant for one. | |
| 393 | + if ( '' !== $prose && ! in_array( $prose, $sentences, true ) ) { | |
| 394 | + $sentences[] = $prose; | |
| 395 | + } | |
| 396 | + | |
| 397 | + if ( ! empty( $sentences ) ) { | |
| 398 | + $reason['message'] = self::clip_reason( implode( ' ', $sentences ) ); | |
| 399 | + } | |
| 400 | + | |
| 401 | + return $reason; | |
| 402 | + } | |
| 403 | + | |
| 404 | + /** | |
| 405 | + * Flatten and shorten one half of an upstream reason. | |
| 406 | + * | |
| 407 | + * Newlines go first so a multi-line upstream message cannot spend the | |
| 408 | + * whole budget on indentation before it says anything. | |
| 409 | + * | |
| 410 | + * `mb_substr()` rather than `substr()`, and the reason is mostly not | |
| 411 | + * the exotic one. A byte-wise cut spends the budget in bytes, so a | |
| 412 | + * reason written in a script that costs three bytes a character keeps | |
| 413 | + * a third of what it was allotted — 67 characters of 200, in the test | |
| 414 | + * that pins this. The encoding damage is the smaller half: the cut | |
| 415 | + * lands inside a character and leaves the field invalid UTF-8, which | |
| 416 | + * `wp_json_encode()` does not reject — its sanity check silently | |
| 417 | + * rewrites the broken bytes, so the reason arrives with a `?` on the | |
| 418 | + * end and nothing anywhere says why. | |
| 419 | + * | |
| 420 | + * The flatten runs in Unicode mode so a non-breaking or ideographic | |
| 421 | + * space collapses like any other, which also means it returns null on | |
| 422 | + * invalid UTF-8. The ASCII pass stands behind it so the bound is still | |
| 423 | + * enforced in that case. Unreachable in practice — every string that | |
| 424 | + * gets here came out of a `json_decode()`, which refuses invalid | |
| 425 | + * UTF-8 outright — but a silent null would turn the whole reason into | |
| 426 | + * an empty string, which is a poor way to find out. | |
| 427 | + * | |
| 428 | + * @param string $value Raw upstream text. | |
| 429 | + * @return string | |
| 430 | + */ | |
| 431 | + private static function clip_reason( $value ) { | |
| 432 | + $flattened = preg_replace( '/\s+/u', ' ', $value ); | |
| 433 | + if ( null === $flattened ) { | |
| 434 | + $flattened = preg_replace( '/\s+/', ' ', $value ); | |
| 435 | + } | |
| 436 | + | |
| 437 | + $value = trim( (string) $flattened ); | |
| 438 | + | |
| 439 | + if ( mb_strlen( $value, 'UTF-8' ) <= self::REASON_MAX_LENGTH ) { | |
| 440 | + return $value; | |
| 441 | + } | |
| 442 | + | |
| 443 | + return rtrim( mb_substr( $value, 0, self::REASON_MAX_LENGTH, 'UTF-8' ) ) . '…'; | |
| 444 | + } | |
| 445 | +} | |