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