Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.90% covered (warning)
74.90%
194 / 259
53.85% covered (warning)
53.85%
7 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST_Controller
74.90% covered (warning)
74.90%
194 / 259
53.85% covered (warning)
53.85%
7 / 13
84.42
0.00% covered (danger)
0.00%
0 / 1
 list_args
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
1
 actors_args
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 group_counts_args
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
 register_rest_routes
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
1
 permissions_callback
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 has_activity_logs_access
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
11
 clear_access_cache
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_activity_log
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 get_activity_log_group_counts
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_activity_log_actors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 known_threats_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_known_threats
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
11
 proxy_get
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2/**
3 * The Activity Log REST Controller.
4 *
5 * Registers the `/jetpack/v4/activity-log/*` routes backing the admin
6 * UI. Each route is a thin proxy to the corresponding WPCOM v2
7 * endpoint, authenticated with the site's blog token.
8 *
9 * @package automattic/jetpack-activity-log
10 */
11
12namespace Automattic\Jetpack\Activity_Log;
13
14use Automattic\Jetpack\Connection\Client;
15use Automattic\Jetpack\Connection\Manager as Connection_Manager;
16use Automattic\Jetpack\Status\Host;
17use Automattic\Jetpack\Status\Visitor;
18use Jetpack_Options;
19use WP_Error;
20use WP_REST_Request;
21use WP_REST_Server;
22use function current_user_can;
23use function delete_site_transient;
24use function esc_html__;
25use function function_exists;
26use function get_current_blog_id;
27use function get_site_transient;
28use function http_build_query;
29use function is_wp_error;
30use function json_decode;
31use function register_rest_route;
32use function rest_ensure_response;
33use function set_site_transient;
34use function wp_remote_retrieve_body;
35use function wp_remote_retrieve_response_code;
36
37/**
38 * REST routes for the Activity Log UI.
39 */
40class REST_Controller {
41
42    /**
43     * REST namespace used by this package.
44     *
45     * @var string
46     */
47    const REST_NAMESPACE = 'jetpack/v4';
48
49    /**
50     * Max items returned per request on the free tier. Matches the "20 most
51     * recent events" copy used by Calypso's upsell callout.
52     *
53     * @var int
54     */
55    const FREE_TIER_ITEM_CAP = 20;
56
57    /**
58     * Site-transient TTL for the has-access capability check.
59     *
60     * @var int
61     */
62    const CAPABILITY_CACHE_TTL = 5 * MINUTE_IN_SECONDS;
63
64    /**
65     * Transient key prefix for the per-blog access cache.
66     *
67     * @var string
68     */
69    const CAPABILITY_CACHE_KEY = 'jetpack_activity_log_has_access_';
70
71    /**
72     * Query params accepted by the list endpoint. Shape matches Calypso's
73     * ActivityLogParams so the ported UI can forward its filter state
74     * verbatim.
75     *
76     * @return array
77     */
78    private static function list_args() {
79        return array(
80            'number'      => array(
81                'description' => __( 'Number of items to return per page.', 'jetpack-activity-log' ),
82                'type'        => 'integer',
83                'minimum'     => 1,
84                'maximum'     => 1000,
85            ),
86            'page'        => array(
87                'description' => __( '1-indexed page number.', 'jetpack-activity-log' ),
88                'type'        => 'integer',
89                'minimum'     => 1,
90            ),
91            'sort_order'  => array(
92                'description' => __( 'Sort direction.', 'jetpack-activity-log' ),
93                'type'        => 'string',
94                'enum'        => array( 'asc', 'desc' ),
95            ),
96            'after'       => array(
97                'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ),
98                'type'        => 'string',
99                'format'      => 'date-time',
100            ),
101            'before'      => array(
102                'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ),
103                'type'        => 'string',
104                'format'      => 'date-time',
105            ),
106            'group'       => array(
107                'description' => __( 'Only return events in these groups.', 'jetpack-activity-log' ),
108                'type'        => 'array',
109                'items'       => array( 'type' => 'string' ),
110            ),
111            'not_group'   => array(
112                'description' => __( 'Exclude events in these groups.', 'jetpack-activity-log' ),
113                'type'        => 'array',
114                'items'       => array( 'type' => 'string' ),
115            ),
116            'text_search' => array(
117                'description' => __( 'Full-text search string.', 'jetpack-activity-log' ),
118                'type'        => 'string',
119            ),
120            'actor'       => array(
121                'description' => __( 'Only return events performed by these actor IDs.', 'jetpack-activity-log' ),
122                'type'        => 'array',
123                'items'       => array( 'type' => 'string' ),
124            ),
125        );
126    }
127
128    /**
129     * Query params accepted by the actors endpoint. Same date window as the
130     * counts endpoint (no pagination, no sort, no filters) â€” we just want
131     * the distinct set for the "Performed by" dropdown.
132     *
133     * @return array
134     */
135    private static function actors_args() {
136        return array(
137            'number' => array(
138                'description' => __( 'Cap on the number of events considered when collecting actors.', 'jetpack-activity-log' ),
139                'type'        => 'integer',
140                'minimum'     => 1,
141                'maximum'     => 1000,
142            ),
143            'after'  => array(
144                'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ),
145                'type'        => 'string',
146                'format'      => 'date-time',
147            ),
148            'before' => array(
149                'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ),
150                'type'        => 'string',
151                'format'      => 'date-time',
152            ),
153        );
154    }
155
156    /**
157     * Query params accepted by the group-counts endpoint. A subset of the
158     * list params â€” no pagination or sort, no text search.
159     *
160     * @return array
161     */
162    private static function group_counts_args() {
163        return array(
164            'number'    => array(
165                'description' => __( 'Cap on the number of events considered when counting groups.', 'jetpack-activity-log' ),
166                'type'        => 'integer',
167                'minimum'     => 1,
168                'maximum'     => 1000,
169            ),
170            'after'     => array(
171                'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ),
172                'type'        => 'string',
173                'format'      => 'date-time',
174            ),
175            'before'    => array(
176                'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ),
177                'type'        => 'string',
178                'format'      => 'date-time',
179            ),
180            'group'     => array(
181                'description' => __( 'Only count events in these groups.', 'jetpack-activity-log' ),
182                'type'        => 'array',
183                'items'       => array( 'type' => 'string' ),
184            ),
185            'not_group' => array(
186                'description' => __( 'Exclude events in these groups.', 'jetpack-activity-log' ),
187                'type'        => 'array',
188                'items'       => array( 'type' => 'string' ),
189            ),
190        );
191    }
192
193    /**
194     * Register the Activity Log REST routes.
195     *
196     * Hooked on `rest_api_init` by {@see Jetpack_Activity_Log::initialize()}.
197     */
198    public static function register_rest_routes() {
199        register_rest_route(
200            self::REST_NAMESPACE,
201            '/activity-log',
202            array(
203                'methods'             => WP_REST_Server::READABLE,
204                'callback'            => array( __CLASS__, 'get_activity_log' ),
205                'permission_callback' => array( __CLASS__, 'permissions_callback' ),
206                'args'                => self::list_args(),
207            )
208        );
209
210        register_rest_route(
211            self::REST_NAMESPACE,
212            '/activity-log/count/group',
213            array(
214                'methods'             => WP_REST_Server::READABLE,
215                'callback'            => array( __CLASS__, 'get_activity_log_group_counts' ),
216                'permission_callback' => array( __CLASS__, 'permissions_callback' ),
217                'args'                => self::group_counts_args(),
218            )
219        );
220
221        register_rest_route(
222            self::REST_NAMESPACE,
223            '/activity-log/known-threats',
224            array(
225                'methods'             => WP_REST_Server::READABLE,
226                'callback'            => array( __CLASS__, 'get_known_threats' ),
227                'permission_callback' => array( __CLASS__, 'permissions_callback' ),
228            )
229        );
230
231        register_rest_route(
232            self::REST_NAMESPACE,
233            '/activity-log/actors',
234            array(
235                'methods'             => WP_REST_Server::READABLE,
236                'callback'            => array( __CLASS__, 'get_activity_log_actors' ),
237                'permission_callback' => array( __CLASS__, 'permissions_callback' ),
238                'args'                => self::actors_args(),
239            )
240        );
241    }
242
243    /**
244     * Permission callback. Mirrors the menu gating â€” any admin on a
245     * non-multisite install with a user-level WPCOM connection can read
246     * the log. A user-level connection is required because the upstream
247     * WPCOM endpoint is user-gated (it needs to identify *which* admin
248     * is asking); signing as the blog gets rejected with "Only
249     * Administrators can query information about the current site."
250     *
251     * @return bool|WP_Error
252     */
253    public static function permissions_callback() {
254        if ( ! current_user_can( 'manage_options' ) ) {
255            return false;
256        }
257
258        if ( ! ( new Connection_Manager() )->is_user_connected() ) {
259            return new WP_Error(
260                'activity_log_user_not_connected',
261                esc_html__( 'Your WordPress.com account is not connected to this site. Connect it to use the Activity Log.', 'jetpack-activity-log' ),
262                array( 'status' => 403 )
263            );
264        }
265
266        return true;
267    }
268
269    /**
270     * Whether the site's current plan unlocks the full activity log.
271     *
272     * Checks the WPCOM `/sites/{id}/features` endpoint for the
273     * `full-activity-log` feature flag and caches the boolean for
274     * {@see self::CAPABILITY_CACHE_TTL} seconds in a site transient so the
275     * list endpoint doesn't pay the round-trip on every pagination page.
276     * The cache is per-blog (fine for multisite) and keyed on `blog_id`.
277     *
278     * Checking the feature flag (rather than a specific plan slug or the
279     * rewind state) means Jetpack Complete, Security, Personal, and all
280     * standalone Backup plans are covered correctly, regardless of whether
281     * backup credentials have been configured.
282     *
283     * On WordPress.com Simple sites the feature is checked locally instead.
284     *
285     * @since 0.6.0 Checks the feature locally on WordPress.com Simple sites.
286     *
287     * @return bool True when the site has the full-activity-log feature.
288     */
289    public static function has_activity_logs_access() {
290        if ( Jetpack_Activity_Log::is_wpcom_simple() ) {
291            return function_exists( 'wpcom_site_has_feature' ) && wpcom_site_has_feature( 'full-activity-log', get_current_blog_id() );
292        }
293
294        $blog_id = (int) Jetpack_Options::get_option( 'id' );
295        if ( ! $blog_id ) {
296            return false;
297        }
298
299        $cache_key = self::CAPABILITY_CACHE_KEY . $blog_id;
300        $cached    = get_site_transient( $cache_key );
301        if ( false !== $cached ) {
302            return 'yes' === $cached;
303        }
304
305        $response = Client::wpcom_json_api_request_as_blog(
306            sprintf( '/sites/%d/features', $blog_id ),
307            '1.1',
308            array( 'timeout' => 2 )
309        );
310
311        if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
312            // Fail closed: assume no access if we can't reach WPCOM. Cache for
313            // a short window to avoid hammering the endpoint on every call.
314            set_site_transient( $cache_key, 'no', 10 );
315            return false;
316        }
317
318        $body   = json_decode( wp_remote_retrieve_body( $response ) );
319        $active = is_object( $body ) && isset( $body->active ) && is_array( $body->active ) ? $body->active : array();
320        $has_it = in_array( 'full-activity-log', $active, true );
321        set_site_transient( $cache_key, $has_it ? 'yes' : 'no', self::CAPABILITY_CACHE_TTL );
322        return $has_it;
323    }
324
325    /**
326     * Clear the cached has-access flag. Exposed so front-end flows that
327     * know the plan just changed (e.g. a successful checkout redirect) can
328     * force a refresh on the next request.
329     *
330     * @return void
331     */
332    public static function clear_access_cache() {
333        $blog_id = (int) Jetpack_Options::get_option( 'id' );
334        if ( $blog_id ) {
335            delete_site_transient( self::CAPABILITY_CACHE_KEY . $blog_id );
336        }
337    }
338
339    /**
340     * Free-tier params that survive the filter strip in
341     * {@see self::get_activity_log()}. Anything outside this list is
342     * nulled out before the request reaches WPCOM.
343     *
344     * @var string[]
345     */
346    const FREE_TIER_ALLOWED_PARAMS = array( 'number', 'page', 'sort_order' );
347
348    /**
349     * Proxy the paginated activity list.
350     *
351     * Enforces the free-tier boundary server-side. When the site doesn't
352     * have access:
353     *
354     *   1. `number` is clamped to {@see self::FREE_TIER_ITEM_CAP}.
355     *   2. `page` is forced to 1.
356     *   3. All filter inputs (`after`, `before`, `group`, `not_group`,
357     *      `text_search`, `actor`) are dropped.
358     *
359     * Together these mean a client-side bypass (DevTools, direct
360     * `wp.apiFetch`) is bounded to "the 20 most recent events overall" â€”
361     * the same dataset the locked-down UI surfaces. Without (3),
362     * date-walking via `before` would let a free-tier caller page through
363     * the entire history 20 rows at a time.
364     *
365     * @param WP_REST_Request $request Request.
366     * @return mixed
367     */
368    public static function get_activity_log( WP_REST_Request $request ) {
369        if ( ! self::has_activity_logs_access() ) {
370            // Mutating the request in-place is deliberate: any
371            // downstream `rest_request_*` filter sees the clamped
372            // values, not the caller's originals. For a security
373            // clamp that's the right side of the trade â€” no filter
374            // can undo the limit.
375            $requested = (int) $request->get_param( 'number' );
376            $request->set_param(
377                'number',
378                $requested > 0 ? min( $requested, self::FREE_TIER_ITEM_CAP ) : self::FREE_TIER_ITEM_CAP
379            );
380            $request->set_param( 'page', 1 );
381
382            foreach ( array_keys( self::list_args() ) as $key ) {
383                if ( ! in_array( $key, self::FREE_TIER_ALLOWED_PARAMS, true ) ) {
384                    $request->set_param( $key, null );
385                }
386            }
387        }
388        return self::proxy_get( '/activity', $request, array_keys( self::list_args() ) );
389    }
390
391    /**
392     * Proxy the group-counts endpoint.
393     *
394     * Deliberately not tier-clamped â€” the free-tier list clamp
395     * (`number` â†’ 20 / `page` â†’ 1) is the security boundary; the group
396     * counts are cosmetic metadata that powers the filter dropdown. A
397     * stable, full-history count keeps the dropdown from flickering as
398     * users type in the search field, matching Calypso's behavior at
399     * `wp-calypso:client/dashboard/sites/logs-activity/dataviews/
400     * index.tsx:100-102`.
401     *
402     * @param WP_REST_Request $request Request.
403     * @return mixed
404     */
405    public static function get_activity_log_group_counts( WP_REST_Request $request ) {
406        return self::proxy_get( '/activity/count/group', $request, array_keys( self::group_counts_args() ) );
407    }
408
409    /**
410     * Proxy the actors endpoint. Returns the distinct actors that have at
411     * least one event in the requested date window â€” used to populate the
412     * "Performed by" filter dropdown.
413     *
414     * Tier-clamping mirrors the group-counts endpoint: the list clamp at
415     * {@see self::get_activity_log()} is the security boundary, so the
416     * actors metadata is fine to serve unconditionally.
417     *
418     * @param WP_REST_Request $request Request.
419     * @return mixed
420     */
421    public static function get_activity_log_actors( WP_REST_Request $request ) {
422        return self::proxy_get( '/activity/actors', $request, array_keys( self::actors_args() ) );
423    }
424
425    /**
426     * Whether this site checks backups for known threats. Only WoA sites do, for now.
427     *
428     * @return bool
429     */
430    public static function known_threats_available() {
431        return ( new Host() )->is_woa_site();
432    }
433
434    /**
435     * Known Scan threat counts for the site's newest 100 backups, by backup period.
436     *
437     * Without Scan, or off WoA, the answer is `threats_unavailable` (404) and the log shows no badges.
438     *
439     * @return \WP_REST_Response|WP_Error
440     */
441    public static function get_known_threats() {
442        $unavailable = new WP_Error(
443            'threats_unavailable',
444            esc_html__( 'Known threats are not available for this site.', 'jetpack-activity-log' ),
445            array( 'status' => 404 )
446        );
447
448        $blog_id = (int) Jetpack_Options::get_option( 'id' );
449        if ( ! $blog_id || ! self::known_threats_available() ) {
450            return $unavailable;
451        }
452
453        $response = Client::wpcom_json_api_request_as_user(
454            sprintf( '/sites/%d/rewind/backups?number=100&page=1&known_threats_only=true', $blog_id ),
455            '3',
456            array( 'method' => 'GET' ),
457            null,
458            'wpcom'
459        );
460
461        if ( is_wp_error( $response ) ) {
462            return new WP_Error( 'known_threats_request_failed', $response->get_error_message(), array( 'status' => 502 ) );
463        }
464
465        $status = (int) wp_remote_retrieve_response_code( $response );
466        if ( 403 === $status ) {
467            return $unavailable;
468        }
469
470        $body = json_decode( wp_remote_retrieve_body( $response ), true );
471        if ( 200 !== $status || ! is_array( $body ) ) {
472            return new WP_Error(
473                'known_threats_request_failed',
474                esc_html__( 'Unable to fetch the known threats.', 'jetpack-activity-log' ),
475                array( 'status' => 502 )
476            );
477        }
478
479        // Every period is kept: a row belongs to the newest backup at or before its rewind id.
480        $backups = array();
481        foreach ( (array) ( $body['backups'] ?? array() ) as $backup ) {
482            if ( ! isset( $backup['object']['backup_period'] ) || ! is_numeric( $backup['object']['backup_period'] ) ) {
483                continue;
484            }
485            $known_threats = $backup['object']['known_threats'] ?? null;
486            $backups[]     = array(
487                'period'        => (int) $backup['object']['backup_period'],
488                'known_threats' => is_array( $known_threats ) ? $known_threats : null,
489            );
490        }
491
492        return rest_ensure_response( array( 'backups' => $backups ) );
493    }
494
495    /**
496     * Shared helper: forward whitelisted query params from $request to the
497     * equivalent WPCOM v2 path under `/sites/{blog_id}`.
498     *
499     * @param string          $wpcom_path    Path relative to the site, starting with "/".
500     * @param WP_REST_Request $request       Incoming request.
501     * @param array           $allowed_keys  Params to forward. Any unset keys are dropped.
502     * @return mixed Decoded JSON response from WPCOM, or WP_Error on failure.
503     */
504    private static function proxy_get( $wpcom_path, WP_REST_Request $request, array $allowed_keys ) {
505        $blog_id = Jetpack_Options::get_option( 'id' );
506        if ( ! $blog_id ) {
507            return new WP_Error(
508                'activity_log_not_connected',
509                esc_html__( 'This site is not connected to WordPress.com.', 'jetpack-activity-log' ),
510                array( 'status' => 400 )
511            );
512        }
513
514        $params = array();
515        foreach ( $allowed_keys as $key ) {
516            $value = $request->get_param( $key );
517            if ( $value !== null ) {
518                $params[ $key ] = $value;
519            }
520        }
521
522        $path = sprintf( '/sites/%d%s', (int) $blog_id, $wpcom_path );
523        if ( ! empty( $params ) ) {
524            $path .= '?' . http_build_query( $params );
525        }
526
527        // Sign as the current user, not the blog: the upstream /sites/{id}/activity
528        // endpoint checks that a specific admin is asking. Forward the visitor IP
529        // so WPCOM logs match the existing /jetpack/v4/site/activity proxy.
530        $response = Client::wpcom_json_api_request_as_user(
531            $path,
532            '2',
533            array(
534                'method'  => 'GET',
535                'headers' => array(
536                    'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
537                ),
538            ),
539            null,
540            'wpcom'
541        );
542
543        if ( is_wp_error( $response ) ) {
544            return new WP_Error(
545                'activity_log_request_failed',
546                $response->get_error_message(),
547                array( 'status' => 500 )
548            );
549        }
550
551        $status = (int) wp_remote_retrieve_response_code( $response );
552        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
553
554        if ( 200 !== $status ) {
555            return new WP_Error(
556                'activity_log_request_failed',
557                isset( $body['message'] ) ? (string) $body['message'] : esc_html__( 'Unable to fetch activity log.', 'jetpack-activity-log' ),
558                array( 'status' => $status ? $status : 500 )
559            );
560        }
561
562        return rest_ensure_response( $body );
563    }
564}