Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.57% covered (warning)
59.57%
28 / 47
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Rest_Controller
62.22% covered (warning)
62.22%
28 / 45
66.67% covered (warning)
66.67%
4 / 6
29.80
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 permission_check
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 get_blog_id_or_error
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 named_types
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 request_names_no_types
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 transport_error
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * REST controller for the modernized Backup dashboard.
4 *
5 * @package automattic/jetpack-backup-plugin
6 */
7
8namespace Automattic\Jetpack\Backup\V0005\REST;
9
10use Automattic\Jetpack\Backup\V0005\Jetpack_Backup;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Jetpack_Options;
13use WP_Error;
14use WP_REST_Request;
15
16if ( ! defined( 'ABSPATH' ) ) {
17    exit( 0 );
18}
19
20/**
21 * Registers REST routes that back the modernized dashboard.
22 *
23 * Each bridge class declares its routes via `register_routes()` and uses
24 * the shared `permission_check()` helper for the `manage_options` gate.
25 * Routes only register when the modernization filter is on, so the
26 * legacy plugin is byte-identical when the flag is off.
27 */
28class Rest_Controller {
29
30    /**
31     * Every category WPCOM's rewind endpoints recognize in a `types` map.
32     *
33     * The first six are the whole-site checklist the Restore and Download
34     * screens render.
35     *
36     * `paths` covers the granular *download* shape only — `types: { paths:
37     * true }`, paired with `include_path_list` / `exclude_path_list`.
38     * Nothing sends it yet (C3), but leaving it out would make the file
39     * browser's granular download fail closed with a confusing 400 the day
40     * it is wired up.
41     *
42     * It does not carry over to granular *restore*, whatever that ends up
43     * spelling: the v1 route took `types: 'paths'` as a bare string, which
44     * is not a map at all and would never reach this allowlist. Granular
45     * restore has to establish its own shape against the v2 route before
46     * anything here can claim to cover it.
47     *
48     * This list has to grow if VaultPress adds a category. WPCOM's own
49     * route deliberately does not allowlist, so that it stays open to new
50     * types; we can afford to be stricter because we also own the UI that
51     * produces the values, and here a value that names nothing is the
52     * dangerous case rather than merely a useless one.
53     *
54     * @var string[]
55     */
56    private const CATEGORIES = array(
57        'themes',
58        'plugins',
59        'roots',
60        'contents',
61        'sqls',
62        'uploads',
63        'paths',
64    );
65
66    /**
67     * Hook entry point. Registers all bridge routes if the modernization
68     * filter is enabled.
69     *
70     * @return void
71     */
72    public static function register_routes() {
73        if ( ! Jetpack_Backup::is_modernized() ) {
74            return;
75        }
76
77        Capabilities_Bridge::register_routes();
78        Activity_Log_Bridge::register_routes();
79        File_Browser_Bridge::register_routes();
80        Download_Bridge::register_routes();
81        Restore_Bridge::register_routes();
82    }
83
84    /**
85     * Permission check shared by every modernized-dashboard route.
86     *
87     * Mirrors the activity-log package's pattern: `manage_options` is
88     * necessary but not sufficient — every bridge eventually proxies a
89     * WPCOM endpoint that's user-gated, so a site admin who isn't
90     * personally WPCOM-linked needs a clearer error than the opaque
91     * "Only Administrators can query…" WPCOM returns.
92     *
93     * @return bool|WP_Error True when the current user can call the bridges, WP_Error otherwise.
94     */
95    public static function permission_check() {
96        if ( ! current_user_can( 'manage_options' ) ) {
97            return false;
98        }
99
100        if ( ! ( new Connection_Manager() )->is_user_connected() ) {
101            return new WP_Error(
102                'user_not_connected',
103                __( 'Your WordPress.com account is not connected to this site.', 'jetpack-backup-pkg' ),
104                array( 'status' => 403 )
105            );
106        }
107
108        return true;
109    }
110
111    /**
112     * Returns the site's WPCOM blog id, or a `not_connected` WP_Error
113     * when the site hasn't been registered yet. Shared across the bridges
114     * so the `sprintf( '/sites/%d/…', $blog_id )` upstream path is never
115     * built with an empty id.
116     *
117     * @return int|WP_Error Blog id, or WP_Error when not connected.
118     */
119    public static function get_blog_id_or_error() {
120        $blog_id = (int) Jetpack_Options::get_option( 'id' );
121        if ( ! $blog_id ) {
122            return new WP_Error(
123                'not_connected',
124                __( 'This site is not connected to Jetpack.', 'jetpack-backup-pkg' ),
125                array( 'status' => 412 )
126            );
127        }
128        return $blog_id;
129    }
130
131    /**
132     * Rebuild a restore/download `types` parameter as a named map.
133     *
134     * The PHP counterpart of the client's `requireTypes`, and the reason
135     * it exists here rather than being trusted from the request: WordPress
136     * validates `'type' => 'object'` with `rest_is_object()`, which is
137     * `is_array()`. A JSON list therefore passes validation and arrives as
138     * a PHP list, whose numeric keys WPCOM reads as category names. The
139     * route schema rejects the realistic version of that, but only because
140     * the members fail a boolean check — shape itself is never asserted —
141     * so the guarantee is made here, where the payload is actually built.
142     *
143     * Only known categories with a truthy value survive, and every
144     * surviving value is normalized to `true`. Values are read with
145     * `rest_sanitize_boolean()` so a form-encoded `"false"` or `"0"` means
146     * skip rather than select.
147     *
148     * Unknown keys are dropped rather than forwarded, which is what makes
149     * `request_names_no_types()` a total guard: without it a payload naming
150     * only categories WPCOM does not recognize would satisfy the guard and
151     * be sent on, and what WPCOM does with a `types` that matches nothing
152     * is not characterized. Dropping them means such a payload names
153     * nothing, and is refused. The realistic way to get there is not an
154     * attacker — an admin who can craft the request can already omit
155     * `types` for a whole-site operation — but a future client-side typo:
156     * renaming a checklist key `sqls` to `sql` would otherwise go through
157     * silently.
158     *
159     * @param mixed $types Raw `types` parameter from the request.
160     * @return array<string, true> Named types, empty when none are selected.
161     */
162    public static function named_types( $types ) {
163        if ( ! is_array( $types ) && ! is_object( $types ) ) {
164            return array();
165        }
166
167        $named = array();
168        foreach ( (array) $types as $key => $value ) {
169            if ( in_array( $key, self::CATEGORIES, true ) && rest_sanitize_boolean( $value ) ) {
170                $named[ $key ] = true;
171            }
172        }
173        return $named;
174    }
175
176    /**
177     * Whether the request supplied a `types` parameter that names no category.
178     *
179     * The distinction this draws is the whole point of the helper, and it
180     * is the opposite of what it looks like. An **absent** `types` is a
181     * valid, deliberate request for every category — WPCOM's contract is
182     * "omit it for everything" — so the mutations leave the key out for a
183     * whole-site restore or a full archive. A **supplied** `types` that
184     * survives into nothing is the other thing entirely: the caller tried
185     * to name categories and named none, and forwarding that as an
186     * omission would quietly upgrade "restore nothing" into "restore
187     * everything", against a live site.
188     *
189     * It takes the request rather than the value because the value cannot
190     * answer the question. `{"types": null}` is supplied and names nothing,
191     * but arrives as the same `null` an omitted key does — and the schema
192     * never sees it, since `WP_REST_Request::has_valid_params()` skips
193     * `validate_callback` for a null param. `has_param()` is the only thing
194     * that knows the key was on the wire.
195     *
196     * Nothing upstream catches it on both routes. The v2 restore route
197     * rejects a `types` naming nothing, but `/rewind/downloads` does not,
198     * so the guarantee has to be made here for the pair to behave alike.
199     *
200     * @param WP_REST_Request $request The REST request.
201     * @return bool True when the caller supplied a `types` that names no category.
202     */
203    public static function request_names_no_types( WP_REST_Request $request ) {
204        if ( ! $request->has_param( 'types' ) ) {
205            return false;
206        }
207
208        return ! self::named_types( $request->get_param( 'types' ) );
209    }
210
211    /**
212     * Convert a transport-level failure into a bridge error.
213     *
214     * `Client::wpcom_json_api_request_as_*` answers with a `WP_Error` when
215     * the request never reached WPCOM at all — DNS, TLS, or the cURL
216     * timeout behind JETPACK-2173's "cURL error 28". Returning that error
217     * unchanged hands cURL's own text to the browser, where the dashboard
218     * renders the message verbatim in a notice; it also carries no
219     * `status`, so core answers 500 for what is really a reachability
220     * problem rather than a server fault.
221     *
222     * The raw text is preserved under `transport` rather than discarded.
223     * It is the only part a support agent can act on, and it is the same
224     * reason the non-200 branches forward WPCOM's status instead of
225     * flattening it.
226     *
227     * Always 502: telling a timeout from a refused connection would mean
228     * matching on cURL's English message text, and no caller reads the
229     * difference.
230     *
231     * @param WP_Error $error Transport error from the HTTP client.
232     * @param string   $code  Bridge error code for the operation that failed.
233     * @return WP_Error
234     */
235    public static function transport_error( WP_Error $error, $code ) {
236        return new WP_Error(
237            $code,
238            __( 'Could not reach WordPress.com. Check your connection and try again.', 'jetpack-backup-pkg' ),
239            array(
240                'status'    => 502,
241                'transport' => array(
242                    'code'    => $error->get_error_code(),
243                    'message' => $error->get_error_message(),
244                ),
245            )
246        );
247    }
248}