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