PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
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 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-scan-page / src / class-rest-controller.php

class-rest-controller.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-scan-page/src/class-rest-controller.php

461 lines 13.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The Scan REST Controller.
4 *
5 * Registers the `/jetpack/v4/site/scan/*` routes backing the admin UI.
6 * Read routes proxy to the corresponding WPCOM v2 endpoint, mutation
7 * routes proxy to the user-scoped `wpcom/v2 /sites/:siteId/alerts/*`
8 * surface that the Calypso Scan dashboard already uses.
9 *
10 * @package automattic/jetpack-scan-page
11 */
12
13 namespace Automattic\Jetpack\Scan_Page;
14
15 use Automattic\Jetpack\Connection\Client;
16 use Automattic\Jetpack\Status\Visitor;
17 use Jetpack_Options;
18 use WP_Error;
19 use WP_REST_Request;
20 use WP_REST_Server;
21 use function add_query_arg;
22 use function current_user_can;
23 use function esc_html__;
24 use function is_wp_error;
25 use function json_decode;
26 use function register_rest_route;
27 use function rest_ensure_response;
28 use function wp_json_encode;
29 use function wp_remote_retrieve_body;
30 use function wp_remote_retrieve_response_code;
31
32 /**
33 * REST routes for the Scan UI.
34 */
35 class REST_Controller {
36
37 /**
38 * REST namespace used by this package.
39 *
40 * @var string
41 */
42 const REST_NAMESPACE = 'jetpack/v4';
43
44 /**
45 * REST route prefix used by this package.
46 *
47 * @var string
48 */
49 const REST_ROUTE_PREFIX = 'site/scan';
50
51 /**
52 * Register the REST routes backing the Scan UI.
53 *
54 * Read paths land in Phase 1 (this controller). Mutation paths
55 * (threat-id `ignore` / `unignore`, bulk `threats/fix`,
56 * `threats/fix-status`) land in Phase 3. The scan-enqueue mutation
57 * lands in Phase 5.
58 */
59 public static function register_rest_routes() {
60 register_rest_route(
61 self::REST_NAMESPACE,
62 '/' . self::REST_ROUTE_PREFIX,
63 array(
64 'methods' => WP_REST_Server::READABLE,
65 'callback' => array( __CLASS__, 'get_site_scan' ),
66 'permission_callback' => array( __CLASS__, 'permissions_check' ),
67 )
68 );
69
70 register_rest_route(
71 self::REST_NAMESPACE,
72 '/' . self::REST_ROUTE_PREFIX . '/history',
73 array(
74 'methods' => WP_REST_Server::READABLE,
75 'callback' => array( __CLASS__, 'get_site_scan_history' ),
76 'permission_callback' => array( __CLASS__, 'permissions_check' ),
77 )
78 );
79
80 register_rest_route(
81 self::REST_NAMESPACE,
82 '/' . self::REST_ROUTE_PREFIX . '/counts',
83 array(
84 'methods' => WP_REST_Server::READABLE,
85 'callback' => array( __CLASS__, 'get_site_scan_counts' ),
86 'permission_callback' => array( __CLASS__, 'permissions_check' ),
87 )
88 );
89
90 register_rest_route(
91 self::REST_NAMESPACE,
92 '/' . self::REST_ROUTE_PREFIX . '/threat/(?P<id>[\w\-]+)/ignore',
93 array(
94 'methods' => WP_REST_Server::CREATABLE,
95 'callback' => array( __CLASS__, 'post_threat_ignore' ),
96 'permission_callback' => array( __CLASS__, 'permissions_check' ),
97 'args' => array(
98 'id' => array(
99 'type' => 'string',
100 'required' => true,
101 'sanitize_callback' => 'sanitize_text_field',
102 ),
103 ),
104 )
105 );
106
107 register_rest_route(
108 self::REST_NAMESPACE,
109 '/' . self::REST_ROUTE_PREFIX . '/threat/(?P<id>[\w\-]+)/unignore',
110 array(
111 'methods' => WP_REST_Server::CREATABLE,
112 'callback' => array( __CLASS__, 'post_threat_unignore' ),
113 'permission_callback' => array( __CLASS__, 'permissions_check' ),
114 'args' => array(
115 'id' => array(
116 'type' => 'string',
117 'required' => true,
118 'sanitize_callback' => 'sanitize_text_field',
119 ),
120 ),
121 )
122 );
123
124 register_rest_route(
125 self::REST_NAMESPACE,
126 '/' . self::REST_ROUTE_PREFIX . '/threats/fix',
127 array(
128 'methods' => WP_REST_Server::CREATABLE,
129 'callback' => array( __CLASS__, 'post_threats_fix' ),
130 'permission_callback' => array( __CLASS__, 'permissions_check' ),
131 'args' => array(
132 'threat_ids' => array(
133 'type' => 'array',
134 'required' => true,
135 'items' => array(
136 'type' => 'string',
137 ),
138 ),
139 ),
140 )
141 );
142
143 register_rest_route(
144 self::REST_NAMESPACE,
145 '/' . self::REST_ROUTE_PREFIX . '/enqueue',
146 array(
147 'methods' => WP_REST_Server::CREATABLE,
148 'callback' => array( __CLASS__, 'post_scan_enqueue' ),
149 'permission_callback' => array( __CLASS__, 'permissions_check' ),
150 )
151 );
152
153 register_rest_route(
154 self::REST_NAMESPACE,
155 '/' . self::REST_ROUTE_PREFIX . '/threats/fix-status',
156 array(
157 'methods' => WP_REST_Server::READABLE,
158 'callback' => array( __CLASS__, 'get_threats_fix_status' ),
159 'permission_callback' => array( __CLASS__, 'permissions_check' ),
160 'args' => array(
161 'threat_ids' => array(
162 'type' => 'array',
163 'required' => true,
164 'items' => array(
165 'type' => 'string',
166 ),
167 ),
168 ),
169 )
170 );
171 }
172
173 /**
174 * Permission callback: admin-only. Mirrors the gate in
175 * `Jetpack_Scan::is_available()`.
176 *
177 * @return bool|WP_Error
178 */
179 public static function permissions_check() {
180 if ( ! current_user_can( 'manage_options' ) ) {
181 return new WP_Error(
182 'rest_forbidden',
183 esc_html__( 'You do not have permission to access this resource.', 'jetpack-scan-page' ),
184 array( 'status' => 401 )
185 );
186 }
187
188 return true;
189 }
190
191 /**
192 * GET /site/scan — current scan state + active threats.
193 *
194 * Proxies WPCOM `/sites/:siteId/scan` with blog auth (matches Protect
195 * plugin's `Threats::fetch_status()`).
196 *
197 * @return \WP_REST_Response|WP_Error
198 */
199 public static function get_site_scan() {
200 return self::proxy_get( '/scan', 'scan', true );
201 }
202
203 /**
204 * GET /site/scan/history — past scan runs and their threats.
205 *
206 * Proxies WPCOM `/sites/:siteId/scan/history` with blog auth (matches
207 * Protect plugin's `Threats::history()`).
208 *
209 * @return \WP_REST_Response|WP_Error
210 */
211 public static function get_site_scan_history() {
212 return self::proxy_get( '/scan/history', 'scan_history', true );
213 }
214
215 /**
216 * GET /site/scan/counts — threat counts for the overview tabs.
217 *
218 * Proxies WPCOM `/sites/:siteId/scan/counts` with blog auth.
219 *
220 * @return \WP_REST_Response|WP_Error
221 */
222 public static function get_site_scan_counts() {
223 return self::proxy_get( '/scan/counts', 'scan_counts', true );
224 }
225
226 /**
227 * POST /site/scan/threat/{id}/ignore — mark a threat as ignored.
228 *
229 * Proxies WPCOM `POST /sites/:siteId/alerts/:threatId` with
230 * `{ ignore: true }`. Same shape Protect plugin's
231 * `Threats::ignore_threat()` already uses.
232 *
233 * @param WP_REST_Request $request The REST request.
234 * @return \WP_REST_Response|WP_Error
235 */
236 public static function post_threat_ignore( WP_REST_Request $request ) {
237 $threat_id = (string) $request->get_param( 'id' );
238 return self::proxy_post(
239 sprintf( '/alerts/%s', rawurlencode( $threat_id ) ),
240 array( 'ignore' => true ),
241 'scan_threat_ignore'
242 );
243 }
244
245 /**
246 * POST /site/scan/threat/{id}/unignore — reactivate a previously-ignored
247 * threat.
248 *
249 * Proxies WPCOM `POST /sites/:siteId/alerts/:threatId` with
250 * `{ unignore: true }`.
251 *
252 * @param WP_REST_Request $request The REST request.
253 * @return \WP_REST_Response|WP_Error
254 */
255 public static function post_threat_unignore( WP_REST_Request $request ) {
256 $threat_id = (string) $request->get_param( 'id' );
257 return self::proxy_post(
258 sprintf( '/alerts/%s', rawurlencode( $threat_id ) ),
259 array( 'unignore' => true ),
260 'scan_threat_unignore'
261 );
262 }
263
264 /**
265 * POST /site/scan/threats/fix — kick auto-fix for one or more threats.
266 *
267 * Proxies WPCOM `POST /sites/:siteId/alerts/fix` with
268 * `{ threat_ids: [...] }`. Same endpoint handles single + bulk fix.
269 *
270 * @param WP_REST_Request $request The REST request.
271 * @return \WP_REST_Response|WP_Error
272 */
273 public static function post_threats_fix( WP_REST_Request $request ) {
274 $ids = (array) $request->get_param( 'threat_ids' );
275 $ids = array_values( array_filter( array_map( 'strval', $ids ) ) );
276 return self::proxy_post(
277 '/alerts/fix',
278 array( 'threat_ids' => $ids ),
279 'scan_threats_fix'
280 );
281 }
282
283 /**
284 * POST /site/scan/enqueue — trigger an immediate scan run.
285 *
286 * Proxies WPCOM `POST /sites/:siteId/scan/enqueue` with blog auth
287 * (matches Protect plugin's `Threats::scan()`).
288 *
289 * @return \WP_REST_Response|WP_Error
290 */
291 public static function post_scan_enqueue() {
292 return self::proxy_post( '/scan/enqueue', array(), 'scan_enqueue', true );
293 }
294
295 /**
296 * GET /site/scan/threats/fix-status — poll the auto-fixer for the
297 * current state of one or more threats.
298 *
299 * Proxies WPCOM `GET /sites/:siteId/alerts/fix?threat_ids[]=…`. Body
300 * shape mirrors `post_threats_fix` so the UI hook can poll until each
301 * threat reaches a terminal state.
302 *
303 * @param WP_REST_Request $request The REST request.
304 * @return \WP_REST_Response|WP_Error
305 */
306 public static function get_threats_fix_status( WP_REST_Request $request ) {
307 $ids = (array) $request->get_param( 'threat_ids' );
308 $ids = array_values( array_filter( array_map( 'strval', $ids ) ) );
309
310 $path = add_query_arg( array( 'threat_ids' => $ids ), '/alerts/fix' );
311 return self::proxy_get( $path, 'scan_threats_fix_status' );
312 }
313
314 /**
315 * Proxy a GET request to the WPCOM v2 Scan endpoint and pass the JSON
316 * body through (or surface a WP_Error mapping the upstream status
317 * code).
318 *
319 * Site-level reads (`/scan`, `/scan/history`, `/scan/counts`) sign
320 * with blog auth — the same contract Protect plugin's `Threats::*`
321 * helpers use, and what WPCOM expects for these endpoints. Alert /
322 * fix-status endpoints stay on user auth so per-user permissions on
323 * threat mutations carry through.
324 *
325 * Forwarding the visitor IP keeps WPCOM-side audit logs aligned with
326 * the existing `/jetpack/v4/site/activity` proxy in `activity-log`.
327 *
328 * @param string $upstream_path WPCOM path suffix (e.g. `/scan`, `/alerts/fix`).
329 * @param string $error_slug Slug used when synthesising WP_Error codes.
330 * @param bool $as_blog Sign with blog auth instead of user auth.
331 * @return \WP_REST_Response|WP_Error
332 */
333 private static function proxy_get( $upstream_path, $error_slug, $as_blog = false ) {
334 $path = self::resolve_blog_path( $upstream_path );
335 if ( is_wp_error( $path ) ) {
336 return $path;
337 }
338
339 $args = array(
340 'method' => 'GET',
341 'headers' => array(
342 'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
343 ),
344 );
345
346 $response = $as_blog
347 ? Client::wpcom_json_api_request_as_blog(
348 $path,
349 '2',
350 $args,
351 null,
352 'wpcom'
353 )
354 : Client::wpcom_json_api_request_as_user(
355 $path,
356 '2',
357 $args,
358 null,
359 'wpcom'
360 );
361
362 return self::map_response( $response, $error_slug );
363 }
364
365 /**
366 * Proxy a POST request to the user-scoped WPCOM v2 Scan endpoint
367 * with a JSON body and pass the response through (or surface a
368 * WP_Error mapping the upstream status code).
369 *
370 * @param string $upstream_path WPCOM path suffix (e.g. `/alerts/fix`).
371 * @param array $body Body payload sent as JSON.
372 * @param string $error_slug Slug used when synthesising WP_Error codes.
373 * @param bool $as_blog Sign with blog auth instead of user auth.
374 * @return \WP_REST_Response|WP_Error
375 */
376 private static function proxy_post( $upstream_path, array $body, $error_slug, $as_blog = false ) {
377 $path = self::resolve_blog_path( $upstream_path );
378 if ( is_wp_error( $path ) ) {
379 return $path;
380 }
381
382 $args = array(
383 'method' => 'POST',
384 'headers' => array(
385 'Content-Type' => 'application/json',
386 'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
387 ),
388 );
389 $encoded_body = wp_json_encode( $body, JSON_UNESCAPED_SLASHES );
390
391 $response = $as_blog
392 ? Client::wpcom_json_api_request_as_blog(
393 $path,
394 '2',
395 $args,
396 $encoded_body,
397 'wpcom'
398 )
399 : Client::wpcom_json_api_request_as_user(
400 $path,
401 '2',
402 $args,
403 $encoded_body,
404 'wpcom'
405 );
406
407 return self::map_response( $response, $error_slug );
408 }
409
410 /**
411 * Resolve the connected blog id and prefix it onto the WPCOM path
412 * suffix. Surfaces a 400 WP_Error if the site isn't connected.
413 *
414 * @param string $upstream_path WPCOM path suffix.
415 * @return string|WP_Error
416 */
417 private static function resolve_blog_path( $upstream_path ) {
418 $blog_id = (int) Jetpack_Options::get_option( 'id' );
419 if ( $blog_id <= 0 ) {
420 return new WP_Error(
421 'jetpack_scan_no_blog_id',
422 esc_html__( 'Site is not connected to WordPress.com.', 'jetpack-scan-page' ),
423 array( 'status' => 400 )
424 );
425 }
426
427 return sprintf( '/sites/%d%s', $blog_id, $upstream_path );
428 }
429
430 /**
431 * Translate a WPCOM HTTP response into a `WP_REST_Response` /
432 * `WP_Error` for the local `/jetpack/v4/*` route to return.
433 *
434 * @param array|WP_Error $response Result of `wpcom_json_api_request_as_user`.
435 * @param string $error_slug Slug used when synthesising WP_Error codes.
436 * @return \WP_REST_Response|WP_Error
437 */
438 private static function map_response( $response, $error_slug ) {
439 if ( is_wp_error( $response ) ) {
440 return new WP_Error(
441 'jetpack_' . $error_slug . '_request_failed',
442 $response->get_error_message(),
443 array( 'status' => 500 )
444 );
445 }
446
447 $status = (int) wp_remote_retrieve_response_code( $response );
448 $body = json_decode( wp_remote_retrieve_body( $response ), true );
449
450 if ( $status < 200 || $status >= 300 ) {
451 return new WP_Error(
452 'jetpack_' . $error_slug . '_request_failed',
453 isset( $body['message'] ) ? (string) $body['message'] : esc_html__( 'Unable to fetch Scan data.', 'jetpack-scan-page' ),
454 array( 'status' => $status > 0 ? $status : 500 )
455 );
456 }
457
458 return rest_ensure_response( $body );
459 }
460 }
461