PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
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-activity-log/src/class-rest-controller.php +473 -0 16.2-beta → 16.3-beta View file →
@@ -1,0 +1,473 @@
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 +
12 +namespace Automattic\Jetpack\Activity_Log;
13 +
14 +use Automattic\Jetpack\Connection\Client;
15 +use Automattic\Jetpack\Connection\Manager as Connection_Manager;
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 current_user_can;
22 +use function delete_site_transient;
23 +use function esc_html__;
24 +use function get_site_transient;
25 +use function http_build_query;
26 +use function is_wp_error;
27 +use function json_decode;
28 +use function register_rest_route;
29 +use function rest_ensure_response;
30 +use function set_site_transient;
31 +use function wp_remote_retrieve_body;
32 +use function wp_remote_retrieve_response_code;
33 +
34 +/**
35 + * REST routes for the Activity Log UI.
36 + */
37 +class 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 +}