PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
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 13.7.2 13.8.3 13.9.2 14.0.1 All 504 releases
jetpack / jetpack_vendor / automattic / jetpack-activity-log / src / class-rest-controller.php

class-rest-controller.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at jetpack_vendor/automattic/jetpack-activity-log/src/class-rest-controller.php

474 lines 15.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
474