Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.73% covered (warning)
65.73%
140 / 213
57.14% covered (warning)
57.14%
8 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST_Controller
65.73% covered (warning)
65.73%
140 / 213
57.14% covered (warning)
57.14%
8 / 14
50.16
0.00% covered (danger)
0.00%
0 / 1
 register_rest_routes
100.00% covered (success)
100.00%
104 / 104
100.00% covered (success)
100.00%
1 / 1
1
 permissions_check
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_site_scan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_site_scan_history
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_site_scan_counts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 post_threat_ignore
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 post_threat_unignore
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 post_threats_fix
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 post_scan_enqueue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_threats_fix_status
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 proxy_get
12.00% covered (danger)
12.00%
3 / 25
0.00% covered (danger)
0.00%
0 / 1
9.13
 proxy_post
11.11% covered (danger)
11.11%
3 / 27
0.00% covered (danger)
0.00%
0 / 1
9.32
 resolve_blog_path
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 map_response
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
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
13namespace Automattic\Jetpack\Scan_Page;
14
15use Automattic\Jetpack\Connection\Client;
16use Automattic\Jetpack\Status\Visitor;
17use Jetpack_Options;
18use WP_Error;
19use WP_REST_Request;
20use WP_REST_Server;
21use function add_query_arg;
22use function current_user_can;
23use function esc_html__;
24use function is_wp_error;
25use function json_decode;
26use function register_rest_route;
27use function rest_ensure_response;
28use function wp_json_encode;
29use function wp_remote_retrieve_body;
30use function wp_remote_retrieve_response_code;
31
32/**
33 * REST routes for the Scan UI.
34 */
35class 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}