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