Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.86% covered (success)
91.86%
79 / 86
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Rest_Controller
92.86% covered (success)
92.86%
78 / 84
77.78% covered (warning)
77.78%
7 / 9
39.55
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 permission_check
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 get_blog_id_or_error
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
2.98
 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
 upstream_error
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 upstream_reason
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
15
 clip_reason
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
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 `is_modernized()` is true, so the
26 * legacy plugin is byte-identical when it is false.
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 `is_modernized()` is true.
68     *
69     * @return void
70     */
71    public static function register_routes() {
72        if ( ! Jetpack_Backup::is_modernized() ) {
73            return;
74        }
75
76        Capabilities_Bridge::register_routes();
77        Activity_Log_Bridge::register_routes();
78        Backup_Sizes_Bridge::register_routes();
79        File_Browser_Bridge::register_routes();
80        Download_Bridge::register_routes();
81        Restore_Bridge::register_routes();
82        Schedule_Bridge::register_routes();
83        Retention_Bridge::register_routes();
84    }
85
86    /**
87     * Permission check shared by every modernized-dashboard route.
88     *
89     * Mirrors the activity-log package's pattern: `manage_options` is
90     * necessary but not sufficient — every bridge eventually proxies a
91     * WPCOM endpoint that's user-gated, so a site admin who isn't
92     * personally WPCOM-linked needs a clearer error than the opaque
93     * "Only Administrators can query…" WPCOM returns.
94     *
95     * @return bool|WP_Error True when the current user can call the bridges, WP_Error otherwise.
96     */
97    public static function permission_check() {
98        if ( ! current_user_can( 'manage_options' ) ) {
99            return false;
100        }
101
102        $offline_mode_error = Jetpack_Backup::get_offline_mode_error();
103        if ( $offline_mode_error ) {
104            return $offline_mode_error;
105        }
106
107        if ( ! ( new Connection_Manager() )->is_user_connected() ) {
108            return new WP_Error(
109                'user_not_connected',
110                __( 'Your WordPress.com account is not connected to this site.', 'jetpack-backup-pkg' ),
111                array( 'status' => 403 )
112            );
113        }
114
115        return true;
116    }
117
118    /**
119     * Returns the site's WPCOM blog id, or a `not_connected` WP_Error
120     * when the site hasn't been registered yet. Shared across the bridges
121     * so the `sprintf( '/sites/%d/…', $blog_id )` upstream path is never
122     * built with an empty id.
123     *
124     * @return int|WP_Error Blog id, or WP_Error when not connected.
125     */
126    public static function get_blog_id_or_error() {
127        $blog_id = (int) Jetpack_Options::get_option( 'id' );
128        if ( ! $blog_id ) {
129            return new WP_Error(
130                'not_connected',
131                __( 'This site is not connected to Jetpack.', 'jetpack-backup-pkg' ),
132                array( 'status' => 412 )
133            );
134        }
135        return $blog_id;
136    }
137
138    /**
139     * Rebuild a restore/download `types` parameter as a named map.
140     *
141     * The PHP counterpart of the client's `requireTypes`, and the reason
142     * it exists here rather than being trusted from the request: WordPress
143     * validates `'type' => 'object'` with `rest_is_object()`, which is
144     * `is_array()`. A JSON list therefore passes validation and arrives as
145     * a PHP list, whose numeric keys WPCOM reads as category names. The
146     * route schema rejects the realistic version of that, but only because
147     * the members fail a boolean check — shape itself is never asserted —
148     * so the guarantee is made here, where the payload is actually built.
149     *
150     * Only known categories with a truthy value survive, and every
151     * surviving value is normalized to `true`. Values are read with
152     * `rest_sanitize_boolean()` so a form-encoded `"false"` or `"0"` means
153     * skip rather than select.
154     *
155     * Unknown keys are dropped rather than forwarded, which is what makes
156     * `request_names_no_types()` a total guard: without it a payload naming
157     * only categories WPCOM does not recognize would satisfy the guard and
158     * be sent on, and what WPCOM does with a `types` that matches nothing
159     * is not characterized. Dropping them means such a payload names
160     * nothing, and is refused. The realistic way to get there is not an
161     * attacker — an admin who can craft the request can already omit
162     * `types` for a whole-site operation — but a future client-side typo:
163     * renaming a checklist key `sqls` to `sql` would otherwise go through
164     * silently.
165     *
166     * @param mixed $types Raw `types` parameter from the request.
167     * @return array<string, true> Named types, empty when none are selected.
168     */
169    public static function named_types( $types ) {
170        if ( ! is_array( $types ) && ! is_object( $types ) ) {
171            return array();
172        }
173
174        $named = array();
175        foreach ( (array) $types as $key => $value ) {
176            if ( in_array( $key, self::CATEGORIES, true ) && rest_sanitize_boolean( $value ) ) {
177                $named[ $key ] = true;
178            }
179        }
180        return $named;
181    }
182
183    /**
184     * Whether the request supplied a `types` parameter that names no category.
185     *
186     * The distinction this draws is the whole point of the helper, and it
187     * is the opposite of what it looks like. An **absent** `types` is a
188     * valid, deliberate request for every category — WPCOM's contract is
189     * "omit it for everything" — so the mutations leave the key out for a
190     * whole-site restore or a full archive. A **supplied** `types` that
191     * survives into nothing is the other thing entirely: the caller tried
192     * to name categories and named none, and forwarding that as an
193     * omission would quietly upgrade "restore nothing" into "restore
194     * everything", against a live site.
195     *
196     * It takes the request rather than the value because the value cannot
197     * answer the question. `{"types": null}` is supplied and names nothing,
198     * but arrives as the same `null` an omitted key does — and the schema
199     * never sees it, since `WP_REST_Request::has_valid_params()` skips
200     * `validate_callback` for a null param. `has_param()` is the only thing
201     * that knows the key was on the wire.
202     *
203     * Nothing upstream catches it on both routes. The v2 restore route
204     * rejects a `types` naming nothing, but `/rewind/downloads` does not,
205     * so the guarantee has to be made here for the pair to behave alike.
206     *
207     * @param WP_REST_Request $request The REST request.
208     * @return bool True when the caller supplied a `types` that names no category.
209     */
210    public static function request_names_no_types( WP_REST_Request $request ) {
211        if ( ! $request->has_param( 'types' ) ) {
212            return false;
213        }
214
215        return ! self::named_types( $request->get_param( 'types' ) );
216    }
217
218    /**
219     * Convert a transport-level failure into a bridge error.
220     *
221     * `Client::wpcom_json_api_request_as_*` answers with a `WP_Error` when
222     * the request never reached WPCOM at all — DNS, TLS, or the cURL
223     * timeout behind JETPACK-2173's "cURL error 28". Returning that error
224     * unchanged hands cURL's own text to the browser, where the dashboard
225     * renders the message verbatim in a notice; it also carries no
226     * `status`, so core answers 500 for what is really a reachability
227     * problem rather than a server fault.
228     *
229     * The raw text is preserved under `transport` rather than discarded.
230     * It is the only part a support agent can act on, and it is the same
231     * reason the non-200 branches forward WPCOM's status instead of
232     * flattening it.
233     *
234     * Always 502: telling a timeout from a refused connection would mean
235     * matching on cURL's English message text, and no caller reads the
236     * difference.
237     *
238     * @param WP_Error $error Transport error from the HTTP client.
239     * @param string   $code  Bridge error code for the operation that failed.
240     * @return WP_Error
241     */
242    public static function transport_error( WP_Error $error, $code ) {
243        return new WP_Error(
244            $code,
245            __( 'Could not reach WordPress.com. Check your connection and try again.', 'jetpack-backup-pkg' ),
246            array(
247                'status'    => 502,
248                'transport' => array(
249                    'code'    => $error->get_error_code(),
250                    'message' => $error->get_error_message(),
251                ),
252            )
253        );
254    }
255
256    /**
257     * Longest either half of a forwarded upstream reason may be.
258     *
259     * Neither field is bounded upstream and both travel to the browser on
260     * every failed request, so a VaultPress stack trace in `message` would
261     * otherwise be copied out verbatim.
262     *
263     * @var int
264     */
265    private const REASON_MAX_LENGTH = 200;
266
267    /**
268     * Convert a non-200 answer from WordPress.com into a bridge error.
269     *
270     * The counterpart of `transport_error()`, for the failure where the
271     * request did arrive and WordPress.com refused it. Every bridge used to
272     * spell this branch out for itself, and every spelling threw away the
273     * only part that says *why*: a plan problem and an expired token both
274     * reached a support agent as `restore_initiate_failed` / "Could not
275     * start the backup restore.", distinguishable only by a status code.
276     *
277     * WordPress.com's own reason is preserved under `wpcom`, deliberately
278     * shaped like `transport_error()`'s `transport` key. The client decides
279     * what to do with it: `failureMessage()` in `_helpers.ts` maps the
280     * codes whose meaning is the code itself, and *renders* the message for
281     * the ones — `rewind_error`, `authorization_required` — where one code
282     * spans several unrelated situations and only the sentence tells them
283     * apart.
284     *
285     * That the message can reach a reader is why the flattening and the
286     * 200-character clip below are not housekeeping. They are the whole
287     * reason it is safe to render, so do not relax them. It stays a plain
288     * string all the way out; the client escapes it by rendering it as
289     * React children.
290     *
291     * Only those two fields are forwarded, never the body — an error body
292     * is unbounded and can echo the request that produced it.
293     *
294     * The status is clamped to the failure range rather than merely tested
295     * for truthiness, and that is load-bearing. The retrieval helper hands
296     * back whatever the transport put there, and its callers must cast
297     * before comparing — a numeric-string `'200'` fails `200 !==
298     * $status_code`, which routed a perfectly good response into the
299     * failure branch. Every status comparison in this package casts now —
300     * bridges and legacy routes alike — so this function should no longer
301     * be reachable with a success code; the clamp stays because "should
302     * not" is not "cannot", and the cost of being wrong is one-sided.
303     * Forwarding a 200 would set `data.status` to 200, WordPress would
304     * serve the error envelope as HTTP 200, `apiFetch` would resolve
305     * instead of rejecting, and `apiCall()` would never throw — so a failed
306     * restore would run the mutation's `onSuccess` and report a restore
307     * that never started. A visible failure becoming an invisible false
308     * success is the worst outcome available on a destructive operation, so
309     * anything outside 4xx/5xx becomes a 500.
310     *
311     * The same clamp is what keeps junk out. `(int)` is a total function:
312     * `'2 Bad'` is 2, `3.7` is 3, `true` is 1, and a zero must never reach
313     * a `WP_Error` at all, because core hands the status to
314     * `status_header()` and a zero there emits an invalid status line.
315     *
316     * What the cast does buy, and the reason it is here rather than an
317     * `is_int()` test, is that a genuine `'404'` from a transport that
318     * reports statuses as strings now travels as 404 instead of being
319     * flattened to 500. The client is literal about the type too:
320     * `isAmbiguousFailure()` only reads a status that is already a number.
321     *
322     * @param array|\WP_Error $response The wp_remote_* response. Non-200 by the time it gets here.
323     * @param string          $code     Bridge error code for the operation that failed.
324     * @param string          $message  Translated message for the reader.
325     * @return WP_Error
326     */
327    public static function upstream_error( $response, $code, $message ) {
328        $status_code = (int) wp_remote_retrieve_response_code( $response );
329
330        $data = array( 'status' => $status_code >= 400 && $status_code <= 599 ? $status_code : 500 );
331
332        $reason = self::upstream_reason( wp_remote_retrieve_body( $response ) );
333        if ( ! empty( $reason ) ) {
334            $data['wpcom'] = $reason;
335        }
336
337        return new WP_Error( $code, $message, $data );
338    }
339
340    /**
341     * Read WordPress.com's own reason out of a response body.
342     *
343     * Three shapes have to be read here and they disagree about where the
344     * reason lives. A wpcom/v2 route serializes a `WP_Error` as `{ code,
345     * message, data }`. The older v1 envelope names that same token `error`
346     * and keeps prose beside it in `message`. And the restore endpoint's
347     * own `{ ok: false, error }` body puts VaultPress's sentence directly
348     * in `error`, with no token anywhere.
349     *
350     * So `error` is sometimes a token and sometimes a sentence, and the
351     * only thing separating them is shape: a `WP_Error` code has no
352     * whitespace in it, and a VaultPress refusal is a sentence. Sorting on
353     * that is what keeps the two halves honest. The client matches `code`
354     * against a list of codes it knows, so a sentence landing there would
355     * become a key that can never match — the reason lost again, in a
356     * quieter way.
357     *
358     * Sorting is all it does, though: nothing is ever discarded. When
359     * `error` holds a sentence *and* `message` holds another — which is
360     * what a VaultPress refusal wrapped in a generic envelope looks like —
361     * both are kept, `error` first, because that is the specific half. An
362     * earlier revision promoted `error` only when `message` was empty, and
363     * so threw away the specific reason in exactly the shape this function
364     * exists to read.
365     *
366     * `/u` on the whitespace test is not cosmetic. Without it `\s` is
367     * ASCII-only, so a sentence spaced with U+00A0 has "no whitespace" and
368     * lands in `code` — the precise outcome the sort is here to prevent.
369     *
370     * @param string|array $body Raw response body, or one already decoded.
371     * @return array<string, string> `code` and/or `message`; empty when the body names no reason.
372     */
373    public static function upstream_reason( $body ) {
374        $decoded = is_array( $body ) ? $body : json_decode( (string) $body, true );
375        if ( ! is_array( $decoded ) ) {
376            return array();
377        }
378
379        $token = '';
380        foreach ( array( 'code', 'error' ) as $key ) {
381            if ( isset( $decoded[ $key ] ) && is_string( $decoded[ $key ] ) && '' !== trim( $decoded[ $key ] ) ) {
382                $token = trim( $decoded[ $key ] );
383                break;
384            }
385        }
386
387        $prose = isset( $decoded['message'] ) && is_string( $decoded['message'] ) ? trim( $decoded['message'] ) : '';
388
389        $reason    = array();
390        $sentences = array();
391
392        if ( '' !== $token && ! preg_match( '/\s/u', $token ) ) {
393            $reason['code'] = self::clip_reason( $token );
394        } elseif ( '' !== $token ) {
395            $sentences[] = $token;
396        }
397
398        // Guarded against the duplicate rather than assumed away: some
399        // envelopes repeat the same text in both keys, and joining it to
400        // itself would say everything twice inside a budget meant for one.
401        if ( '' !== $prose && ! in_array( $prose, $sentences, true ) ) {
402            $sentences[] = $prose;
403        }
404
405        if ( ! empty( $sentences ) ) {
406            $reason['message'] = self::clip_reason( implode( ' ', $sentences ) );
407        }
408
409        return $reason;
410    }
411
412    /**
413     * Flatten and shorten one half of an upstream reason.
414     *
415     * Newlines go first so a multi-line upstream message cannot spend the
416     * whole budget on indentation before it says anything.
417     *
418     * `mb_substr()` rather than `substr()`, and the reason is mostly not
419     * the exotic one. A byte-wise cut spends the budget in bytes, so a
420     * reason written in a script that costs three bytes a character keeps
421     * a third of what it was allotted — 67 characters of 200, in the test
422     * that pins this. The encoding damage is the smaller half: the cut
423     * lands inside a character and leaves the field invalid UTF-8, which
424     * `wp_json_encode()` does not reject — its sanity check silently
425     * rewrites the broken bytes, so the reason arrives with a `?` on the
426     * end and nothing anywhere says why.
427     *
428     * The flatten runs in Unicode mode so a non-breaking or ideographic
429     * space collapses like any other, which also means it returns null on
430     * invalid UTF-8. The ASCII pass stands behind it so the bound is still
431     * enforced in that case. Unreachable in practice — every string that
432     * gets here came out of a `json_decode()`, which refuses invalid
433     * UTF-8 outright — but a silent null would turn the whole reason into
434     * an empty string, which is a poor way to find out.
435     *
436     * @param string $value Raw upstream text.
437     * @return string
438     */
439    private static function clip_reason( $value ) {
440        $flattened = preg_replace( '/\s+/u', ' ', $value );
441        if ( null === $flattened ) {
442            $flattened = preg_replace( '/\s+/', ' ', $value );
443        }
444
445        $value = trim( (string) $flattened );
446
447        if ( mb_strlen( $value, 'UTF-8' ) <= self::REASON_MAX_LENGTH ) {
448            return $value;
449        }
450
451        return rtrim( mb_substr( $value, 0, self::REASON_MAX_LENGTH, 'UTF-8' ) ) . '…';
452    }
453}