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-premium-analytics / src / REST / class-api-proxy-controller.php

class-api-proxy-controller.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at jetpack_vendor/automattic/jetpack-premium-analytics/src/REST/class-api-proxy-controller.php

789 lines 29.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST controller that proxies dashboard data-layer requests to the WPCOM analytics API.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8 namespace Automattic\Jetpack\PremiumAnalytics\REST;
9
10 use Automattic\Jetpack\Connection\Client;
11 use Automattic\Jetpack\Connection\Manager;
12 use Automattic\Jetpack\Constants;
13 use Jetpack_Options;
14 use WP_Error;
15 use WP_REST_Controller;
16 use WP_REST_Request;
17 use WP_REST_Response;
18 use WP_REST_Server;
19
20 /**
21 * Forwards an authenticated dashboard request to the WPCOM endpoint for the connected
22 * site's blog ID, caches the successful response in a short-lived transient, and returns
23 * it. Lets the extracted frontend's data layer talk to WPCOM without each call leaving the
24 * WordPress origin.
25 *
26 * One agnostic route serves the whole pass-through surface (analytics + the re-exposed
27 * `stats-admin` endpoints), minus the blog ID in the URL:
28 *
29 * proxy/v<version>/<prefix>/<subpath> e.g. proxy/v1.1/wordads/earnings
30 *
31 * The `proxy/` segment marks a transparent WPCOM forward (future local endpoints live
32 * elsewhere under the namespace). Rather than registering each endpoint, it accepts any
33 * sub-path under an allowed top-level prefix (see {@see PREFIX_CONFIG}); the caller picks the
34 * WPCOM API `version` in the path (the base is derived: v2 → wpcom, v1.x → rest). The proxy
35 * stays endpoint-agnostic while the prefix allowlist + write-method policy keep the blast
36 * radius of the blog token bounded.
37 */
38 class Api_Proxy_Controller extends WP_REST_Controller {
39
40 /**
41 * Package slug. Also the cache-key prefix (see SLUG-derived CACHE_PREFIX) — the only
42 * piece the source pulled from its dropped Utilities trait.
43 */
44 private const SLUG = 'jetpack-premium-analytics';
45
46 /**
47 * Transient key prefix, derived from the package slug.
48 *
49 * @var string
50 */
51 private const CACHE_PREFIX = self::SLUG . '_proxy_';
52
53 /**
54 * How long a successful response stays cached.
55 *
56 * @var int
57 */
58 private const CACHE_TTL = 5 * MINUTE_IN_SECONDS;
59
60 /**
61 * Timeout for the outbound WPCOM request, in seconds.
62 *
63 * @var int
64 */
65 private const API_TIMEOUT = 20;
66
67 /**
68 * Response headers worth forwarding back to the dashboard.
69 *
70 * @var string[]
71 */
72 private const FORWARDED_HEADERS = array( 'x-wp-total', 'x-wp-totalpages' );
73
74 /**
75 * Per-prefix configuration — the single source of truth for every proxied endpoint group.
76 * The route regex, permission check, write gate, cache-busting, and path builder all read
77 * from this table, so an endpoint group is defined here and nowhere else.
78 *
79 * The keys double as the security boundary: a request is only routed (and the blog token only
80 * forwarded) if its first path segment is a key here — so the proxy can never be driven
81 * against the whole WPCOM site API. Keep keys lowercase; they are matched case-insensitively.
82 *
83 * A request maps to `proxy/v<version>/<key>/<sub-path>` →
84 * `/sites/<blog-id>/<key>/<sub-path>` (the caller chooses `<version>`; the base is derived).
85 *
86 * Fields per entry:
87 * - `capability` (string, required) Capability granting access. `manage_options` is always
88 * also accepted, so a value of `manage_options` means "admins only". A
89 * missing/unknown value fails closed (denies).
90 * - `writes` (string[], optional) Sub-paths reachable with POST (the only write verb).
91 * Each matcher: trailing `/` = that sub-path and anything under it; no
92 * trailing `/` = that exact endpoint only. Omit for a read-only group.
93 * - `cache_bust` (bool, optional) If true, a successful POST clears the matching read cache.
94 * Only meaningful alongside `writes`.
95 * - `path` (string, optional) printf template (`%d` = blog id) for groups NOT under
96 * `/sites/<id>/` (e.g. `upgrades` → `/upgrades?site=%d`). A group with a
97 * fixed `path` takes no sub-path. Omit for the normal `/sites/<id>/<key>/…`.
98 * - `pattern` (string, optional) Regex the sub-path (after `<key>/`) must fully match,
99 * for groups where only specific endpoints are safe to expose (e.g. `posts`
100 * → only `<id>/likes` and `<id>/replies`, never post content). Anchored on both ends and
101 * enforced in the route regex AND in `validate_data_endpoint()` (the route
102 * capture can be shadowed with `?endpoint=`). Omit to allow the whole group.
103 * - `inject_user_email` (bool, optional) Add the local user's email to the forwarded write body
104 * as `user_email`. The blog token carries no user, so a WPCOM endpoint that
105 * attributes a submission to a person cannot resolve one on its own (it falls
106 * back to the site's first administrator). The only body rewrite this proxy
107 * does; see `inject_user_email()`.
108 * - `unauthenticated` (bool, optional) Forward reads WITHOUT signing (plain HTTP, like
109 * stats-admin's Odyssey proxy does for post likes). For WPCOM endpoints
110 * that reject blog-token auth but serve public data without credentials.
111 * Reads only; the group's `capability` still gates the local request.
112 *
113 * Maintaining endpoints (this table is the only edit needed for a pass-through endpoint):
114 * - ADD a group: add a key with at least `capability`. Reads work immediately at
115 * `proxy/v<version>/<key>/<sub-path>`. The frontend picks the WPCOM version.
116 * - ALLOW writes: add `writes` (and `cache_bust` if a write should freshen a cached read).
117 * - CHANGE access: edit `capability` (e.g. tighten a group to `manage_options`).
118 * - REMOVE a group: delete its key — the route stops matching it and it 404s.
119 * - Cover it with a row in `data_endpoint_matrix()` (capability / writable / WPCOM path).
120 * - NOTE: this is for transparent WPCOM forwards only. Endpoints needing local processing
121 * (DB reads, the Notices class, …) are NOT proxied — they get their own routes outside
122 * `proxy/`; do not add them here. `inject_user_email` is the one exception, and stays one:
123 * a rewrite that cannot be expressed as a flag on this table belongs in its own route.
124 *
125 * @var array<string, array<string, mixed>>
126 */
127 private const PREFIX_CONFIG = array(
128 // Gated like WooCommerce's own Analytics screens, which shop managers can read;
129 // woocommerce-analytics made the same move away from manage_options (WOOA7S-551).
130 'analytics' => array( 'capability' => 'view_woocommerce_reports' ),
131 'stats' => array(
132 'capability' => 'view_stats',
133 'writes' => array( 'stats/referrers/spam/' ),
134 ),
135 'wordads' => array( 'capability' => 'activate_wordads' ),
136 'subscribers' => array( 'capability' => 'view_stats' ),
137 'site-has-never-published-post' => array( 'capability' => 'view_stats' ),
138 'jetpack-stats' => array(
139 'capability' => 'view_stats',
140 'writes' => array( 'jetpack-stats/user-feedback' ),
141 'inject_user_email' => true,
142 ),
143 'jetpack-stats-dashboard' => array(
144 'capability' => 'view_stats',
145 'writes' => array( 'jetpack-stats-dashboard/' ),
146 'cache_bust' => true,
147 ),
148 'commercial-classification' => array(
149 'capability' => 'view_stats',
150 'writes' => array( 'commercial-classification' ),
151 ),
152 'upgrades' => array(
153 'capability' => 'view_stats',
154 'path' => '/upgrades?site=%d',
155 ),
156 'posts' => array(
157 'capability' => 'view_stats',
158 // Only a post's public likers and approved replies — never post content
159 // (the blog token could otherwise read private posts for any view_stats user).
160 'pattern' => '[0-9]+/(?:likes|replies)',
161 // Both endpoints serve public data without credentials, while likes
162 // rejects blog-token auth; forward the constrained group unsigned,
163 // mirroring stats-admin's Odyssey proxy for likes.
164 'unauthenticated' => true,
165 ),
166 );
167
168 /**
169 * Constructor.
170 */
171 public function __construct() {
172 $this->namespace = self::SLUG . '/v1';
173 }
174
175 /**
176 * Hook the controller's routes onto rest_api_init, and register its cache prefix with the
177 * stats package's transient cleanup cron.
178 *
179 * @return void
180 */
181 public static function register(): void {
182 $controller = new self();
183 add_action( 'rest_api_init', array( $controller, 'register_routes' ) );
184 add_filter( 'jetpack_stats_transient_cleanup_prefixes', array( $controller, 'register_transient_cleanup_prefix' ) );
185 }
186
187 /**
188 * Register the proxy cache prefix with the stats package's transient cleanup cron, so expired
189 * proxy transients are swept on sites without a persistent object cache (where WordPress's lazy
190 * GC never reaches the rarely re-read, param-keyed entries). The coupling is loose: the filter
191 * is just a hook name, so if the stats package isn't loaded it never fires and nothing breaks.
192 *
193 * Appends only when handed a valid array; a non-array (from a misbehaving upstream filter) is
194 * returned untouched so the stats consumer's own fall-back-to-defaults normalization still runs
195 * instead of being masked into dropping the default stats prefix.
196 *
197 * @param mixed $prefixes Transient prefixes the stats cleanup cron will sweep.
198 *
199 * @return mixed
200 */
201 public function register_transient_cleanup_prefix( $prefixes ) {
202 if ( is_array( $prefixes ) ) {
203 $prefixes[] = self::CACHE_PREFIX;
204 }
205
206 return $prefixes;
207 }
208
209 /**
210 * Register the agnostic data proxy route.
211 *
212 * @return void
213 */
214 public function register_routes(): void {
215 // proxy/v<version>/<prefix>/<subpath> — the `proxy/` segment marks a transparent WPCOM
216 // pass-through (local endpoints live elsewhere under the namespace), the version is part
217 // of the path (matching WPCOM's own `rest/v1.1` / `wpcom/v2` structure), and the prefix
218 // allowlist is anchored into the route.
219 register_rest_route(
220 $this->namespace,
221 '/proxy/v(?P<version>[0-9]+(?:\.[0-9]+)?)/(?P<endpoint>' . $this->allowed_endpoint_pattern() . ')',
222 array(
223 'methods' => WP_REST_Server::READABLE . ',' . WP_REST_Server::EDITABLE,
224 'callback' => array( $this, 'handle_data_request' ),
225 'permission_callback' => array( $this, 'check_data_permission' ),
226 'args' => array(
227 'endpoint' => array(
228 'type' => 'string',
229 'required' => true,
230 'validate_callback' => array( $this, 'validate_data_endpoint' ),
231 ),
232 'version' => array(
233 'description' => __( 'WPCOM API version to forward to (e.g. 1.1, 1.2, 2).', 'jetpack-premium-analytics-pkg' ),
234 'type' => 'string',
235 'required' => true,
236 'validate_callback' => array( $this, 'validate_version' ),
237 ),
238 ),
239 )
240 );
241 }
242
243 /**
244 * Regex alternation of the allowed endpoints, used to anchor the data route: each
245 * {@see PREFIX_CONFIG} key followed by its `pattern`-constrained sub-path when set, or any
246 * sub-path otherwise.
247 *
248 * @return string
249 */
250 private function allowed_endpoint_pattern(): string {
251 $alternatives = array();
252
253 foreach ( self::PREFIX_CONFIG as $prefix => $config ) {
254 $suffix = isset( $config['pattern'] ) ? '/' . $config['pattern'] : '(?:/.*)?';
255 $alternatives[] = preg_quote( $prefix, '#' ) . $suffix;
256 }
257
258 return '(?:' . implode( '|', $alternatives ) . ')';
259 }
260
261 /**
262 * The {@see PREFIX_CONFIG} entry for an endpoint's top-level prefix, or null if not allowed.
263 *
264 * @param string $endpoint The endpoint value (`get_param('endpoint')`).
265 *
266 * @return array<string, mixed>|null
267 */
268 private function config_for( string $endpoint ): ?array {
269 $prefix = strtolower( explode( '/', $endpoint )[0] );
270
271 return self::PREFIX_CONFIG[ $prefix ] ?? null;
272 }
273
274 /**
275 * Permission for the data proxy: the prefix's configured capability grants access, and
276 * `manage_options` always does (so `analytics`, whose capability is `manage_options`, stays
277 * admin-only). The capability comes from {@see PREFIX_CONFIG}.
278 *
279 * @param WP_REST_Request $request Request object.
280 *
281 * @return bool
282 */
283 public function check_data_permission( WP_REST_Request $request ): bool {
284 $config = $this->config_for( (string) $request->get_param( 'endpoint' ) );
285 if ( null === $config ) {
286 return false;
287 }
288
289 // Fall back to `do_not_allow` so a config entry missing `capability` fails closed.
290 $capability = $config['capability'] ?? 'do_not_allow';
291
292 // phpcs:ignore WordPress.WP.Capabilities.Unknown -- capability is from the PREFIX_CONFIG allowlist.
293 return current_user_can( 'manage_options' ) || current_user_can( $capability );
294 }
295
296 /**
297 * Confine a data endpoint to a relative sub-path under an allowed prefix, rejecting traversal
298 * (`..`) and schemes (`:`). Commas are permitted since stats sub-paths legitimately contain
299 * them (UTM params).
300 *
301 * The prefix is re-checked here, not just in the route regex: WP's `get_param()` prefers
302 * GET/JSON/POST over the URL route capture, so a caller could otherwise shadow the matched
303 * `endpoint` with `?endpoint=…` and escape the allowlist. This runs against the same
304 * `get_param()` value the handler forwards, so it closes the hole whichever source wins.
305 *
306 * @param mixed $value Raw endpoint param.
307 *
308 * @return bool
309 */
310 public function validate_data_endpoint( $value ): bool {
311 $value = (string) $value;
312
313 if ( str_contains( $value, '..' ) ) {
314 return false;
315 }
316
317 if ( ! preg_match( '#^[\w.,/-]+$#', $value ) ) {
318 return false;
319 }
320
321 $config = $this->config_for( $value );
322 if ( null === $config ) {
323 return false;
324 }
325
326 // A prefix with a fixed `path` (e.g. site-less `upgrades`) takes no sub-path, so reject
327 // `<prefix>/<anything>` — build_data_path() ignores sub-paths there and would mis-route.
328 if ( isset( $config['path'] ) ) {
329 $prefix = strtolower( explode( '/', $value )[0] );
330 if ( $prefix !== rtrim( strtolower( $value ), '/' ) ) {
331 return false;
332 }
333 }
334
335 // A `pattern`-constrained prefix only exposes matching sub-paths. Re-checked here, not
336 // just in the route regex, because `get_param()` can be shadowed with `?endpoint=`.
337 if ( isset( $config['pattern'] ) ) {
338 $prefix = strtolower( explode( '/', $value )[0] );
339 if ( ! preg_match( '#^' . preg_quote( $prefix, '#' ) . '/' . $config['pattern'] . '$#i', rtrim( $value, '/' ) ) ) {
340 return false;
341 }
342 }
343
344 return true;
345 }
346
347 /**
348 * A WPCOM API version is one or two dot-separated numbers (e.g. `2`, `1.1`).
349 *
350 * @param mixed $value Raw version param.
351 *
352 * @return bool
353 */
354 public function validate_version( $value ): bool {
355 return (bool) preg_match( '#^[0-9]+(\.[0-9]+)?$#', (string) $value );
356 }
357
358 /**
359 * Proxy a data request to its WPCOM endpoint, at the caller-chosen API version.
360 *
361 * @param WP_REST_Request $request Request object.
362 *
363 * @return WP_REST_Response|WP_Error
364 */
365 public function handle_data_request( WP_REST_Request $request ) {
366 $endpoint = (string) $request->get_param( 'endpoint' );
367 $method = strtoupper( $request->get_method() );
368
369 // Reads are open across the allowed prefixes; only POST may mutate, and only the
370 // few endpoints on the write allowlist. Everything else is rejected locally.
371 if ( 'GET' !== $method && ! ( 'POST' === $method && $this->is_write_allowed( $endpoint ) ) ) {
372 return new WP_Error(
373 'rest_read_only',
374 __( 'This endpoint is read-only.', 'jetpack-premium-analytics-pkg' ),
375 array( 'status' => 405 )
376 );
377 }
378
379 $version = (string) $request->get_param( 'version' );
380
381 $config = $this->config_for( $endpoint );
382
383 return $this->forward(
384 $request,
385 $this->build_data_path( $endpoint ),
386 array(
387 'version' => $version,
388 'base' => $this->base_for_version( $version ),
389 'bust_on_write' => $this->busts_cache( $endpoint ),
390 'unauthenticated' => ! empty( $config['unauthenticated'] ),
391 'inject_user_email' => ! empty( $config['inject_user_email'] ),
392 )
393 );
394 }
395
396 /**
397 * The WPCOM API base for a version: v2 lives under `wpcom`, v1.x under `rest`. Derived from
398 * the major component so dotted forms (e.g. `2.0`) map correctly.
399 *
400 * @param string $version WPCOM API version.
401 *
402 * @return string
403 */
404 private function base_for_version( string $version ): string {
405 return 2 === (int) $version ? 'wpcom' : 'rest';
406 }
407
408 /**
409 * Build the WPCOM path for a data endpoint.
410 *
411 * @param string $endpoint The validated, allowed sub-path.
412 *
413 * @return string
414 */
415 private function build_data_path( string $endpoint ): string {
416 $site_id = (int) Jetpack_Options::get_option( 'id' );
417
418 // A prefix with a fixed `path` (e.g. site-less `upgrades`) is not scoped under /sites/<id>/.
419 $config = $this->config_for( $endpoint );
420 if ( null !== $config && isset( $config['path'] ) ) {
421 return sprintf( $config['path'], $site_id );
422 }
423
424 return sprintf( '/sites/%d/%s', $site_id, $endpoint );
425 }
426
427 /**
428 * Whether a non-GET method may be forwarded for this endpoint, per the prefix's `writes`.
429 * A `writes` entry ending in `/` matches that sub-path prefix; otherwise it matches exactly.
430 *
431 * @param string $endpoint The validated sub-path.
432 *
433 * @return bool
434 */
435 private function is_write_allowed( string $endpoint ): bool {
436 $endpoint = strtolower( $endpoint );
437 $config = $this->config_for( $endpoint );
438
439 foreach ( $config['writes'] ?? array() as $matcher ) {
440 $matcher = strtolower( $matcher );
441 $matches = str_ends_with( $matcher, '/' )
442 ? str_starts_with( $endpoint, $matcher )
443 : $endpoint === $matcher;
444 if ( $matches ) {
445 return true;
446 }
447 }
448
449 return false;
450 }
451
452 /**
453 * Add the local user's email to a forwarded write body, for a group declaring
454 * `inject_user_email`.
455 *
456 * Mirrors what stats-admin's own `post_user_feedback()` does: the request is signed with the
457 * blog token, so WPCOM sees no user and would attribute the submission to whichever
458 * administrator it finds first. A body that is neither empty nor a JSON object is returned
459 * untouched rather than replaced, so a malformed request still fails at WPCOM's own validation.
460 *
461 * @param string $body The request body to forward.
462 * @param array<string, mixed> $opts The matched prefix config.
463 *
464 * @return string
465 */
466 private function inject_user_email( string $body, array $opts ): string {
467 if ( empty( $opts['inject_user_email'] ) ) {
468 return $body;
469 }
470
471 $email = wp_get_current_user()->user_email;
472 if ( ! $email ) {
473 return $body;
474 }
475
476 $decoded = '' === $body ? array() : json_decode( $body, true );
477 if ( ! is_array( $decoded ) ) {
478 return $body;
479 }
480
481 // A JSON list is not a param bag, and adding a string key would reshape it into an object.
482 if ( array() !== $decoded && array_keys( $decoded ) === range( 0, count( $decoded ) - 1 ) ) {
483 return $body;
484 }
485
486 $decoded['user_email'] = $email;
487
488 return (string) wp_json_encode( $decoded, JSON_UNESCAPED_SLASHES );
489 }
490
491 /**
492 * Whether a successful write to this endpoint should invalidate the matching read cache.
493 *
494 * @param string $endpoint The validated sub-path.
495 *
496 * @return bool
497 */
498 private function busts_cache( string $endpoint ): bool {
499 $config = $this->config_for( $endpoint );
500
501 return ! empty( $config['cache_bust'] );
502 }
503
504 /**
505 * Serve a cached payload when available, otherwise forward to WPCOM and cache the result.
506 *
507 * @param WP_REST_Request $request Request object.
508 * @param string $wpcom_path WPCOM path without the forwarded query string.
509 * @param array<string, mixed> $opts version | base | bust_on_write | cache overrides.
510 *
511 * @return WP_REST_Response|WP_Error
512 */
513 private function forward( WP_REST_Request $request, string $wpcom_path, array $opts ) {
514 $version = $opts['version'] ?? '2';
515 $base = $opts['base'] ?? 'wpcom';
516 $method = strtoupper( $request->get_method() );
517 $is_read = 'GET' === $method;
518 $cacheable = $is_read
519 && ( $opts['cache'] ?? true )
520 && null === $request->get_param( 'force_refresh' );
521
522 $cache_key = $cacheable ? $this->cache_key_for( $wpcom_path, $version, $base, $this->get_forwarded_params( $request ) ) : null;
523 if ( null !== $cache_key ) {
524 $cached = get_transient( $cache_key );
525 if ( false !== $cached ) {
526 return $this->build_response( $cached );
527 }
528 }
529
530 // Unsigned forwards need no tokens — only the blog id baked into the path —
531 // so they skip the connection gate (its blog-token requirement) entirely.
532 if ( ! empty( $opts['unauthenticated'] ) && $is_read ) {
533 $response = $this->request_unauthenticated( $request, $wpcom_path, $version, $base );
534 if ( is_wp_error( $response ) ) {
535 return $response;
536 }
537
538 return $this->cache_and_build_response( $response, $cache_key );
539 }
540
541 if ( ! ( new Manager( self::SLUG ) )->is_connected() ) {
542 return new WP_Error(
543 'no_connection',
544 __( 'Please connect Jetpack to load your data.', 'jetpack-premium-analytics-pkg' ),
545 array( 'status' => 403 )
546 );
547 }
548
549 $args = array(
550 'method' => $method,
551 'timeout' => self::API_TIMEOUT,
552 );
553 $body = null;
554 if ( ! $is_read ) {
555 $body = $this->inject_user_email( $request->get_body(), $opts );
556 $args['headers'] = array( 'Content-Type' => 'application/json' );
557 }
558
559 try {
560 $response = Client::wpcom_json_api_request_as_blog(
561 $this->append_forwarded_params( $request, $wpcom_path ),
562 $version,
563 $args,
564 $body,
565 $base
566 );
567 } catch ( \Exception $e ) {
568 return new WP_Error(
569 'api_error',
570 __( 'Error processing the request.', 'jetpack-premium-analytics-pkg' ),
571 array( 'status' => 500 )
572 );
573 }
574
575 if ( is_wp_error( $response ) ) {
576 return new WP_Error(
577 'api_error',
578 __( 'Error communicating with the data service.', 'jetpack-premium-analytics-pkg' ),
579 array( 'status' => 500 )
580 );
581 }
582
583 $this->maybe_bust_read_cache( $response, ! $is_read, $opts, $wpcom_path, $version, $base );
584
585 return $this->cache_and_build_response( $response, $cache_key );
586 }
587
588 /**
589 * Forward a read to WPCOM without signing, for `unauthenticated` endpoint groups. Mirrors
590 * stats-admin's Odyssey proxy (`get_single_post_likes()`): the target endpoint rejects
591 * blog-token auth but serves public data to credential-less requests. Private posts/sites
592 * return WPCOM's own restricted error — the same limitation Odyssey has.
593 *
594 * @param WP_REST_Request $request Request object.
595 * @param string $wpcom_path WPCOM path without the forwarded query string.
596 * @param string $version WPCOM API version.
597 * @param string $base WPCOM API base (`rest` or `wpcom`).
598 *
599 * @return array|WP_Error Raw HTTP response, or an error.
600 */
601 private function request_unauthenticated( WP_REST_Request $request, string $wpcom_path, string $version, string $base ) {
602 // The path embeds the blog id; without one the request would target site 0.
603 if ( ! (int) Jetpack_Options::get_option( 'id' ) ) {
604 return new WP_Error(
605 'no_connection',
606 __( 'Please connect Jetpack to load your data.', 'jetpack-premium-analytics-pkg' ),
607 array( 'status' => 403 )
608 );
609 }
610
611 $api_base = Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' );
612 if ( empty( $api_base ) ) {
613 $api_base = 'https://public-api.wordpress.com';
614 }
615
616 $response = wp_remote_get(
617 sprintf( '%s/%s/v%s%s', $api_base, $base, $version, $this->append_forwarded_params( $request, $wpcom_path ) ),
618 array( 'timeout' => self::API_TIMEOUT )
619 );
620
621 if ( is_wp_error( $response ) ) {
622 return new WP_Error(
623 'api_error',
624 __( 'Error communicating with the data service.', 'jetpack-premium-analytics-pkg' ),
625 array( 'status' => 500 )
626 );
627 }
628
629 return $response;
630 }
631
632 /**
633 * Mirror stats-admin: a successful write invalidates the matching (param-less) read cache, so
634 * the next GET reflects the change instead of serving the cached pre-write value. It busts only
635 * when the request was a write, the prefix opted in (`bust_on_write`), and WPCOM returned 200.
636 *
637 * This is a pure function of the response and route context — it takes the raw client response
638 * rather than reaching out to WPCOM itself, so the full bust decision is unit-testable without
639 * a live connection.
640 *
641 * @param array $http_response Raw response from the Jetpack client.
642 * @param bool $is_write Whether the request used a write (non-GET) method.
643 * @param array<string, mixed> $opts Forwarding opts (reads `bust_on_write`).
644 * @param string $wpcom_path WPCOM path without the forwarded query string.
645 * @param string $version WPCOM API version.
646 * @param string $base WPCOM API base.
647 *
648 * @return void
649 */
650 private function maybe_bust_read_cache( array $http_response, bool $is_write, array $opts, string $wpcom_path, string $version, string $base ): void {
651 if ( ! $is_write || empty( $opts['bust_on_write'] ) ) {
652 return;
653 }
654
655 if ( 200 !== (int) wp_remote_retrieve_response_code( $http_response ) ) {
656 return;
657 }
658
659 delete_transient( $this->cache_key_for( $wpcom_path, $version, $base, array() ) );
660 }
661
662 /**
663 * Cache a successful (200) response when a cache key is given, and return it to the caller.
664 *
665 * @param array $http_response Raw response from the Jetpack client.
666 * @param string|null $cache_key Transient key, or null to skip caching.
667 *
668 * @return WP_REST_Response|WP_Error
669 */
670 private function cache_and_build_response( array $http_response, ?string $cache_key ) {
671 $status = (int) wp_remote_retrieve_response_code( $http_response );
672 $data = json_decode( wp_remote_retrieve_body( $http_response ), false );
673
674 // A 200 with an undecodable body means the upstream is degraded; don't cache garbage.
675 if ( 200 === $status && null === $data && JSON_ERROR_NONE !== json_last_error() ) {
676 return new WP_Error(
677 'api_error',
678 __( 'The data service returned an unreadable response.', 'jetpack-premium-analytics-pkg' ),
679 array( 'status' => 502 )
680 );
681 }
682
683 $payload = array(
684 'data' => $data,
685 'status' => $status,
686 'headers' => $this->extract_forwarded_headers( wp_remote_retrieve_headers( $http_response ) ),
687 );
688
689 if ( null !== $cache_key && 200 === $status ) {
690 set_transient( $cache_key, $payload, self::CACHE_TTL );
691 }
692
693 return $this->build_response( $payload );
694 }
695
696 /**
697 * Rebuild a WP_REST_Response from a cached or freshly fetched payload.
698 *
699 * @param array $payload Stored payload with data, status, and headers.
700 *
701 * @return WP_REST_Response
702 */
703 private function build_response( array $payload ): WP_REST_Response {
704 $response = new WP_REST_Response( $payload['data'], (int) $payload['status'] );
705
706 foreach ( (array) $payload['headers'] as $name => $value ) {
707 $response->header( $name, $value );
708 }
709
710 return $response;
711 }
712
713 /**
714 * Keep only the response headers the dashboard needs (pagination totals).
715 *
716 * @param mixed $headers Response headers as returned by the HTTP API.
717 *
718 * @return array<string, string>
719 */
720 private function extract_forwarded_headers( $headers ): array {
721 if ( $headers instanceof \ArrayAccess || is_array( $headers ) ) {
722 $forwarded = array();
723 foreach ( self::FORWARDED_HEADERS as $name ) {
724 if ( isset( $headers[ $name ] ) ) {
725 $forwarded[ $name ] = (string) $headers[ $name ];
726 }
727 }
728 return $forwarded;
729 }
730
731 return array();
732 }
733
734 /**
735 * Append the forwarded query params to a WPCOM path, choosing the right separator.
736 *
737 * @param WP_REST_Request $request Request object.
738 * @param string $wpcom_path WPCOM path that may already carry a query string.
739 *
740 * @return string
741 */
742 private function append_forwarded_params( WP_REST_Request $request, string $wpcom_path ): string {
743 $params = $this->get_forwarded_params( $request );
744 if ( empty( $params ) ) {
745 return $wpcom_path;
746 }
747
748 $separator = str_contains( $wpcom_path, '?' ) ? '&' : '?';
749
750 return $wpcom_path . $separator . http_build_query( $params );
751 }
752
753 /**
754 * Query params to forward to WPCOM, minus the WordPress routing params, the proxy's own
755 * control params (`endpoint`, `version`, `force_refresh` — which a caller could also pass as
756 * query params since `get_param()` prefers GET), and `site` (the proxy pins the site itself,
757 * so a caller-supplied `site` must not reach the `upgrades` query string). Dropping the
758 * control params also keeps them out of the cache key.
759 *
760 * @param WP_REST_Request $request Request object.
761 *
762 * @return array
763 */
764 private function get_forwarded_params( WP_REST_Request $request ): array {
765 $params = $request->get_query_params();
766 unset( $params['rest_route'], $params['_locale'], $params['site'], $params['endpoint'], $params['version'], $params['force_refresh'] );
767
768 return is_array( $params ) ? $params : array();
769 }
770
771 /**
772 * Transient key for a target path + API version/base + forwarded params (order-independent).
773 * Version and base are part of the key so the same path at different versions doesn't collide.
774 *
775 * @param string $wpcom_path WPCOM path without the forwarded query string.
776 * @param string $version WPCOM API version.
777 * @param string $base WPCOM API base.
778 * @param array $params Forwarded query params.
779 *
780 * @return string
781 */
782 private function cache_key_for( string $wpcom_path, string $version, string $base, array $params ): string {
783 ksort( $params );
784 $signature = implode( '|', array( $wpcom_path, $version, $base, (string) wp_json_encode( $params, JSON_UNESCAPED_SLASHES ) ) );
785
786 return self::CACHE_PREFIX . md5( $signature );
787 }
788 }
789