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