PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-backup / src / rest / class-download-bridge.php

class-download-bridge.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-backup/src/rest/class-download-bridge.php

353 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
353