Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.74% covered (success)
96.74%
208 / 215
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Download_Bridge
97.65% covered (success)
97.65%
208 / 213
42.86% covered (danger)
42.86%
3 / 7
59
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
1 / 1
1
 initiate_download
97.01% covered (success)
97.01%
65 / 67
0.00% covered (danger)
0.00%
0 / 1
21
 path_list
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 get_download_status
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
5
 get_downloads
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
7
 project_download
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
15
 is_same_backup
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
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
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 *   - GET  /jetpack/v4/backups/downloads                      → find a download already under way
25 */
26class 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}