| 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 |
} |
| 446 |
|