Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.22% covered (success)
94.22%
212 / 225
91.67% covered (success)
91.67%
22 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Api_Proxy_Controller
94.22% covered (success)
94.22%
212 / 225
91.67% covered (success)
91.67%
22 / 24
83.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 register_transient_cleanup_prefix
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 register_routes
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 allowed_endpoint_pattern
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 config_for
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 check_data_permission
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 validate_data_endpoint
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 validate_version
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle_data_request
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 base_for_version
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 build_data_path
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 is_write_allowed
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 inject_user_email
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 busts_cache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 forward
78.85% covered (warning)
78.85%
41 / 52
0.00% covered (danger)
0.00%
0 / 1
14.60
 request_unauthenticated
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 maybe_bust_read_cache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 cache_and_build_response
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 build_response
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 extract_forwarded_headers
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 append_forwarded_params
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 get_forwarded_params
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 cache_key_for
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
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
8namespace Automattic\Jetpack\PremiumAnalytics\REST;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager;
12use Automattic\Jetpack\Constants;
13use Jetpack_Options;
14use WP_Error;
15use WP_REST_Controller;
16use WP_REST_Request;
17use WP_REST_Response;
18use 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 */
38class 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}