Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.76% covered (success)
95.76%
158 / 165
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Download_Bridge
96.93% covered (success)
96.93%
158 / 163
25.00% covered (danger)
25.00%
1 / 4
41
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
1
 initiate_download
96.72% covered (success)
96.72%
59 / 61
0.00% covered (danger)
0.00%
0 / 1
19
 path_list
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 get_download_status
95.56% covered (success)
95.56%
43 / 45
0.00% covered (danger)
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
9namespace Automattic\Jetpack\Backup\V0005\REST;
10
11use Automattic\Jetpack\Connection\Client;
12use WP_Error;
13use WP_REST_Request;
14use WP_REST_Server;
15
16if ( ! 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 */
25class 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}