Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
88.75% |
71 / 80 |
|
66.67% |
6 / 9 |
CRAP | |
0.00% |
0 / 1 |
| Rest_Controller | |
91.03% |
71 / 78 |
|
66.67% |
6 / 9 |
39.04 | |
0.00% |
0 / 1 |
| register_routes | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| permission_check | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 | |||
| get_blog_id_or_error | |
37.50% |
3 / 8 |
|
0.00% |
0 / 1 |
2.98 | |||
| named_types | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
6 | |||
| request_names_no_types | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| transport_error | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
| upstream_error | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| upstream_reason | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
15 | |||
| clip_reason | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
| 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 | /** |
| 250 | * Longest either half of a forwarded upstream reason may be. |
| 251 | * |
| 252 | * Neither field is bounded upstream and both travel to the browser on |
| 253 | * every failed request, so a VaultPress stack trace in `message` would |
| 254 | * otherwise be copied out verbatim. |
| 255 | * |
| 256 | * @var int |
| 257 | */ |
| 258 | private const REASON_MAX_LENGTH = 200; |
| 259 | |
| 260 | /** |
| 261 | * Convert a non-200 answer from WordPress.com into a bridge error. |
| 262 | * |
| 263 | * The counterpart of `transport_error()`, for the failure where the |
| 264 | * request did arrive and WordPress.com refused it. Every bridge used to |
| 265 | * spell this branch out for itself, and every spelling threw away the |
| 266 | * only part that says *why*: a plan problem and an expired token both |
| 267 | * reached a support agent as `restore_initiate_failed` / "Could not |
| 268 | * start the backup restore.", distinguishable only by a status code. |
| 269 | * |
| 270 | * WordPress.com's own reason is preserved under `wpcom`, deliberately |
| 271 | * shaped like `transport_error()`'s `transport` key. The client decides |
| 272 | * what to do with it: `failureMessage()` in `_helpers.ts` maps the |
| 273 | * codes whose meaning is the code itself, and *renders* the message for |
| 274 | * the ones — `rewind_error`, `authorization_required` — where one code |
| 275 | * spans several unrelated situations and only the sentence tells them |
| 276 | * apart. |
| 277 | * |
| 278 | * That the message can reach a reader is why the flattening and the |
| 279 | * 200-character clip below are not housekeeping. They are the whole |
| 280 | * reason it is safe to render, so do not relax them. It stays a plain |
| 281 | * string all the way out; the client escapes it by rendering it as |
| 282 | * React children. |
| 283 | * |
| 284 | * Only those two fields are forwarded, never the body — an error body |
| 285 | * is unbounded and can echo the request that produced it. |
| 286 | * |
| 287 | * The status is clamped to the failure range rather than merely tested |
| 288 | * for truthiness, and that is load-bearing. The retrieval helper hands |
| 289 | * back whatever the transport put there, and its callers must cast |
| 290 | * before comparing — a numeric-string `'200'` fails `200 !== |
| 291 | * $status_code`, which routed a perfectly good response into the |
| 292 | * failure branch. Every status comparison in this package casts now — |
| 293 | * bridges and legacy routes alike — so this function should no longer |
| 294 | * be reachable with a success code; the clamp stays because "should |
| 295 | * not" is not "cannot", and the cost of being wrong is one-sided. |
| 296 | * Forwarding a 200 would set `data.status` to 200, WordPress would |
| 297 | * serve the error envelope as HTTP 200, `apiFetch` would resolve |
| 298 | * instead of rejecting, and `apiCall()` would never throw — so a failed |
| 299 | * restore would run the mutation's `onSuccess` and report a restore |
| 300 | * that never started. A visible failure becoming an invisible false |
| 301 | * success is the worst outcome available on a destructive operation, so |
| 302 | * anything outside 4xx/5xx becomes a 500. |
| 303 | * |
| 304 | * The same clamp is what keeps junk out. `(int)` is a total function: |
| 305 | * `'2 Bad'` is 2, `3.7` is 3, `true` is 1, and a zero must never reach |
| 306 | * a `WP_Error` at all, because core hands the status to |
| 307 | * `status_header()` and a zero there emits an invalid status line. |
| 308 | * |
| 309 | * What the cast does buy, and the reason it is here rather than an |
| 310 | * `is_int()` test, is that a genuine `'404'` from a transport that |
| 311 | * reports statuses as strings now travels as 404 instead of being |
| 312 | * flattened to 500. The client is literal about the type too: |
| 313 | * `isAmbiguousFailure()` only reads a status that is already a number. |
| 314 | * |
| 315 | * @param array|\WP_Error $response The wp_remote_* response. Non-200 by the time it gets here. |
| 316 | * @param string $code Bridge error code for the operation that failed. |
| 317 | * @param string $message Translated message for the reader. |
| 318 | * @return WP_Error |
| 319 | */ |
| 320 | public static function upstream_error( $response, $code, $message ) { |
| 321 | $status_code = (int) wp_remote_retrieve_response_code( $response ); |
| 322 | |
| 323 | $data = array( 'status' => $status_code >= 400 && $status_code <= 599 ? $status_code : 500 ); |
| 324 | |
| 325 | $reason = self::upstream_reason( wp_remote_retrieve_body( $response ) ); |
| 326 | if ( ! empty( $reason ) ) { |
| 327 | $data['wpcom'] = $reason; |
| 328 | } |
| 329 | |
| 330 | return new WP_Error( $code, $message, $data ); |
| 331 | } |
| 332 | |
| 333 | /** |
| 334 | * Read WordPress.com's own reason out of a response body. |
| 335 | * |
| 336 | * Three shapes have to be read here and they disagree about where the |
| 337 | * reason lives. A wpcom/v2 route serializes a `WP_Error` as `{ code, |
| 338 | * message, data }`. The older v1 envelope names that same token `error` |
| 339 | * and keeps prose beside it in `message`. And the restore endpoint's |
| 340 | * own `{ ok: false, error }` body puts VaultPress's sentence directly |
| 341 | * in `error`, with no token anywhere. |
| 342 | * |
| 343 | * So `error` is sometimes a token and sometimes a sentence, and the |
| 344 | * only thing separating them is shape: a `WP_Error` code has no |
| 345 | * whitespace in it, and a VaultPress refusal is a sentence. Sorting on |
| 346 | * that is what keeps the two halves honest. The client matches `code` |
| 347 | * against a list of codes it knows, so a sentence landing there would |
| 348 | * become a key that can never match — the reason lost again, in a |
| 349 | * quieter way. |
| 350 | * |
| 351 | * Sorting is all it does, though: nothing is ever discarded. When |
| 352 | * `error` holds a sentence *and* `message` holds another — which is |
| 353 | * what a VaultPress refusal wrapped in a generic envelope looks like — |
| 354 | * both are kept, `error` first, because that is the specific half. An |
| 355 | * earlier revision promoted `error` only when `message` was empty, and |
| 356 | * so threw away the specific reason in exactly the shape this function |
| 357 | * exists to read. |
| 358 | * |
| 359 | * `/u` on the whitespace test is not cosmetic. Without it `\s` is |
| 360 | * ASCII-only, so a sentence spaced with U+00A0 has "no whitespace" and |
| 361 | * lands in `code` — the precise outcome the sort is here to prevent. |
| 362 | * |
| 363 | * @param string|array $body Raw response body, or one already decoded. |
| 364 | * @return array<string, string> `code` and/or `message`; empty when the body names no reason. |
| 365 | */ |
| 366 | public static function upstream_reason( $body ) { |
| 367 | $decoded = is_array( $body ) ? $body : json_decode( (string) $body, true ); |
| 368 | if ( ! is_array( $decoded ) ) { |
| 369 | return array(); |
| 370 | } |
| 371 | |
| 372 | $token = ''; |
| 373 | foreach ( array( 'code', 'error' ) as $key ) { |
| 374 | if ( isset( $decoded[ $key ] ) && is_string( $decoded[ $key ] ) && '' !== trim( $decoded[ $key ] ) ) { |
| 375 | $token = trim( $decoded[ $key ] ); |
| 376 | break; |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | $prose = isset( $decoded['message'] ) && is_string( $decoded['message'] ) ? trim( $decoded['message'] ) : ''; |
| 381 | |
| 382 | $reason = array(); |
| 383 | $sentences = array(); |
| 384 | |
| 385 | if ( '' !== $token && ! preg_match( '/\s/u', $token ) ) { |
| 386 | $reason['code'] = self::clip_reason( $token ); |
| 387 | } elseif ( '' !== $token ) { |
| 388 | $sentences[] = $token; |
| 389 | } |
| 390 | |
| 391 | // Guarded against the duplicate rather than assumed away: some |
| 392 | // envelopes repeat the same text in both keys, and joining it to |
| 393 | // itself would say everything twice inside a budget meant for one. |
| 394 | if ( '' !== $prose && ! in_array( $prose, $sentences, true ) ) { |
| 395 | $sentences[] = $prose; |
| 396 | } |
| 397 | |
| 398 | if ( ! empty( $sentences ) ) { |
| 399 | $reason['message'] = self::clip_reason( implode( ' ', $sentences ) ); |
| 400 | } |
| 401 | |
| 402 | return $reason; |
| 403 | } |
| 404 | |
| 405 | /** |
| 406 | * Flatten and shorten one half of an upstream reason. |
| 407 | * |
| 408 | * Newlines go first so a multi-line upstream message cannot spend the |
| 409 | * whole budget on indentation before it says anything. |
| 410 | * |
| 411 | * `mb_substr()` rather than `substr()`, and the reason is mostly not |
| 412 | * the exotic one. A byte-wise cut spends the budget in bytes, so a |
| 413 | * reason written in a script that costs three bytes a character keeps |
| 414 | * a third of what it was allotted — 67 characters of 200, in the test |
| 415 | * that pins this. The encoding damage is the smaller half: the cut |
| 416 | * lands inside a character and leaves the field invalid UTF-8, which |
| 417 | * `wp_json_encode()` does not reject — its sanity check silently |
| 418 | * rewrites the broken bytes, so the reason arrives with a `?` on the |
| 419 | * end and nothing anywhere says why. |
| 420 | * |
| 421 | * The flatten runs in Unicode mode so a non-breaking or ideographic |
| 422 | * space collapses like any other, which also means it returns null on |
| 423 | * invalid UTF-8. The ASCII pass stands behind it so the bound is still |
| 424 | * enforced in that case. Unreachable in practice — every string that |
| 425 | * gets here came out of a `json_decode()`, which refuses invalid |
| 426 | * UTF-8 outright — but a silent null would turn the whole reason into |
| 427 | * an empty string, which is a poor way to find out. |
| 428 | * |
| 429 | * @param string $value Raw upstream text. |
| 430 | * @return string |
| 431 | */ |
| 432 | private static function clip_reason( $value ) { |
| 433 | $flattened = preg_replace( '/\s+/u', ' ', $value ); |
| 434 | if ( null === $flattened ) { |
| 435 | $flattened = preg_replace( '/\s+/', ' ', $value ); |
| 436 | } |
| 437 | |
| 438 | $value = trim( (string) $flattened ); |
| 439 | |
| 440 | if ( mb_strlen( $value, 'UTF-8' ) <= self::REASON_MAX_LENGTH ) { |
| 441 | return $value; |
| 442 | } |
| 443 | |
| 444 | return rtrim( mb_substr( $value, 0, self::REASON_MAX_LENGTH, 'UTF-8' ) ) . '…'; |
| 445 | } |
| 446 | } |