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-scan-page/src/class-rest-controller.php +460 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,460 @@
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 +}