Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.74% covered (warning)
61.74%
71 / 115
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
File_Browser_Bridge
62.83% covered (warning)
62.83%
71 / 113
25.00% covered (danger)
25.00%
1 / 4
46.85
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
38 / 38
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_file_content
35.29% covered (danger)
35.29%
18 / 51
0.00% covered (danger)
0.00%
0 / 1
67.10
 forward_response
20.00% covered (danger)
20.00%
2 / 10
0.00% covered (danger)
0.00%
0 / 1
17.80
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 *
24 * NOTE: WPCOM's `/sites/{id}/rewind/backup/path-info` endpoint was
25 * removed from this bridge in 2026-05. It returned "No file found"
26 * for every input variant we could synthesize and there's no working
27 * caller of it elsewhere in the monorepo; the React layer derives the
28 * `lastModified` field from `/ls`'s `period` and the mime type from
29 * the file extension instead.
30 */
31class File_Browser_Bridge {
32
33    /**
34     * Cap on text-preview size (bytes). 64 KB is plenty for wp-config.php,
35     * theme style.css, small SQL dumps.
36     */
37    const PREVIEW_MAX_BYTES = 64 * 1024;
38
39    /**
40     * Register routes.
41     *
42     * @return void
43     */
44    public static function register_routes() {
45        register_rest_route(
46            'jetpack/v4',
47            '/rewind/backup/ls',
48            array(
49                'methods'             => WP_REST_Server::CREATABLE,
50                'callback'            => array( __CLASS__, 'list_directory' ),
51                'permission_callback' => array( Rest_Controller::class, 'permission_check' ),
52                'args'                => array(
53                    'rewind_id' => array(
54                        'type'     => 'string',
55                        'required' => true,
56                    ),
57                    'path'      => array(
58                        'type'     => 'string',
59                        'required' => true,
60                    ),
61                ),
62            )
63        );
64
65        register_rest_route(
66            'jetpack/v4',
67            '/rewind/backup/file-content',
68            array(
69                'methods'             => WP_REST_Server::READABLE,
70                'callback'            => array( __CLASS__, 'get_file_content' ),
71                'permission_callback' => array( Rest_Controller::class, 'permission_check' ),
72                'args'                => array(
73                    'file_period'           => array(
74                        'type'     => 'string',
75                        'required' => true,
76                    ),
77                    'encoded_manifest_path' => array(
78                        'type'     => 'string',
79                        'required' => true,
80                    ),
81                ),
82            )
83        );
84    }
85
86    /**
87     * List folder children. Proxies POST wpcom/v2 /sites/{id}/rewind/backup/ls.
88     *
89     * @param WP_REST_Request $request The REST request.
90     * @return \WP_REST_Response|WP_Error
91     */
92    public static function list_directory( WP_REST_Request $request ) {
93        $blog_id = Rest_Controller::get_blog_id_or_error();
94        if ( is_wp_error( $blog_id ) ) {
95            return $blog_id;
96        }
97
98        $response = Client::wpcom_json_api_request_as_user(
99            sprintf( '/sites/%d/rewind/backup/ls', $blog_id ),
100            'v2',
101            array( 'method' => 'POST' ),
102            array(
103                'backup_id' => $request->get_param( 'rewind_id' ),
104                'path'      => $request->get_param( 'path' ),
105            ),
106            'wpcom'
107        );
108
109        return self::forward_response( $response, 'backup_ls_fetch_failed', __( 'Could not list backup contents.', 'jetpack-backup-pkg' ) );
110    }
111
112    /**
113     * Fetch a text file's content for the preview pane.
114     *
115     * Resolves the one-time signed URL from WPCOM, then fetches the
116     * stream server-side and caps the body at PREVIEW_MAX_BYTES.
117     * WPCOM's signed-URL stream endpoint doesn't send CORS headers, so
118     * the browser can't fetch it directly.
119     *
120     * VaultPress stores file content per the file's own snapshot
121     * `period` — the timestamp when the file last changed — not by the
122     * parent backup's rewindId. Files don't get re-snapshotted every
123     * backup, so the `{period}` URL segment below is the per-entry
124     * `period` from `/ls`, never the rewindId of the backup the user
125     * is browsing. Sending the rewindId instead silently produces a
126     * signed URL for a non-existent storage location and 400s with
127     * "File not found" at stream time.
128     *
129     * @param WP_REST_Request $request The REST request.
130     * @return \WP_REST_Response|WP_Error
131     */
132    public static function get_file_content( WP_REST_Request $request ) {
133        $blog_id = Rest_Controller::get_blog_id_or_error();
134        if ( is_wp_error( $blog_id ) ) {
135            return $blog_id;
136        }
137        $file_period           = (string) $request->get_param( 'file_period' );
138        $encoded_manifest_path = (string) $request->get_param( 'encoded_manifest_path' );
139
140        // Step 1: resolve the signed stream URL.
141        $url_response = Client::wpcom_json_api_request_as_user(
142            sprintf(
143                '/sites/%d/rewind/backup/%s/file/%s/url',
144                $blog_id,
145                rawurlencode( $file_period ),
146                rawurlencode( $encoded_manifest_path )
147            ),
148            'v2',
149            array(),
150            null,
151            'wpcom'
152        );
153
154        if ( is_wp_error( $url_response ) ) {
155            return Rest_Controller::transport_error( $url_response, 'backup_file_content_url_failed' );
156        }
157
158        $url_status = wp_remote_retrieve_response_code( $url_response );
159        if ( 200 !== $url_status ) {
160            return new WP_Error(
161                'backup_file_content_url_failed',
162                __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ),
163                array( 'status' => is_int( $url_status ) && $url_status > 0 ? $url_status : 500 )
164            );
165        }
166
167        $url_body   = json_decode( wp_remote_retrieve_body( $url_response ), true );
168        $signed_url = is_array( $url_body ) && isset( $url_body['url'] ) ? $url_body['url'] : null;
169        if ( ! $signed_url || ! wp_http_validate_url( $signed_url ) ) {
170            // Defense-in-depth: WPCOM is supposed to hand back an HTTPS
171            // URL, but a regression that returned `file://…` or another
172            // scheme would otherwise reach `wp_remote_get` below.
173            return new WP_Error(
174                'backup_file_content_url_missing',
175                __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ),
176                array( 'status' => 502 )
177            );
178        }
179
180        // Step 2: fetch the stream body server-side.
181        //
182        // `limit_response_size` caps the body at the HTTP-transport
183        // layer so a multi-GB blob can't be buffered into PHP memory
184        // before truncation. The bridge enforces no mime check at
185        // all — the React layer's allowlist is advisory only — so any
186        // admin can address any blob the WPCOM signer is willing to
187        // hand a URL for.
188        $stream_response = wp_remote_get(
189            $signed_url,
190            array(
191                'timeout'             => 15,
192                'limit_response_size' => self::PREVIEW_MAX_BYTES,
193            )
194        );
195
196        if ( is_wp_error( $stream_response ) ) {
197            return Rest_Controller::transport_error( $stream_response, 'backup_file_content_stream_failed' );
198        }
199
200        $stream_status = wp_remote_retrieve_response_code( $stream_response );
201        if ( 200 !== $stream_status ) {
202            return new WP_Error(
203                'backup_file_content_stream_failed',
204                __( 'Could not fetch file content.', 'jetpack-backup-pkg' ),
205                array( 'status' => is_int( $stream_status ) && $stream_status > 0 ? $stream_status : 500 )
206            );
207        }
208
209        return rest_ensure_response( array( 'content' => wp_remote_retrieve_body( $stream_response ) ) );
210    }
211
212    /**
213     * Shared response forwarder for the bridges that just pass through
214     * WPCOM JSON. Wraps transport failures and non-200 responses alike
215     * with bridge-level error codes the front-end branches on, so cURL's
216     * own text never reaches the reader.
217     *
218     * @param array|\WP_Error $response The wp_remote_* response.
219     * @param string          $code     Error code for a transport failure or a non-200.
220     * @param string          $message  Translated error message for a non-200.
221     * @return \WP_REST_Response|WP_Error
222     */
223    private static function forward_response( $response, $code, $message ) {
224        if ( is_wp_error( $response ) ) {
225            return Rest_Controller::transport_error( $response, $code );
226        }
227        $status_code = wp_remote_retrieve_response_code( $response );
228        if ( 200 !== $status_code ) {
229            return new WP_Error(
230                $code,
231                $message,
232                array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 )
233            );
234        }
235        return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) );
236    }
237}