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