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