PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-activity-log / src / class-rest-controller.php
jetpack / jetpack_vendor / automattic / jetpack-activity-log / src Last commit date
class-initial-state.php 4 weeks ago class-jetpack-activity-log.php 4 weeks ago class-package-version.php 5 days ago class-rest-controller.php 3 months ago
class-rest-controller.php
474 lines
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