PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
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-premium-analytics/src/REST/class-api-proxy-controller.php +788 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,788 @@
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 +}