Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.17% covered (success)
97.17%
206 / 212
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
File_Browser_Bridge
98.10% covered (success)
98.10%
206 / 210
55.56% covered (warning)
55.56%
5 / 9
34
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
86 / 86
100.00% covered (success)
100.00%
1 / 1
1
 list_directory
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_path_info
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_file_content
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
7
 get_file_download_url
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 resolve_signed_url
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
10
 is_text
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 drop_partial_trailing_character
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 forward_response
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * File browser REST bridge — proxies /sites/{id}/rewind/backup/*.
4 *
5 * @package automattic/jetpack-backup-plugin
6 */
7
8namespace Automattic\Jetpack\Backup\V0005\REST;
9
10use Automattic\Jetpack\Connection\Client;
11use WP_Error;
12use WP_REST_Request;
13use WP_REST_Server;
14
15if ( ! defined( 'ABSPATH' ) ) {
16    exit( 0 );
17}
18
19/**
20 * File browser endpoints powering the BackupDetail file tree:
21 *   - POST /jetpack/v4/rewind/backup/ls           → list folder children
22 *   - GET  /jetpack/v4/rewind/backup/file-content → text preview proxy
23 *   - GET  /jetpack/v4/rewind/backup/path-info    → per-file metadata
24 *   - GET  /jetpack/v4/rewind/backup/file-download-url → signed one-file download link
25 *
26 * All four address a file by the *file's own* `period` from `/ls` —
27 * the timestamp at which that file last changed — never by the parent
28 * backup's rewindId. VaultPress records one row per file version and
29 * matches `period` exactly, with no nearest-earlier fallback, so a
30 * file that did not change during the backup the reader is browsing
31 * has no row under the backup's own id.
32 *
33 * Note the two paths take the manifest path in *different* encodings,
34 * because the upstream routes do: `path-info` carries it raw in the
35 * request body, while `file-content` puts standard base64 in a URL
36 * segment that WPCOM `base64_decode()`s. Neither tolerates the other's
37 * form.
38 */
39class File_Browser_Bridge {
40
41    /**
42     * Cap on text-preview size (bytes). 64 KB is plenty for wp-config.php,
43     * theme style.css, small SQL dumps.
44     */
45    const PREVIEW_MAX_BYTES = 64 * 1024;
46
47    /**
48     * Standard base64, with optional padding — the exact alphabet, and
49     * nothing else.
50     *
51     * Notably excludes `%` and `.`, which is what makes it safe to
52     * interpolate a matching value into a URL path unescaped.
53     */
54    const BASE64_PATTERN = '^[A-Za-z0-9+/]*={0,2}$';
55
56    /**
57     * A snapshot period: Unix seconds, digits only.
58     *
59     * Matches the upstream route's own `(?P<backup_id>\d+)` capture, so
60     * a malformed value fails here with a clear 400 instead of an
61     * opaque upstream 404.
62     */
63    const PERIOD_PATTERN = '^[0-9]+$';
64
65    /**
66     * Register routes.
67     *
68     * @return void
69     */
70    public static function register_routes() {
71        register_rest_route(
72            'jetpack/v4',
73            '/rewind/backup/ls',
74            array(
75                'methods'             => WP_REST_Server::CREATABLE,
76                'callback'            => array( __CLASS__, 'list_directory' ),
77                'permission_callback' => array( Rest_Controller::class, 'permission_check' ),
78                'args'                => array(
79                    'rewind_id' => array(
80                        'type'     => 'string',
81                        'required' => true,
82                    ),
83                    'path'      => array(
84                        'type'     => 'string',
85                        'required' => true,
86                    ),
87                ),
88            )
89        );
90
91        register_rest_route(
92            'jetpack/v4',
93            '/rewind/backup/path-info',
94            array(
95                'methods'             => WP_REST_Server::READABLE,
96                'callback'            => array( __CLASS__, 'get_path_info' ),
97                'permission_callback' => array( Rest_Controller::class, 'permission_check' ),
98                'args'                => array(
99                    'file_period'    => array(
100                        'type'     => 'string',
101                        'required' => true,
102                        'pattern'  => self::PERIOD_PATTERN,
103                    ),
104                    // Unconstrained on purpose: this one is forwarded in
105                    // the request body, not the URL, and a manifest path
106                    // can legitimately contain any byte a filename can.
107                    'manifest_path'  => array(
108                        'type'     => 'string',
109                        'required' => true,
110                    ),
111                    'extension_type' => array(
112                        'type'     => 'string',
113                        'required' => false,
114                        'default'  => '',
115                    ),
116                ),
117            )
118        );
119
120        register_rest_route(
121            'jetpack/v4',
122            '/rewind/backup/file-content',
123            array(
124                'methods'             => WP_REST_Server::READABLE,
125                'callback'            => array( __CLASS__, 'get_file_content' ),
126                'permission_callback' => array( Rest_Controller::class, 'permission_check' ),
127                'args'                => array(
128                    'file_period'           => array(
129                        'type'     => 'string',
130                        'required' => true,
131                        'pattern'  => self::PERIOD_PATTERN,
132                    ),
133                    // Both of these land in the WPCOM URL *path*, and the
134                    // manifest path deliberately goes in unescaped, so
135                    // these patterns are the only guard on it. See
136                    // `resolve_signed_url()` for why escaping is not an option.
137                    'encoded_manifest_path' => array(
138                        'type'     => 'string',
139                        'required' => true,
140                        'pattern'  => self::BASE64_PATTERN,
141                    ),
142                ),
143            )
144        );
145
146        register_rest_route(
147            'jetpack/v4',
148            '/rewind/backup/file-download-url',
149            array(
150                'methods'             => WP_REST_Server::READABLE,
151                'callback'            => array( __CLASS__, 'get_file_download_url' ),
152                'permission_callback' => array( Rest_Controller::class, 'permission_check' ),
153                'args'                => array(
154                    'file_period'           => array(
155                        'type'     => 'string',
156                        'required' => true,
157                        'pattern'  => self::PERIOD_PATTERN,
158                    ),
159                    // Lands in the WPCOM URL path unescaped, like `file-content`.
160                    'encoded_manifest_path' => array(
161                        'type'     => 'string',
162                        'required' => true,
163                        'pattern'  => self::BASE64_PATTERN,
164                    ),
165                ),
166            )
167        );
168    }
169
170    /**
171     * List folder children. Proxies POST wpcom/v2 /sites/{id}/rewind/backup/ls.
172     *
173     * @param WP_REST_Request $request The REST request.
174     * @return \WP_REST_Response|WP_Error
175     */
176    public static function list_directory( WP_REST_Request $request ) {
177        $blog_id = Rest_Controller::get_blog_id_or_error();
178        if ( is_wp_error( $blog_id ) ) {
179            return $blog_id;
180        }
181
182        $response = Client::wpcom_json_api_request_as_user(
183            sprintf( '/sites/%d/rewind/backup/ls', $blog_id ),
184            'v2',
185            array( 'method' => 'POST' ),
186            array(
187                'backup_id' => $request->get_param( 'rewind_id' ),
188                'path'      => $request->get_param( 'path' ),
189            ),
190            'wpcom'
191        );
192
193        return self::forward_response( $response, 'backup_ls_fetch_failed', __( 'Could not list backup contents.', 'jetpack-backup-pkg' ) );
194    }
195
196    /**
197     * Read one file's real metadata. Proxies POST wpcom/v2
198     * /sites/{id}/rewind/backup/path-info.
199     *
200     * Gives the info card the `size`, `hash` and `mtime` VaultPress
201     * actually recorded, rather than the snapshot `period` `/ls` carries.
202     * `manifest_filter` comes back too, which is what a future granular
203     * per-file download needs.
204     *
205     * Two fields are deliberately not surfaced. `download_url` is
206     * hardcoded empty upstream behind a TODO, so it says nothing about
207     * whether the bytes exist. And `data_type` is a small integer type
208     * code — the second character of the manifest path — not a mime
209     * type, so it cannot drive the preview decision; the card keeps
210     * deriving that from the file extension, as Calypso does.
211     *
212     * Upstream answers 200 with an `error` string when the file has no
213     * row, so a caller must branch on `error` rather than on the status.
214     *
215     * @param WP_REST_Request $request The REST request.
216     * @return \WP_REST_Response|WP_Error
217     */
218    public static function get_path_info( WP_REST_Request $request ) {
219        $blog_id = Rest_Controller::get_blog_id_or_error();
220        if ( is_wp_error( $blog_id ) ) {
221            return $blog_id;
222        }
223
224        $response = Client::wpcom_json_api_request_as_user(
225            sprintf( '/sites/%d/rewind/backup/path-info', $blog_id ),
226            'v2',
227            array( 'method' => 'POST' ),
228            array(
229                'backup_id'      => $request->get_param( 'file_period' ),
230                'manifest_path'  => $request->get_param( 'manifest_path' ),
231                'extension_type' => (string) $request->get_param( 'extension_type' ),
232            ),
233            'wpcom'
234        );
235
236        return self::forward_response( $response, 'backup_path_info_failed', __( 'Could not read file details.', 'jetpack-backup-pkg' ) );
237    }
238
239    /**
240     * Fetch a text file's content for the preview pane.
241     *
242     * Resolves the signed URL from WPCOM, then fetches the
243     * stream server-side and caps the body at PREVIEW_MAX_BYTES.
244     * WPCOM's signed-URL stream endpoint doesn't send CORS headers, so
245     * the browser can't fetch it directly.
246     *
247     * Answers `content`, `is_text` and `truncated`; `content` is null unless
248     * `is_text`. A binary or capped body is not an error — both answer 200.
249     *
250     * VaultPress stores file content per the file's own snapshot
251     * `period` — the timestamp when the file last changed — not by the
252     * parent backup's rewindId. Files don't get re-snapshotted every
253     * backup, so the `{period}` URL segment below is the per-entry
254     * `period` from `/ls`, never the rewindId of the backup the user
255     * is browsing. Sending the rewindId instead silently produces a
256     * signed URL for a non-existent storage location and 400s with
257     * "File not found" at stream time.
258     *
259     * @param WP_REST_Request $request The REST request.
260     * @return \WP_REST_Response|WP_Error
261     */
262    public static function get_file_content( WP_REST_Request $request ) {
263        $blog_id = Rest_Controller::get_blog_id_or_error();
264        if ( is_wp_error( $blog_id ) ) {
265            return $blog_id;
266        }
267        $file_period           = (string) $request->get_param( 'file_period' );
268        $encoded_manifest_path = (string) $request->get_param( 'encoded_manifest_path' );
269
270        // Step 1: resolve the signed stream URL. `$encoded_manifest_path` goes in
271        // verbatim; see `resolve_signed_url()` for why.
272        $signed_url = self::resolve_signed_url( $blog_id, $file_period, $encoded_manifest_path );
273        if ( is_wp_error( $signed_url ) ) {
274            return $signed_url;
275        }
276
277        // Step 2: fetch the stream body server-side.
278        //
279        // `limit_response_size` caps the body at the HTTP-transport
280        // layer so a multi-GB blob can't be buffered into PHP memory
281        // before truncation. The bridge enforces no mime check at
282        // all — the React layer's allowlist is advisory only — so any
283        // admin can address any blob the WPCOM signer is willing to
284        // hand a URL for.
285        $stream_response = wp_remote_get(
286            $signed_url,
287            array(
288                'timeout'             => 15,
289                'limit_response_size' => self::PREVIEW_MAX_BYTES,
290            )
291        );
292
293        if ( is_wp_error( $stream_response ) ) {
294            return Rest_Controller::transport_error( $stream_response, 'backup_file_content_stream_failed' );
295        }
296
297        // Cast, as at the signed-URL lookup above — and this is the call
298        // where it bites hardest, because the response body on the success
299        // side is the previewed file itself. Uncast, a `'200'` from a
300        // transport that reports statuses as strings took the branch below
301        // with the file's own bytes in hand: the preview the reader asked
302        // for was discarded and reported as a 500.
303        $stream_status = (int) wp_remote_retrieve_response_code( $stream_response );
304        if ( 200 !== $stream_status ) {
305            // The one failure here whose reason does not come from the JSON
306            // API. This response is the storage host's, not WordPress.com's,
307            // so two caveats ride along with reusing the shared wrapper.
308            //
309            // Its error bodies are usually XML, which `upstream_reason()`
310            // reads nothing out of; asking anyway costs one `json_decode`
311            // on a path that has already failed, and the host does
312            // sometimes answer in JSON. When it does, the reason is filed
313            // under a key named `wpcom`, which misnames its origin — worth
314            // knowing before anyone reads that field as WordPress.com's.
315            return Rest_Controller::upstream_error(
316                $stream_response,
317                'backup_file_content_stream_failed',
318                __( 'Could not fetch file content.', 'jetpack-backup-pkg' )
319            );
320        }
321
322        $body = wp_remote_retrieve_body( $stream_response );
323
324        // A body at the cap is reported truncated even in the rare case where
325        // the file ends exactly there — the transport cannot tell the two apart.
326        $truncated = strlen( $body ) >= self::PREVIEW_MAX_BYTES;
327        if ( $truncated ) {
328            $body = self::drop_partial_trailing_character( $body );
329        }
330
331        // Bytes that are not text must never go out as `content`: the REST
332        // server's `wp_json_encode()` sanity fallback re-encodes invalid UTF-8
333        // into `?`, so a 200 would hand the reader a corrupted file as if it
334        // were the real one.
335        $is_text = self::is_text( $body );
336
337        return rest_ensure_response(
338            array(
339                'content'   => $is_text ? $body : null,
340                'is_text'   => $is_text,
341                'truncated' => $truncated,
342            )
343        );
344    }
345
346    /**
347     * Fetch the one-file download link for the info card's "Download file" button.
348     *
349     * Resolved on click, never alongside the preview, so the link exists only
350     * for a reader who asked for it. Asks WPCOM for `disposition=attachment`
351     * so the browser saves the file instead of rendering it.
352     *
353     * @param WP_REST_Request $request The REST request.
354     * @return \WP_REST_Response|WP_Error
355     */
356    public static function get_file_download_url( WP_REST_Request $request ) {
357        $blog_id = Rest_Controller::get_blog_id_or_error();
358        if ( is_wp_error( $blog_id ) ) {
359            return $blog_id;
360        }
361
362        $signed_url = self::resolve_signed_url(
363            $blog_id,
364            (string) $request->get_param( 'file_period' ),
365            (string) $request->get_param( 'encoded_manifest_path' ),
366            array( 'disposition' => 'attachment' ),
367            'backup_file_download',
368            true
369        );
370        if ( is_wp_error( $signed_url ) ) {
371            return $signed_url;
372        }
373
374        return rest_ensure_response( array( 'url' => $signed_url ) );
375    }
376
377    /**
378     * Resolve the signed storage URL for one file version.
379     *
380     * @param int                  $blog_id               WPCOM blog id.
381     * @param string               $file_period           The file's own snapshot period.
382     * @param string               $encoded_manifest_path Base64 manifest path, already validated.
383     * @param array<string,string> $query                 Extra query args for the lookup.
384     * @param string               $code_prefix           Prefix of the error codes, so each route keeps its own.
385     * @param bool                 $https_only            Refuse any other scheme, for a link that goes to the browser.
386     * @return string|WP_Error The validated URL, or the error to return.
387     */
388    private static function resolve_signed_url( $blog_id, $file_period, $encoded_manifest_path, array $query = array(), $code_prefix = 'backup_file_content', $https_only = false ) {
389        // `$encoded_manifest_path` goes in verbatim. It is already
390        // base64, and WPCOM's stream route runs a plain
391        // `base64_decode()` on this segment, so percent-encoding it
392        // first is silently destructive: PHP's non-strict decoder
393        // discards the `%` and keeps the `3` and the `D`, both valid
394        // base64 characters. `ZjU6L3dwLWNvbmZpZy5waHA%3D` decodes to
395        // `f5:/wp-config.php7`, and VaultPress then correctly reports
396        // `File not found` for a file that is really there. Base64's
397        // `+`, `/` and `=` are all legal in a path segment, and the
398        // upstream route captures it as `\S+`, so nothing needs
399        // escaping. `$file_period` is digits, so its encoding is a
400        // no-op either way.
401        //
402        // Because nothing escapes it here, `BASE64_PATTERN` on the arg
403        // definition is the guard. That matters: cURL applies RFC 3986
404        // dot-segment removal before the request leaves the host, so an
405        // unconstrained value containing `../` would climb out of this
406        // route — with a `?` swallowing the trailing `/url` — and turn
407        // a file proxy into an arbitrary authenticated WPCOM GET. The
408        // base64 alphabet contains neither `%` nor `.`, so the pattern
409        // closes that off without re-breaking the preview.
410        $url_response = Client::wpcom_json_api_request_as_user(
411            sprintf(
412                '/sites/%d/rewind/backup/%s/file/%s/url%s',
413                $blog_id,
414                rawurlencode( $file_period ),
415                $encoded_manifest_path,
416                $query ? '?' . http_build_query( $query ) : ''
417            ),
418            'v2',
419            array(),
420            null,
421            'wpcom'
422        );
423
424        if ( is_wp_error( $url_response ) ) {
425            return Rest_Controller::transport_error( $url_response, $code_prefix . '_url_failed' );
426        }
427
428        // Cast because `wp_remote_retrieve_response_code()` hands back
429        // whatever the transport put there, and a numeric string fails the
430        // strict comparison below — routing a perfectly good response into
431        // the failure branch, where `upstream_error()`'s clamp then reports
432        // it as a 500. Same reasoning at every bridge; the long version is
433        // on `Rest_Controller::upstream_error()`.
434        $url_status = (int) wp_remote_retrieve_response_code( $url_response );
435        if ( 200 !== $url_status ) {
436            return Rest_Controller::upstream_error(
437                $url_response,
438                $code_prefix . '_url_failed',
439                __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' )
440            );
441        }
442
443        $url_body   = json_decode( wp_remote_retrieve_body( $url_response ), true );
444        $signed_url = is_array( $url_body ) && isset( $url_body['url'] ) ? $url_body['url'] : null;
445        if ( ! $signed_url || ! wp_http_validate_url( $signed_url ) || ( $https_only && 'https' !== wp_parse_url( $signed_url, PHP_URL_SCHEME ) ) ) {
446            // Defense-in-depth: WPCOM is supposed to hand back an HTTPS
447            // URL, but a regression that returned `file://…` or another
448            // scheme would otherwise reach `wp_remote_get`, or the browser.
449            return new WP_Error(
450                $code_prefix . '_url_missing',
451                __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ),
452                array( 'status' => 502 )
453            );
454        }
455
456        return $signed_url;
457    }
458
459    /**
460     * Whether a fetched body can be shown in a text preview.
461     *
462     * A NUL byte is valid UTF-8 but does not occur in text, so rejecting it
463     * also catches UTF-16 and the binaries that happen to decode cleanly.
464     *
465     * @param string $body The fetched body.
466     * @return bool
467     */
468    private static function is_text( $body ) {
469        if ( false !== strpos( $body, "\0" ) ) {
470            return false;
471        }
472        // A `//u` pattern fails to match on exactly the bytes `json_encode()`
473        // rejects, and unlike mbstring, PCRE cannot be absent from a host.
474        return 1 === preg_match( '//u', $body );
475    }
476
477    /**
478     * Drop a character the byte cap cut in half.
479     *
480     * The transport counts bytes, so the cut can land inside a multi-byte
481     * sequence and leave a text file looking like binary. A UTF-8 character
482     * is at most four bytes, so at most three can be left over.
483     *
484     * @param string $body The capped body.
485     * @return string
486     */
487    private static function drop_partial_trailing_character( $body ) {
488        for ( $dropped = 0; $dropped < 3 && '' !== $body; $dropped++ ) {
489            if ( self::is_text( $body ) ) {
490                break;
491            }
492            $body = substr( $body, 0, -1 );
493        }
494        return $body;
495    }
496
497    /**
498     * Shared response forwarder for the bridges that just pass through
499     * WPCOM JSON. Wraps transport failures and non-200 responses alike
500     * with bridge-level error codes the front-end branches on, so cURL's
501     * own text never reaches the reader.
502     *
503     * Both wrappers keep what WordPress.com actually said one level down,
504     * under `transport` and `wpcom` respectively, so `$message` names the
505     * operation and the reason survives beside it rather than replacing
506     * it. The client frames the two together when the reason is one only
507     * a sentence can carry.
508     *
509     * @param array|\WP_Error $response The wp_remote_* response.
510     * @param string          $code     Error code for a transport failure or a non-200.
511     * @param string          $message  Translated error message for a non-200.
512     * @return \WP_REST_Response|WP_Error
513     */
514    private static function forward_response( $response, $code, $message ) {
515        if ( is_wp_error( $response ) ) {
516            return Rest_Controller::transport_error( $response, $code );
517        }
518        // Cast, as at every other bridge — see `get_file_content()` above.
519        $status_code = (int) wp_remote_retrieve_response_code( $response );
520        if ( 200 !== $status_code ) {
521            return Rest_Controller::upstream_error( $response, $code, $message );
522        }
523        return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) );
524    }
525}