Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.74% |
208 / 215 |
|
42.86% |
3 / 7 |
CRAP | |
0.00% |
0 / 1 |
| Download_Bridge | |
97.65% |
208 / 213 |
|
42.86% |
3 / 7 |
59 | |
0.00% |
0 / 1 |
| register_routes | |
100.00% |
55 / 55 |
|
100.00% |
1 / 1 |
1 | |||
| initiate_download | |
97.01% |
65 / 67 |
|
0.00% |
0 / 1 |
21 | |||
| path_list | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
5.02 | |||
| get_download_status | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
5 | |||
| get_downloads | |
96.77% |
30 / 31 |
|
0.00% |
0 / 1 |
7 | |||
| project_download | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
15 | |||
| is_same_backup | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Download REST bridge — proxies the /sites/{id}/rewind/downloads |
| 4 | * collection and its per-download status. |
| 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_Request; |
| 14 | use WP_REST_Server; |
| 15 | |
| 16 | if ( ! defined( 'ABSPATH' ) ) { |
| 17 | exit( 0 ); |
| 18 | } |
| 19 | |
| 20 | /** |
| 21 | * Download endpoints powering the Download screen: |
| 22 | * - POST /jetpack/v4/backups/download/{rewindId} → ask WPCOM to build the archive |
| 23 | * - GET /jetpack/v4/backups/download/{rewindId}/status → poll progress |
| 24 | * - GET /jetpack/v4/backups/downloads → find a download already under way |
| 25 | */ |
| 26 | class Download_Bridge { |
| 27 | |
| 28 | /** |
| 29 | * Register routes. |
| 30 | * |
| 31 | * @return void |
| 32 | */ |
| 33 | public static function register_routes() { |
| 34 | register_rest_route( |
| 35 | 'jetpack/v4', |
| 36 | '/backups/download/(?P<rewind_id>[A-Za-z0-9.\-]+)', |
| 37 | array( |
| 38 | 'methods' => WP_REST_Server::CREATABLE, |
| 39 | 'callback' => array( __CLASS__, 'initiate_download' ), |
| 40 | 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), |
| 41 | 'args' => array( |
| 42 | 'rewind_id' => array( |
| 43 | 'type' => 'string', |
| 44 | 'required' => true, |
| 45 | ), |
| 46 | 'types' => array( |
| 47 | 'type' => 'object', |
| 48 | // Values must be booleans. WordPress validates |
| 49 | // `object` with `rest_is_object()`, which is just |
| 50 | // `is_array()`, so without this a JSON list of |
| 51 | // category names passes and reaches WPCOM as a list |
| 52 | // whose numeric keys it reads as category names. |
| 53 | // This rejects that with a 400 before the callback |
| 54 | // runs; `Rest_Controller::named_types()` makes the |
| 55 | // shape guarantee that a value check cannot. |
| 56 | 'additionalProperties' => array( 'type' => 'boolean' ), |
| 57 | ), |
| 58 | // Opaque `/rewind/backup/ls` entry ids, only meaningful |
| 59 | // alongside `types: { paths: true }` — see `initiate_download()`. |
| 60 | 'include_path_list' => array( |
| 61 | 'type' => 'array', |
| 62 | 'items' => array( 'type' => 'string' ), |
| 63 | ), |
| 64 | 'exclude_path_list' => array( |
| 65 | 'type' => 'array', |
| 66 | 'items' => array( 'type' => 'string' ), |
| 67 | ), |
| 68 | ), |
| 69 | ) |
| 70 | ); |
| 71 | |
| 72 | register_rest_route( |
| 73 | 'jetpack/v4', |
| 74 | '/backups/download/(?P<rewind_id>[A-Za-z0-9.\-]+)/status', |
| 75 | array( |
| 76 | 'methods' => WP_REST_Server::READABLE, |
| 77 | 'callback' => array( __CLASS__, 'get_download_status' ), |
| 78 | 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), |
| 79 | 'args' => array( |
| 80 | 'rewind_id' => array( |
| 81 | 'type' => 'string', |
| 82 | 'required' => true, |
| 83 | ), |
| 84 | 'download_id' => array( |
| 85 | 'type' => 'integer', |
| 86 | 'required' => true, |
| 87 | ), |
| 88 | ), |
| 89 | ) |
| 90 | ); |
| 91 | |
| 92 | register_rest_route( |
| 93 | 'jetpack/v4', |
| 94 | '/backups/downloads', |
| 95 | array( |
| 96 | 'methods' => WP_REST_Server::READABLE, |
| 97 | 'callback' => array( __CLASS__, 'get_downloads' ), |
| 98 | 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), |
| 99 | ) |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Initiate a backup download. |
| 105 | * |
| 106 | * Proxies POST wpcom/v2 /sites/{id}/rewind/downloads. The previous |
| 107 | * target — a `prepare-download` path under the backup — is not a |
| 108 | * registered route and answered `rest_no_route` on every site tested, |
| 109 | * so the Download screen could never have worked. |
| 110 | * |
| 111 | * The rewind id travels in the request **body**, in full. It is not a |
| 112 | * path segment here, and truncating its decimal suffix would address |
| 113 | * a different backup than the one the reader picked. |
| 114 | * |
| 115 | * Returns `{ id }` for the React layer (the WPCOM key is `downloadId`). |
| 116 | * |
| 117 | * @param WP_REST_Request $request The REST request. |
| 118 | * @return \WP_REST_Response|WP_Error |
| 119 | */ |
| 120 | public static function initiate_download( WP_REST_Request $request ) { |
| 121 | $blog_id = Rest_Controller::get_blog_id_or_error(); |
| 122 | if ( is_wp_error( $blog_id ) ) { |
| 123 | return $blog_id; |
| 124 | } |
| 125 | $rewind_id = (string) $request->get_param( 'rewind_id' ); |
| 126 | $types = $request->get_param( 'types' ); |
| 127 | |
| 128 | $named_types = Rest_Controller::named_types( $types ); |
| 129 | $include = self::path_list( $request, 'include_path_list' ); |
| 130 | $exclude = self::path_list( $request, 'exclude_path_list' ); |
| 131 | |
| 132 | // Nothing upstream checks this pairing: VaultPress reads the path |
| 133 | // lists only for the `paths` type, so a list beside any other |
| 134 | // category answers 200 with a *full-site* archive. |
| 135 | // |
| 136 | // `has_param()` too, for both keys: `path_list()` trims a blank list |
| 137 | // away, and a caller that named files must not fall through as one |
| 138 | // that named none. |
| 139 | if ( $include || $exclude |
| 140 | || $request->has_param( 'include_path_list' ) |
| 141 | || $request->has_param( 'exclude_path_list' ) ) { |
| 142 | if ( array( 'paths' ) !== array_keys( $named_types ) ) { |
| 143 | return new WP_Error( |
| 144 | 'path_list_needs_paths_type', |
| 145 | __( 'A file selection can only be downloaded on its own.', 'jetpack-backup-pkg' ), |
| 146 | array( 'status' => 400 ) |
| 147 | ); |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | if ( ! $include && ! $exclude && isset( $named_types['paths'] ) ) { |
| 152 | // The mirror image, and just as silent upstream: `paths` with |
| 153 | // nothing to scope it by yields an archive of everything. |
| 154 | return new WP_Error( |
| 155 | 'paths_type_needs_path_list', |
| 156 | __( 'No files were named for this download.', 'jetpack-backup-pkg' ), |
| 157 | array( 'status' => 400 ) |
| 158 | ); |
| 159 | } |
| 160 | |
| 161 | // A supplied `types` that names nothing is refused rather than |
| 162 | // dropped. Omitting the key is not "download nothing" — WPCOM |
| 163 | // reads an absent `types` as every category, so forwarding an |
| 164 | // empty selection as an omission would hand back the full archive |
| 165 | // the caller had just excluded. `/rewind/downloads` has no |
| 166 | // server-side guard of its own, unlike the v2 restore route. |
| 167 | if ( Rest_Controller::request_names_no_types( $request ) ) { |
| 168 | return new WP_Error( |
| 169 | 'no_types_selected', |
| 170 | __( 'Select at least one item to download.', 'jetpack-backup-pkg' ), |
| 171 | array( 'status' => 400 ) |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | $body = array( 'rewindId' => $rewind_id ); |
| 176 | // Absent when the caller named no categories at all, which is how |
| 177 | // a whole-archive download is spelled upstream. |
| 178 | if ( ! empty( $named_types ) ) { |
| 179 | $body['types'] = $named_types; |
| 180 | } |
| 181 | // Arrays, not the comma-joined string upstream also takes: that branch |
| 182 | // sanitises the whole string before splitting, so `"a, b"` arrives as `" b"`. |
| 183 | if ( $include ) { |
| 184 | $body['include_path_list'] = $include; |
| 185 | } |
| 186 | if ( $exclude ) { |
| 187 | $body['exclude_path_list'] = $exclude; |
| 188 | } |
| 189 | |
| 190 | $response = Client::wpcom_json_api_request_as_user( |
| 191 | sprintf( '/sites/%d/rewind/downloads', $blog_id ), |
| 192 | 'v2', |
| 193 | array( 'method' => 'POST' ), |
| 194 | $body, |
| 195 | 'wpcom' |
| 196 | ); |
| 197 | |
| 198 | if ( is_wp_error( $response ) ) { |
| 199 | return Rest_Controller::transport_error( $response, 'download_initiate_failed' ); |
| 200 | } |
| 201 | |
| 202 | // Cast because `wp_remote_retrieve_response_code()` hands back |
| 203 | // whatever the transport put there, and a numeric string fails the |
| 204 | // strict comparison below — routing a perfectly good response into |
| 205 | // the failure branch, where `upstream_error()`'s clamp then reports |
| 206 | // it as a 500. Same reasoning at every bridge; the long version is |
| 207 | // on `Rest_Controller::upstream_error()`. |
| 208 | $status_code = (int) wp_remote_retrieve_response_code( $response ); |
| 209 | if ( 200 !== $status_code ) { |
| 210 | return Rest_Controller::upstream_error( |
| 211 | $response, |
| 212 | 'download_initiate_failed', |
| 213 | __( 'Could not start the backup download.', 'jetpack-backup-pkg' ) |
| 214 | ); |
| 215 | } |
| 216 | |
| 217 | $body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 218 | $download_id = is_array( $body ) && isset( $body['downloadId'] ) ? (int) $body['downloadId'] : 0; |
| 219 | if ( ! $download_id ) { |
| 220 | return new WP_Error( |
| 221 | 'download_initiate_failed', |
| 222 | __( 'Download response missing download id.', 'jetpack-backup-pkg' ), |
| 223 | array( 'status' => 500 ) |
| 224 | ); |
| 225 | } |
| 226 | |
| 227 | // While one download builds, WPCOM answers every request with that one, whichever backup it is of. |
| 228 | if ( isset( $body['rewindId'] ) && ! self::is_same_backup( $body['rewindId'], $rewind_id ) ) { |
| 229 | return new WP_Error( |
| 230 | 'download_in_progress', |
| 231 | __( 'Another download is being prepared for this site. Try again once it finishes.', 'jetpack-backup-pkg' ), |
| 232 | array( 'status' => 409 ) |
| 233 | ); |
| 234 | } |
| 235 | |
| 236 | return rest_ensure_response( array( 'id' => $download_id ) ); |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * One path-list parameter as a clean list of entries. |
| 241 | * |
| 242 | * Entries are trimmed and empties dropped, and the result is a PHP |
| 243 | * list so `wp_json_encode()` emits a JSON array rather than an object. |
| 244 | * |
| 245 | * @param WP_REST_Request $request The REST request. |
| 246 | * @param string $key Parameter name. |
| 247 | * @return string[] The entries, empty when the parameter is absent or names nothing. |
| 248 | */ |
| 249 | private static function path_list( WP_REST_Request $request, $key ) { |
| 250 | $value = $request->get_param( $key ); |
| 251 | if ( ! is_array( $value ) ) { |
| 252 | return array(); |
| 253 | } |
| 254 | |
| 255 | $entries = array(); |
| 256 | foreach ( $value as $entry ) { |
| 257 | if ( ! is_scalar( $entry ) ) { |
| 258 | continue; |
| 259 | } |
| 260 | $entry = trim( (string) $entry ); |
| 261 | if ( '' !== $entry ) { |
| 262 | $entries[] = $entry; |
| 263 | } |
| 264 | } |
| 265 | return $entries; |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Poll download status. |
| 270 | * |
| 271 | * Proxies GET wpcom/v2 /sites/{id}/rewind/downloads/{downloadId}. The |
| 272 | * rewind id is not part of the upstream path — it is kept on our own |
| 273 | * route because the client keys its poll cache on (rewindId, |
| 274 | * downloadId), and because it keeps the two download routes |
| 275 | * symmetrical. |
| 276 | * |
| 277 | * @param WP_REST_Request $request The REST request. |
| 278 | * @return \WP_REST_Response|WP_Error |
| 279 | */ |
| 280 | public static function get_download_status( WP_REST_Request $request ) { |
| 281 | $blog_id = Rest_Controller::get_blog_id_or_error(); |
| 282 | if ( is_wp_error( $blog_id ) ) { |
| 283 | return $blog_id; |
| 284 | } |
| 285 | $download_id = (int) $request->get_param( 'download_id' ); |
| 286 | |
| 287 | $response = Client::wpcom_json_api_request_as_user( |
| 288 | sprintf( '/sites/%d/rewind/downloads/%d', $blog_id, $download_id ), |
| 289 | 'v2', |
| 290 | array(), |
| 291 | null, |
| 292 | 'wpcom' |
| 293 | ); |
| 294 | |
| 295 | if ( is_wp_error( $response ) ) { |
| 296 | return Rest_Controller::transport_error( $response, 'download_status_fetch_failed' ); |
| 297 | } |
| 298 | |
| 299 | // Cast, as in `initiate_download()`. |
| 300 | $status_code = (int) wp_remote_retrieve_response_code( $response ); |
| 301 | if ( 200 !== $status_code ) { |
| 302 | return Rest_Controller::upstream_error( |
| 303 | $response, |
| 304 | 'download_status_fetch_failed', |
| 305 | __( 'Could not fetch download status.', 'jetpack-backup-pkg' ) |
| 306 | ); |
| 307 | } |
| 308 | |
| 309 | $body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 310 | |
| 311 | return rest_ensure_response( self::project_download( is_array( $body ) ? $body : array(), $download_id ) ); |
| 312 | } |
| 313 | |
| 314 | /** |
| 315 | * The site's latest downloads, so a reload can find one already under way. |
| 316 | * |
| 317 | * Proxies GET wpcom/v2 /sites/{id}/rewind/downloads, which lists the latest download not yet dismissed. |
| 318 | * |
| 319 | * @return \WP_REST_Response|WP_Error |
| 320 | */ |
| 321 | public static function get_downloads() { |
| 322 | $blog_id = Rest_Controller::get_blog_id_or_error(); |
| 323 | if ( is_wp_error( $blog_id ) ) { |
| 324 | return $blog_id; |
| 325 | } |
| 326 | |
| 327 | $response = Client::wpcom_json_api_request_as_user( |
| 328 | sprintf( '/sites/%d/rewind/downloads', $blog_id ), |
| 329 | 'v2', |
| 330 | array(), |
| 331 | null, |
| 332 | 'wpcom' |
| 333 | ); |
| 334 | |
| 335 | if ( is_wp_error( $response ) ) { |
| 336 | return Rest_Controller::transport_error( $response, 'download_list_fetch_failed' ); |
| 337 | } |
| 338 | |
| 339 | // Cast, as in `initiate_download()`. |
| 340 | $status_code = (int) wp_remote_retrieve_response_code( $response ); |
| 341 | if ( 200 !== $status_code ) { |
| 342 | return Rest_Controller::upstream_error( |
| 343 | $response, |
| 344 | 'download_list_fetch_failed', |
| 345 | __( 'Could not fetch download status.', 'jetpack-backup-pkg' ) |
| 346 | ); |
| 347 | } |
| 348 | |
| 349 | $body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 350 | if ( ! wp_is_numeric_array( $body ) ) { |
| 351 | return new WP_Error( |
| 352 | 'download_list_fetch_failed', |
| 353 | __( 'Could not fetch download status.', 'jetpack-backup-pkg' ), |
| 354 | array( 'status' => 500 ) |
| 355 | ); |
| 356 | } |
| 357 | |
| 358 | $downloads = array(); |
| 359 | foreach ( $body as $download ) { |
| 360 | if ( is_array( $download ) ) { |
| 361 | $downloads[] = self::project_download( $download, 0 ); |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | return rest_ensure_response( $downloads ); |
| 366 | } |
| 367 | |
| 368 | /** |
| 369 | * Shape one WPCOM download for the client. |
| 370 | * |
| 371 | * WPCOM's payload carries **no status field**: it attaches keys by |
| 372 | * branch — `url` and `validUntil` once the archive is ready, `error` |
| 373 | * when it failed, and `progress` only while it is still being built. |
| 374 | * |
| 375 | * @param array $body One download as WPCOM sent it. |
| 376 | * @param int $download_id The id to report when the payload names none. |
| 377 | * @return array The projected download. |
| 378 | */ |
| 379 | private static function project_download( array $body, $download_id ) { |
| 380 | $error = isset( $body['error'] ) ? (string) $body['error'] : ''; |
| 381 | $raw_url = isset( $body['url'] ) ? (string) $body['url'] : ''; |
| 382 | |
| 383 | // The client puts this straight into an `<a href>`, and React does |
| 384 | // not strip dangerous schemes — so check before handing it over. |
| 385 | // |
| 386 | // Deliberately a scheme check rather than `wp_http_validate_url()`, |
| 387 | // which the file-browser bridge uses: that one is built for URLs |
| 388 | // *this server* is about to fetch, so it also does a DNS lookup and |
| 389 | // rejects private IPs and non-standard ports. Right there, wrong |
| 390 | // here — this URL is only ever loaded by the browser, so those |
| 391 | // rules could reject a perfectly good host while costing a DNS |
| 392 | // lookup on every poll. |
| 393 | $scheme = '' === $raw_url ? null : wp_parse_url( $raw_url, PHP_URL_SCHEME ); |
| 394 | $url = ( 'https' === $scheme || 'http' === $scheme ) ? $raw_url : ''; |
| 395 | |
| 396 | // Order matters: a failed download can still carry a stale `url` |
| 397 | // from an earlier attempt, so the error branch is checked first. |
| 398 | if ( '' !== $error ) { |
| 399 | $status = 'failed'; |
| 400 | } elseif ( '' !== $url ) { |
| 401 | $status = 'finished'; |
| 402 | } elseif ( '' !== $raw_url ) { |
| 403 | // A URL arrived but is not one we will hand to the browser. |
| 404 | // Reported as a failure rather than left to fall through to |
| 405 | // `running`, which would poll forever against a download that |
| 406 | // is in fact finished. |
| 407 | $status = 'failed'; |
| 408 | $error = __( 'The download link could not be used.', 'jetpack-backup-pkg' ); |
| 409 | } elseif ( isset( $body['validUntil'] ) ) { |
| 410 | // WPCOM withdraws the link once it lapses, but keeps its expiry. |
| 411 | $status = 'expired'; |
| 412 | } else { |
| 413 | $status = 'running'; |
| 414 | } |
| 415 | |
| 416 | return array( |
| 417 | 'id' => isset( $body['downloadId'] ) ? (int) $body['downloadId'] : $download_id, |
| 418 | 'rewind_id' => isset( $body['rewindId'] ) && is_scalar( $body['rewindId'] ) ? (string) $body['rewindId'] : '', |
| 419 | 'status' => $status, |
| 420 | // 0-100, absent once the download leaves the in-flight branch. Clamped, not |
| 421 | // trusted: the client hands it straight to a progress bar. |
| 422 | 'progress' => isset( $body['progress'] ) ? max( 0, min( 100, (int) $body['progress'] ) ) : 0, |
| 423 | 'url' => $url, |
| 424 | 'valid_until' => isset( $body['validUntil'] ) ? (string) $body['validUntil'] : '', |
| 425 | 'error' => $error, |
| 426 | ); |
| 427 | } |
| 428 | |
| 429 | /** |
| 430 | * Whether the rewind id WPCOM echoed names the backup that was asked for. |
| 431 | * |
| 432 | * Compared as numbers too, so a trailing zero or a JSON number still matches. |
| 433 | * |
| 434 | * @param mixed $echoed The rewind id WPCOM answered with. |
| 435 | * @param string $requested The rewind id this request sent. |
| 436 | * @return bool True when both name the same backup. |
| 437 | */ |
| 438 | private static function is_same_backup( $echoed, $requested ) { |
| 439 | return is_scalar( $echoed ) && ( |
| 440 | (string) $echoed === $requested |
| 441 | || ( is_numeric( $echoed ) && is_numeric( $requested ) && (float) $echoed === (float) $requested ) |
| 442 | ); |
| 443 | } |
| 444 | } |